Compare commits
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "sanihome-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"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
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "sanihome-tmp",
|
||||
"name": "sanihome",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sanihome-tmp",
|
||||
"name": "sanihome",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"next": "16.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"swiper": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -631,9 +633,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -650,9 +649,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -669,9 +665,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -688,9 +681,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -707,9 +697,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -726,9 +713,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -745,9 +729,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -764,9 +745,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -783,9 +761,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -808,9 +783,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -833,9 +805,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -858,9 +827,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -883,9 +849,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -908,9 +871,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -933,9 +893,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -958,9 +915,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1228,9 +1182,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1247,9 +1198,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1266,9 +1214,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1285,9 +1230,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1526,9 +1468,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1546,9 +1485,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1566,9 +1502,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1586,9 +1519,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1676,6 +1606,19 @@
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.20",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz",
|
||||
"integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || >=4.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -2139,9 +2082,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2156,9 +2096,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2173,9 +2110,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2190,9 +2124,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2207,9 +2138,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2224,9 +2152,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2241,9 +2166,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2258,9 +2180,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2275,9 +2194,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2292,9 +2208,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2902,6 +2815,19 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -4979,9 +4905,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5003,9 +4926,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5027,9 +4947,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5051,9 +4968,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5670,6 +5584,20 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -6372,6 +6300,25 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swiper": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/swiper/-/swiper-14.1.0.tgz",
|
||||
"integrity": "sha512-ppit/AqmGvThKlXy4I1/9N/gEyb9s/BDHWciWNeIxbfteFKUhPQXrYuOMKszomXKd9WiLd+/0vKdPHW+0N0mhA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "custom",
|
||||
"url": "https://sponsors.nolimits4web.com"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nolimits4web"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
@@ -6733,6 +6680,13 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
"dependencies": {
|
||||
"next": "16.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"swiper": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
Domain: sanihome.ir
|
||||
Documentation for backend is:https://api.meshkee.com/docs/website
|
||||
|
||||
|
||||
Login or user icon should go to customer.sanihome.ir.com
|
||||
|
||||
Create product page and details >> connect to store items
|
||||
Create blog page and details >> connect to blog
|
||||
|
||||
Product Menu should be a mega menu with level 2 and 3 of categories - skip level 1
|
||||
|
||||
Search input should be instant search - activate when type more than 2 char
|
||||
|
||||
First carousel >> special-sale
|
||||
Second carousel >> top-selling
|
||||
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getProducts } from "@/lib/meshkee";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||
if (query.length <= 2) {
|
||||
return NextResponse.json({ items: [] });
|
||||
}
|
||||
|
||||
const { items } = await getProducts({ name: query, pageSize: 6 });
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { notFound } from "next/navigation";
|
||||
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,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await getBlog(slug);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { getBlogs } from "@/lib/meshkee";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "long" }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default async function BlogListPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number(params.page ?? 1) || 1);
|
||||
const { items, total } = await getBlogs({ page, pageSize: PAGE_SIZE });
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb items={[{ label: "خانه", href: "/" }, { label: "وبلاگ" }]} />
|
||||
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">وبلاگ</h1>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState message="هنوز مطلبی ثبت نشده است." />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((post) => (
|
||||
<a
|
||||
key={post.id}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="p-[10px]">
|
||||
<Placeholder
|
||||
src={post.image ?? "/images/placeholder.svg"}
|
||||
alt={post.title}
|
||||
width="100%"
|
||||
fit="contain"
|
||||
className="aspect-square w-full rounded-lg border-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted">
|
||||
{post.categoryName && <span>{post.categoryName}</span>}
|
||||
{post.publishedAt && <span>{formatDate(post.publishedAt)}</span>}
|
||||
</div>
|
||||
<h3 className="mb-2 line-clamp-2 text-sm font-bold leading-relaxed">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.abstract && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-muted">
|
||||
{post.abstract}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
{page > 1 ? (
|
||||
<a
|
||||
href={`/blog?page=${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={`/blog?page=${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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
--background: #fafafa;
|
||||
@@ -22,7 +23,8 @@
|
||||
--color-red-dark: var(--red-dark);
|
||||
--color-red-light: var(--red-light);
|
||||
--color-red-muted: var(--red-muted);
|
||||
--font-sans: var(--font-vazirmatn);
|
||||
--font-sans: var(--font-iranyekan);
|
||||
--font-sans-bold: var(--font-iranyekan-bold);
|
||||
--font-display: var(--font-syne);
|
||||
}
|
||||
|
||||
@@ -37,11 +39,21 @@ html {
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-vazirmatn), system-ui, sans-serif;
|
||||
font-family: var(--font-iranyekan), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Titles use the bold face; everything else stays on the light one. */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-iranyekan-bold), var(--font-iranyekan), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-syne), var(--font-vazirmatn), sans-serif;
|
||||
font-family: var(--font-syne), var(--font-iranyekan), sans-serif;
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
@@ -82,3 +94,123 @@ body {
|
||||
.carousel-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Vazirmatn, Syne } from "next/font/google";
|
||||
import { Syne } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { TopBar } from "@/components/home/TopBar";
|
||||
import { Header } from "@/components/home/Header";
|
||||
import { Footer } from "@/components/home/Footer";
|
||||
import { Providers } from "@/context/Providers";
|
||||
import {
|
||||
getBusinessInfo,
|
||||
getCategories,
|
||||
getCategoryTree,
|
||||
parseMetaTagHtml,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
const vazirmatn = Vazirmatn({
|
||||
variable: "--font-vazirmatn",
|
||||
subsets: ["arabic", "latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
// Body text — headings use the bold weight instead, see globals.css.
|
||||
const iranyekan = localFont({
|
||||
variable: "--font-iranyekan",
|
||||
src: "../../public/fonts/iranyekanweblightfanum.woff",
|
||||
weight: "400",
|
||||
});
|
||||
|
||||
const iranyekanBold = localFont({
|
||||
variable: "--font-iranyekan-bold",
|
||||
src: "../../public/fonts/iranyekanwebboldfanum.woff",
|
||||
weight: "700",
|
||||
});
|
||||
|
||||
const syne = Syne({
|
||||
@@ -14,19 +32,46 @@ const syne = Syne({
|
||||
weight: ["600", "700", "800"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "سانیهوم | فروشگاه لوازم خانگی",
|
||||
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([
|
||||
getCategoryTree(),
|
||||
getCategories(),
|
||||
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);
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
className={`${vazirmatn.variable} ${syne.variable} h-full antialiased`}
|
||||
className={`${iranyekan.variable} ${iranyekanBold.variable} ${syne.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
{cmsMetaTags.length > 0 ? <head>{cmsMetaTags}</head> : null}
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>
|
||||
<TopBar />
|
||||
<Header categoryTree={categoryTree} categories={categories} logoUrl={businessInfo?.logoUrl} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer businessInfo={businessInfo} categories={categories} />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,57 @@
|
||||
import { TopBar } from "@/components/home/TopBar";
|
||||
import { Header } from "@/components/home/Header";
|
||||
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 { SamsungCarousel } from "@/components/home/SamsungCarousel";
|
||||
import { StoreSpecialCarousels } from "@/components/home/StoreSpecialCarousels";
|
||||
import { AboutSanihome } from "@/components/home/AboutSanihome";
|
||||
import { BlogCarousel } from "@/components/home/BlogCarousel";
|
||||
import { Footer } from "@/components/home/Footer";
|
||||
import {
|
||||
STATIC_IMAGE_KEYS,
|
||||
firstImageForSlot,
|
||||
getBlogs,
|
||||
getBusinessInfo,
|
||||
getCategories,
|
||||
getStoreSpecials,
|
||||
getStaticImageSlots,
|
||||
firstImageForSlot,
|
||||
imagesForSlot,
|
||||
STATIC_IMAGE_KEYS,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
export default async function Home() {
|
||||
const slots = await getStaticImageSlots();
|
||||
const [categories, storeSpecials, staticImageSlots, businessInfo, blogsResult] = await Promise.all([
|
||||
getCategories(),
|
||||
getStoreSpecials(),
|
||||
getStaticImageSlots(),
|
||||
getBusinessInfo(),
|
||||
getBlogs({ pageSize: 8 }),
|
||||
]);
|
||||
|
||||
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||
const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner);
|
||||
const threeBannerSlot = staticImageSlots.find(
|
||||
(slot) => slot.key === STATIC_IMAGE_KEYS.threeBannerRow,
|
||||
);
|
||||
const heroImages = imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.homeHero);
|
||||
const featuredCategoryImages = imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.featuredCategories);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar />
|
||||
<Header />
|
||||
<main>
|
||||
<HeroSlider images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)} />
|
||||
<SpecialSaleCarousel
|
||||
banner={firstImageForSlot(slots, STATIC_IMAGE_KEYS.bannerInSpecialSale)}
|
||||
<HeroSlider images={heroImages} />
|
||||
<FeaturedCategories categories={categories} images={featuredCategoryImages} />
|
||||
<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}
|
||||
/>
|
||||
<FeatureBar />
|
||||
<FeaturedCategories
|
||||
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)}
|
||||
<AboutSanihome
|
||||
image={firstImageForSlot(staticImageSlots, STATIC_IMAGE_KEYS.aboutUsImage)}
|
||||
aspectRatio={staticImageSlots.find(
|
||||
(slot) => slot.key === STATIC_IMAGE_KEYS.aboutUsImage,
|
||||
)?.aspectRatio}
|
||||
/>
|
||||
<TwoBanners images={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerRow)} />
|
||||
<TopSellingCarousel />
|
||||
<ThreeBanners />
|
||||
<SamsungCarousel />
|
||||
<AboutSanihome />
|
||||
<BlogCarousel />
|
||||
</main>
|
||||
<Footer />
|
||||
<BlogCarousel posts={blogsResult.items} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function ProductDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
permanentRedirect(`/store-items/${slug}`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getProductById } from "@/lib/meshkee";
|
||||
|
||||
export default async function ProductByIdPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const product = await getProductById(id);
|
||||
|
||||
if (!product?.slug) notFound();
|
||||
|
||||
redirect(`/store-items/${product.slug}`);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function ProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const query = new URLSearchParams();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { blogPosts } from "@/data/sample";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||
import type { BlogSummary } from "@/lib/meshkee";
|
||||
|
||||
export function BlogCarousel() {
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "long" }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function BlogCarousel({ posts }: { posts: BlogSummary[] }) {
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
@@ -11,43 +18,49 @@ export function BlogCarousel() {
|
||||
title="آخرین مطالب وبلاگ"
|
||||
enTitle="Latest Blog"
|
||||
action="وبلاگ"
|
||||
actionHref="/blog"
|
||||
/>
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState message="هنوز مطلبی ثبت نشده است." />
|
||||
) : (
|
||||
<Carousel ariaLabel="کاروسل وبلاگ">
|
||||
{blogPosts.map((post) => (
|
||||
{posts.map((post) => (
|
||||
<article
|
||||
key={post.id}
|
||||
className="w-[280px] shrink-0 overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md md:w-[300px]"
|
||||
>
|
||||
<a href={`/blog/${post.slug}`}>
|
||||
<div className="p-[10px]">
|
||||
<Placeholder
|
||||
src={post.image}
|
||||
src={post.image ?? "/images/placeholder.svg"}
|
||||
alt={post.title}
|
||||
width="100%"
|
||||
height="160px"
|
||||
label="300×160"
|
||||
className="w-full rounded-none border-0 border-b border-dashed"
|
||||
fit="contain"
|
||||
className="aspect-square w-full rounded-lg border-0"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted">
|
||||
<span>{post.date}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} مطالعه</span>
|
||||
</div>
|
||||
<div className="p-4 pt-0">
|
||||
{post.publishedAt && (
|
||||
<div className="mb-2 text-[11px] text-muted">{formatDate(post.publishedAt)}</div>
|
||||
)}
|
||||
<h3 className="mb-2 line-clamp-2 text-sm font-bold leading-relaxed">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.abstract && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-muted">
|
||||
{post.excerpt}
|
||||
{post.abstract}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 text-xs font-medium text-red transition-colors hover:text-red-dark"
|
||||
>
|
||||
ادامه مطلب ←
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
</article>
|
||||
))}
|
||||
</Carousel>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,65 +1,104 @@
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { SlotImage } from "@/components/ui/SlotImage";
|
||||
import { categories } from "@/data/sample";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
"use client";
|
||||
|
||||
type FeaturedCategoriesProps = {
|
||||
images?: StaticImageWithUrl[];
|
||||
};
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { FreeMode } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/free-mode";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import type { Category, StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
export function FeaturedCategories({ images = [] }: FeaturedCategoriesProps) {
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title="دستهبندیهای پرطرفدار"
|
||||
enTitle="Featured Categories"
|
||||
action="همه دستهها"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{images.length > 0
|
||||
? images.map((image, i) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="animate-slide-in group flex flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
>
|
||||
<SlotImage
|
||||
url={image.url}
|
||||
title={image.title}
|
||||
linkUrl={image.linkUrl}
|
||||
imgClassName="mb-3 h-[72px] w-[72px] rounded-full object-cover transition-colors group-hover:border-red"
|
||||
/>
|
||||
{image.title ? (
|
||||
<span className="text-center text-xs font-medium leading-relaxed md:text-sm">
|
||||
{image.title}
|
||||
</span>
|
||||
) : null}
|
||||
function CategorySlotCard({ image }: { image: StaticImageWithUrl }) {
|
||||
const label = image.titleFa ?? image.titleEn ?? "";
|
||||
const content = (
|
||||
<div className="group flex h-full flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md">
|
||||
<div className="mb-3 w-[110px] overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image.url} alt={label} className="h-full w-full object-contain" />
|
||||
</div>
|
||||
))
|
||||
: categories.map((cat, i) => (
|
||||
{label && (
|
||||
<span className="w-full truncate text-center text-xs font-medium leading-relaxed md:text-sm">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return image.linkUrl ? <a href={image.linkUrl}>{content}</a> : content;
|
||||
}
|
||||
|
||||
export function FeaturedCategories({
|
||||
categories,
|
||||
images = [],
|
||||
}: {
|
||||
categories: Category[];
|
||||
images?: StaticImageWithUrl[];
|
||||
}) {
|
||||
return (
|
||||
<section className="py-4 md:py-6">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
{images.length > 0 ? (
|
||||
<Swiper
|
||||
modules={[FreeMode]}
|
||||
dir="rtl"
|
||||
slidesPerView={2.3}
|
||||
spaceBetween={12}
|
||||
breakpoints={{
|
||||
640: { slidesPerView: 4.3, spaceBetween: 12 },
|
||||
1024: { slidesPerView: 8, spaceBetween: 12 },
|
||||
}}
|
||||
freeMode={{ enabled: true, momentum: true, sticky: true }}
|
||||
grabCursor
|
||||
className="!px-1 !py-2"
|
||||
>
|
||||
{images.map((image, i) => (
|
||||
<SwiperSlide key={image.id}>
|
||||
<div className="animate-slide-in h-full" style={{ animationDelay: `${i * 60}ms` }}>
|
||||
<CategorySlotCard image={image} />
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState message="هنوز دستهبندیای ثبت نشده است." />
|
||||
) : (
|
||||
<Swiper
|
||||
modules={[FreeMode]}
|
||||
dir="rtl"
|
||||
slidesPerView={2.3}
|
||||
spaceBetween={12}
|
||||
breakpoints={{
|
||||
640: { slidesPerView: 4.3, spaceBetween: 12 },
|
||||
1024: { slidesPerView: 8, spaceBetween: 12 },
|
||||
}}
|
||||
freeMode={{ enabled: true, momentum: true, sticky: true }}
|
||||
grabCursor
|
||||
className="!px-1 !py-2"
|
||||
>
|
||||
{categories.map((cat, i) => (
|
||||
<SwiperSlide key={cat.id}>
|
||||
<a
|
||||
key={cat.id}
|
||||
href="#"
|
||||
className="animate-slide-in group flex flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md"
|
||||
className="animate-slide-in group flex h-full flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
>
|
||||
<Placeholder
|
||||
src={cat.image}
|
||||
<div className="mb-3 w-[110px] overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={cat.image ?? "/images/placeholder.svg"}
|
||||
alt={cat.name}
|
||||
width="72px"
|
||||
height="72px"
|
||||
label="72×72"
|
||||
className="mb-3 rounded-full transition-colors group-hover:border-red"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
<span className="text-center text-xs font-medium leading-relaxed md:text-sm">
|
||||
</div>
|
||||
<span className="w-full truncate text-center text-xs font-medium leading-relaxed md:text-sm">
|
||||
{cat.name}
|
||||
</span>
|
||||
{cat.count != null && (
|
||||
<span className="mt-1 text-[11px] text-muted">{cat.count} محصول</span>
|
||||
)}
|
||||
</a>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</div>
|
||||
</Swiper>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,79 +1,158 @@
|
||||
export function Footer() {
|
||||
import type { BusinessInfo, Category } from "@/lib/meshkee";
|
||||
|
||||
const quickLinks = ["درباره ما", "تماس با ما", "سوالات متداول", "قوانین و مقررات", "پیگیری سفارش"];
|
||||
|
||||
const socialLabels: Record<string, string> = {
|
||||
instagram: "IG",
|
||||
telegram: "TG",
|
||||
whatsapp: "WA",
|
||||
linkedin: "IN",
|
||||
youtube: "YT",
|
||||
aparat: "AP",
|
||||
};
|
||||
|
||||
export function Footer({
|
||||
businessInfo,
|
||||
categories,
|
||||
}: {
|
||||
businessInfo: BusinessInfo | null;
|
||||
categories: Category[];
|
||||
}) {
|
||||
const socialEntries = businessInfo
|
||||
? Object.entries(businessInfo.socialMedia).filter(([, url]) => !!url)
|
||||
: [];
|
||||
const hasContactInfo = Boolean(
|
||||
businessInfo &&
|
||||
(businessInfo.addresses.length || businessInfo.phoneNumbers.length || businessInfo.emails.length),
|
||||
);
|
||||
|
||||
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>
|
||||
{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>
|
||||
<p className="text-sm leading-relaxed text-white/70">
|
||||
فروشگاه آنلاین لوازم خانگی با بیش از ۱۵ سال سابقه. محصولات اصل،
|
||||
گارانتی معتبر، ارسال سریع.
|
||||
</p>
|
||||
{businessInfo?.about && (
|
||||
<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">
|
||||
{["درباره ما", "تماس با ما", "سوالات متداول", "قوانین و مقررات", "پیگیری سفارش"].map(
|
||||
(link) => (
|
||||
<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>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-bold">دستهبندیها</h4>
|
||||
<ul className="space-y-2 text-sm text-white/70">
|
||||
{["یخچال و فریزر", "ماشین لباسشویی", "تلویزیون", "جاروبرقی", "لوازم آشپزخانه"].map(
|
||||
(link) => (
|
||||
<li key={link}>
|
||||
<a href="#" className="transition-colors hover:text-white">
|
||||
{link}
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-sm text-black/50">هنوز دستهبندیای ثبت نشده است.</p>
|
||||
) : (
|
||||
<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-black">
|
||||
{cat.name}
|
||||
</a>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-bold">تماس با ما</h4>
|
||||
<ul className="space-y-2 text-sm text-white/70">
|
||||
<li>تهران، خیابان ولیعصر</li>
|
||||
<li>تلفن: ۰۲۱-۸۸۰۰۱۲۳۴</li>
|
||||
<li>ایمیل: info@sanihome.ir</li>
|
||||
{hasContactInfo ? (
|
||||
<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>
|
||||
))}
|
||||
{businessInfo?.emails.map((email) => <li key={email}>ایمیل: {email}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-black/50">هنوز اطلاعات تماس ثبت نشده است.</p>
|
||||
)}
|
||||
{socialEntries.length > 0 && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
{["IG", "TG", "WA"].map((social) => (
|
||||
{socialEntries.map(([key, url]) => (
|
||||
<a
|
||||
key={social}
|
||||
href="#"
|
||||
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"
|
||||
key={key}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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"
|
||||
>
|
||||
{social}
|
||||
{socialLabels[key] ?? key.slice(0, 2).toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -1,41 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { categories } from "@/data/sample";
|
||||
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 megaColumns = [
|
||||
{
|
||||
title: "سرمایش و گرمایش",
|
||||
en: "Cooling & Heating",
|
||||
items: ["یخچال و فریزر", "کولر گازی", "آبگرمکن", "بخاری"],
|
||||
},
|
||||
{
|
||||
title: "شستشو",
|
||||
en: "Laundry & Care",
|
||||
items: ["ماشین لباسشویی", "ماشین ظرفشویی", "خشککن", "اتو بخار"],
|
||||
},
|
||||
{
|
||||
title: "صوتی و تصویری",
|
||||
en: "AV & Display",
|
||||
items: ["تلویزیون", "ساندبار", "سینمای خانگی", "پروژکتور"],
|
||||
},
|
||||
{
|
||||
title: "آشپزخانه",
|
||||
en: "Kitchen",
|
||||
items: ["مایکروویو", "جاروبرقی", "هود", "اجاق گاز"],
|
||||
},
|
||||
];
|
||||
const LOGIN_URL = "https://customer.sanihome.ir";
|
||||
|
||||
export function Header() {
|
||||
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={src || "/images/logo.png"}
|
||||
alt="صنیع هوم"
|
||||
className={
|
||||
large
|
||||
? "h-[65px] w-auto object-contain"
|
||||
: "h-[65px] w-auto object-contain"
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function flattenLevel2(categoryTree: CategoryTreeGroup[]): CategoryNode[] {
|
||||
return categoryTree.flatMap((group) => group.items);
|
||||
}
|
||||
|
||||
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);
|
||||
const level2 = flattenLevel2(categoryTree);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="min-[1200px]:hidden" aria-hidden={!open}>
|
||||
<div
|
||||
onClick={onClose}
|
||||
className={`fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm transition-opacity duration-300 ${
|
||||
open ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`fixed top-0 right-0 z-[110] h-dvh w-[85%] max-w-sm overflow-y-auto bg-surface shadow-2xl transition-transform duration-300 ease-out ${
|
||||
open ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border p-5">
|
||||
<Logo src={logoUrl} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن منو"
|
||||
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>
|
||||
|
||||
<nav className="flex flex-col text-sm">
|
||||
<a href="/" className="border-b border-border px-5 py-4 transition-colors hover:bg-red-light/40 hover:text-red">
|
||||
خانه
|
||||
</a>
|
||||
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategoriesOpen((v) => !v)}
|
||||
aria-expanded={categoriesOpen}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-right transition-colors hover:bg-red-light/40 hover:text-red"
|
||||
>
|
||||
دستهبندیها
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 shrink-0 transition-transform duration-200 ${
|
||||
categoriesOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`grid transition-all duration-300 ease-out ${
|
||||
categoriesOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{level2.length === 0 ? (
|
||||
<div className="bg-background px-5 pb-4">
|
||||
<EmptyState message="هنوز دستهبندیای ثبت نشده است." />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1 bg-background px-5 pb-4">
|
||||
{level2.map((node) => (
|
||||
<li key={node.id}>
|
||||
<a
|
||||
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}
|
||||
</a>
|
||||
{node.children.length > 0 && (
|
||||
<ul className="mr-3 space-y-1 border-r border-border pr-3">
|
||||
{node.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<a
|
||||
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}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="border-b border-border px-5 py-4 transition-colors hover:bg-red-light/40 hover:text-red"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
ورود
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({
|
||||
categoryTree,
|
||||
categories,
|
||||
logoUrl,
|
||||
}: {
|
||||
categoryTree: CategoryTreeGroup[];
|
||||
categories: Category[];
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const level2 = flattenLevel2(categoryTree);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (event: MouseEvent) => {
|
||||
@@ -50,58 +226,60 @@ 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="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red text-lg font-bold text-white">
|
||||
S
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-xl font-bold tracking-tight">
|
||||
<span className="text-red">SANI</span>
|
||||
<span className="text-foreground">HOME</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted">فروشگاه لوازم خانگی</p>
|
||||
<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 transition-colors hover:border-red hover:text-red min-[1200px]:hidden"
|
||||
>
|
||||
<MenuIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="hidden min-[1200px]:block">
|
||||
<Logo src={logoUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 px-6 md:block">
|
||||
<div className="flex overflow-hidden rounded-xl border border-border bg-background">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="جستجوی محصول، برند یا دستهبندی..."
|
||||
className="flex-1 bg-transparent px-4 py-2.5 text-sm outline-none placeholder:text-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red px-5 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
جستجو
|
||||
</button>
|
||||
<div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6">
|
||||
<div className="min-[1200px]:hidden">
|
||||
<Logo src={logoUrl} large />
|
||||
</div>
|
||||
<div className="hidden w-full min-[1200px]:block">
|
||||
<SearchBar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="hidden items-center gap-1.5 text-muted transition-colors hover:text-red sm:flex"
|
||||
<div className="flex items-center justify-end gap-3 text-sm">
|
||||
<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>👤</span>
|
||||
<span>ورود</span>
|
||||
</button>
|
||||
<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 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>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<nav className="relative flex items-center gap-6 overflow-visible border-t border-border py-3 text-sm">
|
||||
<div className="border-t border-border py-3 min-[1200px]:hidden">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<nav className="relative hidden items-center gap-6 overflow-visible border-t border-border py-3 text-sm min-[1200px]:flex">
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="relative"
|
||||
@@ -115,12 +293,12 @@ 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>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute top-full right-0 z-50 mt-0 w-[min(92vw,760px)] animate-fade-up rounded-b-2xl border border-t-0 border-border bg-surface p-5 shadow-xl">
|
||||
<div className="absolute top-full right-0 z-50 mt-0 max-h-[70vh] w-[min(92vw,900px)] animate-fade-up overflow-y-auto rounded-b-2xl border border-t-0 border-border bg-surface p-5 shadow-xl">
|
||||
<div className="mb-4 flex items-end justify-between gap-3 border-b border-border pb-3">
|
||||
<div>
|
||||
<p className="font-bold text-red-dark">همه دستهبندیها</p>
|
||||
@@ -128,45 +306,59 @@ export function Header() {
|
||||
Shop by Category
|
||||
</p>
|
||||
</div>
|
||||
<a href="#" className="text-xs font-medium text-red hover:text-red-dark">
|
||||
مشاهده همه ←
|
||||
<a
|
||||
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>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{megaColumns.map((column) => (
|
||||
<div key={column.title}>
|
||||
<p className="mb-0.5 text-sm font-bold text-red-dark">{column.title}</p>
|
||||
<p className="font-display mb-3 text-[10px] tracking-wider text-muted uppercase">
|
||||
{column.en}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{column.items.map((item) => (
|
||||
<li key={item}>
|
||||
{level2.length === 0 ? (
|
||||
<EmptyState message="هنوز دستهبندیای ثبت نشده است." />
|
||||
) : (
|
||||
<div className="columns-1 gap-6 sm:columns-2 lg:columns-4">
|
||||
{level2.map((node) => (
|
||||
<div key={node.id} className="mb-4 break-inside-avoid">
|
||||
<a
|
||||
href="#"
|
||||
href={`/store-items?categoryId=${node.id}`}
|
||||
className="text-sm font-bold text-red-dark transition-colors hover:text-red"
|
||||
>
|
||||
{node.nameFa}
|
||||
</a>
|
||||
{node.children.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-1.5">
|
||||
{node.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<a
|
||||
href={`/store-items?categoryId=${child.id}`}
|
||||
className="text-sm text-muted transition-colors hover:text-red"
|
||||
>
|
||||
{item}
|
||||
{child.nameFa}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="mt-5 grid grid-cols-2 gap-2 border-t border-border pt-4 sm:grid-cols-4">
|
||||
{categories.slice(0, 4).map((cat) => (
|
||||
<a
|
||||
key={cat.id}
|
||||
href="#"
|
||||
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}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -182,6 +374,13 @@ export function Header() {
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<MobileDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
categoryTree={categoryTree}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,85 +2,78 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { SlotImage } from "@/components/ui/SlotImage";
|
||||
import { sliderSlides } from "@/data/sample";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
type HeroSliderProps = {
|
||||
images?: StaticImageWithUrl[];
|
||||
};
|
||||
const PEEK_OFFSET = 70;
|
||||
|
||||
export function HeroSlider({ images = [] }: HeroSliderProps) {
|
||||
const remote = images.length > 0;
|
||||
const slides = remote
|
||||
type Slide = { id: string; title: string; image: string; href?: string };
|
||||
|
||||
export function HeroSlider({ images = [] }: { images?: StaticImageWithUrl[] }) {
|
||||
const slides: Slide[] =
|
||||
images.length > 0
|
||||
? images.map((image) => ({
|
||||
id: image.id,
|
||||
title: image.title ?? "",
|
||||
src: image.url,
|
||||
linkUrl: image.linkUrl,
|
||||
title: image.titleFa ?? image.titleEn ?? "",
|
||||
image: image.url,
|
||||
href: image.linkUrl ?? undefined,
|
||||
}))
|
||||
: sliderSlides.map((slide) => ({
|
||||
id: String(slide.id),
|
||||
title: slide.title,
|
||||
src: slide.image,
|
||||
linkUrl: undefined as string | null | undefined,
|
||||
image: slide.image,
|
||||
}));
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
const len = slides.length;
|
||||
|
||||
const getIndex = (offset: number) => (active + offset + len) % len;
|
||||
|
||||
const positions = [
|
||||
{ offset: -1, scale: "scale-[0.92]", opacity: "opacity-55", z: "z-10" },
|
||||
{ offset: 0, scale: "scale-100", opacity: "opacity-100", z: "z-20" },
|
||||
{ offset: 1, scale: "scale-[0.92]", opacity: "opacity-55", z: "z-10" },
|
||||
];
|
||||
const getDiff = (i: number) => {
|
||||
let diff = i - active;
|
||||
if (diff > len / 2) diff -= len;
|
||||
if (diff < -len / 2) diff += len;
|
||||
return diff;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="overflow-x-clip bg-gradient-to-b from-red-light/30 to-background py-4 md:py-6">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-4">
|
||||
<div className="relative h-[220px] sm:h-[280px] md:h-[340px] lg:h-[380px]">
|
||||
{positions.map(({ offset, scale, opacity, z }) => {
|
||||
const isCenter = offset === 0;
|
||||
if (!isCenter && len < 2) return null;
|
||||
const slide = slides[getIndex(offset)];
|
||||
{slides.map((slide, i) => {
|
||||
const diff = getDiff(i);
|
||||
const isCenter = diff === 0;
|
||||
const isVisible = Math.abs(diff) <= 1;
|
||||
|
||||
const heightClass = `w-full h-[200px] sm:h-[260px] md:h-[310px] ${
|
||||
isCenter ? "lg:h-[380px]" : "lg:h-[350px]"
|
||||
}`;
|
||||
|
||||
const media = remote ? (
|
||||
<SlotImage
|
||||
url={slide.src}
|
||||
title={slide.title}
|
||||
linkUrl={isCenter ? slide.linkUrl : undefined}
|
||||
className="block h-full"
|
||||
imgClassName={`${heightClass} object-cover`}
|
||||
/>
|
||||
) : (
|
||||
const media = (
|
||||
<Placeholder
|
||||
src={slide.src}
|
||||
src={slide.image}
|
||||
alt={slide.title}
|
||||
width="100%"
|
||||
label={isCenter ? "1280×380" : "880×350"}
|
||||
className={`rounded-none border-0 ${heightClass}`}
|
||||
className="h-full w-full rounded-none border-0"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={offset}
|
||||
className={`absolute top-1/2 -translate-y-1/2 transition-all duration-500 ease-out ${scale} ${opacity} ${z} ${
|
||||
isCenter
|
||||
? "inset-x-0 w-full origin-center"
|
||||
: offset === -1
|
||||
? "right-full mr-1.5 w-[78%] origin-right sm:w-[70%] md:w-[65%]"
|
||||
: "left-full ml-1.5 w-[78%] origin-left sm:w-[70%] md:w-[65%]"
|
||||
} ${isCenter ? "" : "pointer-events-none"}`}
|
||||
key={slide.id}
|
||||
className={`absolute inset-0 flex items-center justify-center ${
|
||||
isCenter ? "z-20" : "z-10 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-lg">
|
||||
<div
|
||||
className={`h-[200px] overflow-hidden rounded-2xl border border-border bg-surface shadow-lg transition-all duration-500 ease-out sm:h-[260px] md:h-[310px] lg:h-[380px] ${
|
||||
isCenter
|
||||
? "w-[94%] scale-100 opacity-100 sm:w-[90%] md:w-[86%] lg:w-[82%]"
|
||||
: "w-[66%] scale-[0.85] opacity-55 sm:w-[60%] md:w-[54%] lg:w-[48%]"
|
||||
} ${isVisible ? "" : "opacity-0"}`}
|
||||
style={{ transform: `translateX(${-diff * PEEK_OFFSET}%)` }}
|
||||
>
|
||||
{slide.href ? (
|
||||
<a href={slide.href} className="block h-full w-full">
|
||||
{media}
|
||||
</a>
|
||||
) : (
|
||||
media
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { samsungProducts } from "@/data/sample";
|
||||
|
||||
export function SamsungCarousel() {
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title="محصولات سامسونگ"
|
||||
enTitle="Samsung Collection"
|
||||
action="مشاهده همه"
|
||||
/>
|
||||
<Carousel ariaLabel="کاروسل محصولات سامسونگ">
|
||||
{samsungProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand}
|
||||
price={product.price}
|
||||
image={product.image}
|
||||
variant="brand"
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"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);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length <= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||
const data = (await res.json()) as { items: Product[] };
|
||||
setResults(data.items);
|
||||
setOpen(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onClickOutside);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) router.push(`/store-items?q=${encodeURIComponent(trimmed)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative flex overflow-hidden rounded-xl border border-border bg-background">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
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="جستجوی محصول، برند یا دستهبندی..."
|
||||
className="flex-1 bg-transparent px-4 py-2.5 text-sm outline-none placeholder:text-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="bg-red px-5 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
جستجو
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 left-0 z-50 mt-2 max-h-[70vh] overflow-y-auto rounded-xl border border-border bg-surface shadow-xl">
|
||||
{loading ? (
|
||||
<p className="p-4 text-center text-sm text-muted">در حال جستجو...</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="p-4 text-center text-sm text-muted">نتیجهای یافت نشد.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{results.map((product) => (
|
||||
<li key={product.id}>
|
||||
<a
|
||||
href={`/store-items/${product.slug}`}
|
||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-red-light/40"
|
||||
>
|
||||
<Placeholder
|
||||
src={product.image ?? "/images/placeholder.svg"}
|
||||
alt={product.name}
|
||||
width="48px"
|
||||
height="48px"
|
||||
className="shrink-0 rounded-lg"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{product.name}</p>
|
||||
<p className="text-xs text-muted">
|
||||
{product.price != null ? `${formatPrice(product.price)} تومان` : "قیمت نامشخص"}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +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 { SlotImage } from "@/components/ui/SlotImage";
|
||||
import { specialSaleProducts } from "@/data/sample";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
type SpecialSaleCarouselProps = {
|
||||
banner?: StaticImageWithUrl;
|
||||
};
|
||||
|
||||
function SpecialSaleBanner({ banner }: { banner?: StaticImageWithUrl }) {
|
||||
const frameClassName =
|
||||
"relative w-[220px] shrink-0 self-stretch overflow-hidden rounded-xl border border-border md:w-[240px]";
|
||||
|
||||
if (banner) {
|
||||
return (
|
||||
<div className={frameClassName}>
|
||||
<SlotImage
|
||||
url={banner.url}
|
||||
title={banner.title}
|
||||
linkUrl={banner.linkUrl}
|
||||
alt={banner.title ?? "فروش ویژه"}
|
||||
className="block h-full"
|
||||
imgClassName="h-full min-h-[380px] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href="#" className={frameClassName}>
|
||||
<Placeholder
|
||||
src="/images/sale-banner.svg"
|
||||
alt="فروش ویژه"
|
||||
width="100%"
|
||||
label="240×380"
|
||||
className="h-full min-h-[380px] w-full rounded-none border-0"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecialSaleCarousel({ banner }: SpecialSaleCarouselProps) {
|
||||
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="مشاهده همه"
|
||||
/>
|
||||
<Carousel ariaLabel="کاروسل فروش ویژه">
|
||||
<SpecialSaleBanner banner={banner} />
|
||||
{specialSaleProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand}
|
||||
price={product.price}
|
||||
oldPrice={product.oldPrice}
|
||||
discount={product.discount}
|
||||
image={product.image}
|
||||
variant="sale"
|
||||
/>
|
||||
))}
|
||||
</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,34 +1,52 @@
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
const banners = [
|
||||
{ title: "ماشین لباسشویی", subtitle: "از ۲۸ میلیون", label: "420×160", image: "/images/promo-1.svg" },
|
||||
{ title: "جاروبرقی", subtitle: "ارسال فوری", label: "420×160", image: "/images/promo-2.svg" },
|
||||
{ title: "لوازم آشپزخانه", subtitle: "تا ۲۰٪ تخفیف", label: "420×160", image: "/images/promo-3.svg" },
|
||||
const fallbackBanners = [
|
||||
{ src: "/images/banner3/6.jpg", alt: "خانهای تمیزتر، نفسهایی آسودهتر" },
|
||||
{ src: "/images/banner3/7.jpg", alt: "خنکای دلنشین، در تمام لحظهها" },
|
||||
{ src: "/images/banner3/8.jpg", alt: "هر تصویر، یک تجربه تازه" },
|
||||
];
|
||||
|
||||
export function ThreeBanners() {
|
||||
export function ThreeBanners({
|
||||
images = [],
|
||||
aspectRatio,
|
||||
}: {
|
||||
images?: StaticImageWithUrl[];
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
const style = aspectRatio ? { aspectRatio: aspectRatio.replace(":", " / ") } : undefined;
|
||||
|
||||
return (
|
||||
<section className="border-y border-border bg-background py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{banners.map((banner) => (
|
||||
<a
|
||||
key={banner.title}
|
||||
href="#"
|
||||
className="group relative overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-red hover:shadow-md"
|
||||
>
|
||||
<Placeholder
|
||||
src={banner.image}
|
||||
alt={banner.title}
|
||||
width="100%"
|
||||
height="160px"
|
||||
label={banner.label}
|
||||
className="w-full rounded-none border-0"
|
||||
{images.length > 0
|
||||
? images.map((image) => {
|
||||
const frame = (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.titleFa ?? image.titleEn ?? ""}
|
||||
style={style}
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col items-start justify-end bg-gradient-to-t from-black/60 to-transparent p-5">
|
||||
<h3 className="font-bold text-white">{banner.title}</h3>
|
||||
<p className="text-sm text-white/75">{banner.subtitle}</p>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={image.id}
|
||||
className="group overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-red hover:shadow-md"
|
||||
>
|
||||
{image.linkUrl ? <a href={image.linkUrl}>{frame}</a> : frame}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: fallbackBanners.map((banner) => (
|
||||
<a
|
||||
key={banner.src}
|
||||
href="#"
|
||||
className="group overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-red hover:shadow-md"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={banner.src} alt={banner.alt} className="h-auto w-full" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { topSellingProducts } from "@/data/sample";
|
||||
|
||||
export function TopSellingCarousel() {
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title="پرفروشترینها"
|
||||
enTitle="Top Selling"
|
||||
action="مشاهده همه"
|
||||
/>
|
||||
<Carousel ariaLabel="کاروسل پرفروشترینها">
|
||||
{topSellingProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand}
|
||||
price={product.price}
|
||||
image={product.image}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,51 @@
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { SlotImage } from "@/components/ui/SlotImage";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
type TwoBannersProps = {
|
||||
images?: StaticImageWithUrl[];
|
||||
};
|
||||
|
||||
const fallbackBanners = [
|
||||
{
|
||||
src: "/images/banner-1.svg",
|
||||
alt: "تخفیف ویژه یخچال و فریزر",
|
||||
kicker: "Summer Sale",
|
||||
title: "تخفیف ویژه یخچال و فریزر",
|
||||
subtitle: "تا ۲۵٪ تخفیف — تا پایان مرداد",
|
||||
},
|
||||
{
|
||||
src: "/images/banner-2.svg",
|
||||
alt: "تلویزیونهای هوشمند ۲۰۲۶",
|
||||
kicker: "Smart TV",
|
||||
title: "تلویزیونهای هوشمند ۲۰۲۶",
|
||||
subtitle: "QLED و OLED با اقساط ۱۲ ماهه",
|
||||
},
|
||||
{ src: "/images/banner/5.jpg", alt: "وقتی که انرژی لازمی" },
|
||||
{ src: "/images/banner/4.jpg", alt: "قدرت بخار، درخشش بینقص" },
|
||||
];
|
||||
|
||||
export function TwoBanners({ images = [] }: TwoBannersProps) {
|
||||
export function TwoBanners({
|
||||
images = [],
|
||||
aspectRatio,
|
||||
}: {
|
||||
images?: StaticImageWithUrl[];
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
const style = aspectRatio ? { aspectRatio: aspectRatio.replace(":", " / ") } : undefined;
|
||||
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{images.length > 0
|
||||
? images.map((image) => (
|
||||
? images.map((image) => {
|
||||
const frame = (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.titleFa ?? image.titleEn ?? ""}
|
||||
style={style}
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={image.id}
|
||||
className="group relative overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
|
||||
className="group overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<SlotImage
|
||||
url={image.url}
|
||||
title={image.title}
|
||||
linkUrl={image.linkUrl}
|
||||
className="block"
|
||||
imgClassName="block h-[200px] w-full object-cover"
|
||||
/>
|
||||
{image.linkUrl ? <a href={image.linkUrl}>{frame}</a> : frame}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
: fallbackBanners.map((banner) => (
|
||||
<a
|
||||
key={banner.src}
|
||||
href="#"
|
||||
className="group relative overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
|
||||
className="group overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<Placeholder
|
||||
src={banner.src}
|
||||
alt={banner.alt}
|
||||
width="100%"
|
||||
height="200px"
|
||||
label="640×200"
|
||||
className="w-full rounded-none border-0"
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-red-dark/80 to-transparent p-6">
|
||||
<p className="font-display text-xs tracking-widest text-red-muted uppercase">
|
||||
{banner.kicker}
|
||||
</p>
|
||||
<h3 className="text-lg font-bold text-white">{banner.title}</h3>
|
||||
<p className="mt-1 text-sm text-white/80">{banner.subtitle}</p>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={banner.src} alt={banner.alt} className="h-auto w-full" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d={d} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const go = (delta: number) => {
|
||||
setIndex((prev) => (prev + delta + list.length) % list.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div
|
||||
className="relative aspect-square w-full overflow-hidden rounded-2xl border border-border bg-surface"
|
||||
onMouseMove={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setOrigin({
|
||||
x: ((event.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((event.clientY - rect.top) / rect.height) * 100,
|
||||
});
|
||||
}}
|
||||
onMouseEnter={() => setZoomed(true)}
|
||||
onMouseLeave={() => setZoomed(false)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<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-in" : "zoom-in",
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasMultiple && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
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="next" className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
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="prev" className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMultiple && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{list.map((img, i) => (
|
||||
<button
|
||||
key={img + i}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`تصویر ${i + 1}`}
|
||||
className={`h-16 w-16 shrink-0 overflow-hidden rounded-lg border-2 transition-colors ${
|
||||
i === index ? "border-red" : "border-border hover:border-red-muted"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={img} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxOpen && (
|
||||
<Lightbox
|
||||
images={list}
|
||||
alt={alt}
|
||||
index={index}
|
||||
onIndexChange={setIndex}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
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 (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M5 12.5 9.5 17 19 7.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductPurchasePanel({
|
||||
header,
|
||||
variations,
|
||||
storeVariants,
|
||||
fallbackPrice,
|
||||
inStock,
|
||||
contactPhone,
|
||||
storeName,
|
||||
initialSelections,
|
||||
productName,
|
||||
productSlug,
|
||||
productImage,
|
||||
}: {
|
||||
header: ReactNode;
|
||||
variations: ProductVariation[];
|
||||
storeVariants: ProductStoreVariant[];
|
||||
fallbackPrice: {
|
||||
minPrice: number | null;
|
||||
maxPrice: number | null;
|
||||
oldPrice?: number | null;
|
||||
discount?: number;
|
||||
};
|
||||
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;
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [cartStatus, setCartStatus] = useState<"idle" | "done">("idle");
|
||||
|
||||
const matchedVariant = useMemo(() => {
|
||||
if (storeVariants.length === 0) return null;
|
||||
if (variations.length === 0) return storeVariants[0] ?? null;
|
||||
return (
|
||||
storeVariants.find((variant) =>
|
||||
variations.every((variation) => {
|
||||
const selection = variant.selections.find((s) => s.variationId === variation.id);
|
||||
return selection?.optionId === selected[variation.id];
|
||||
}),
|
||||
) ?? null
|
||||
);
|
||||
}, [variations, storeVariants, selected]);
|
||||
|
||||
const hasVariations = variations.length > 0;
|
||||
const price = hasVariations
|
||||
? (matchedVariant?.discountedPrice ?? matchedVariant?.price ?? null)
|
||||
: fallbackPrice.minPrice;
|
||||
const priceRangeLabel =
|
||||
!hasVariations &&
|
||||
fallbackPrice.minPrice != null &&
|
||||
fallbackPrice.maxPrice != null &&
|
||||
fallbackPrice.minPrice !== fallbackPrice.maxPrice
|
||||
? `${formatPrice(fallbackPrice.minPrice)} - ${formatPrice(fallbackPrice.maxPrice)}`
|
||||
: null;
|
||||
|
||||
const variantOldPrice =
|
||||
matchedVariant?.discountedPrice != null &&
|
||||
matchedVariant?.price != null &&
|
||||
matchedVariant.price !== matchedVariant.discountedPrice
|
||||
? matchedVariant.price
|
||||
: null;
|
||||
const oldPrice = hasVariations ? variantOldPrice : (fallbackPrice.oldPrice ?? null);
|
||||
const discount = hasVariations
|
||||
? (oldPrice != null && price != null ? Math.round((1 - price / oldPrice) * 100) : null)
|
||||
: (fallbackPrice.discount ?? null);
|
||||
|
||||
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">
|
||||
{header}
|
||||
|
||||
{hasVariations && (
|
||||
<div className="mt-6 space-y-5">
|
||||
{variations.map((variation) => (
|
||||
<div key={variation.id}>
|
||||
<p className="mb-2 text-sm font-bold">
|
||||
{variation.type === "color" ? "انتخاب رنگ" : variation.name}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variation.options.map((option) => {
|
||||
const isSelected = selected[variation.id] === option.id;
|
||||
const swatch = option.colorHex ?? (variation.type === "color" ? option.value : null);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelected((prev) => ({ ...prev, [variation.id]: option.id }))
|
||||
}
|
||||
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
||||
isSelected
|
||||
? "border-red text-red-dark"
|
||||
: "border-border text-foreground hover:border-red-muted"
|
||||
}`}
|
||||
>
|
||||
{swatch && (
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 rounded-full border border-border"
|
||||
style={{ backgroundColor: swatch }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
{isSelected && <CheckIcon className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-3">
|
||||
<div className="sticky top-24 rounded-2xl border border-border bg-surface p-5">
|
||||
{storeName && <p className="mb-4 text-sm font-bold">{storeName}</p>}
|
||||
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">موجودی:</span>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
available ? "bg-green-50 text-green-700" : "bg-red-light text-red-dark"
|
||||
}`}
|
||||
>
|
||||
{available ? "موجود" : "ناموجود"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{price != null ? (
|
||||
<>
|
||||
<div className="mb-5 space-y-1.5">
|
||||
{!priceRangeLabel && oldPrice != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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-xs font-bold text-green-700">
|
||||
{discount}٪
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-2xl font-bold text-red-dark">
|
||||
{priceRangeLabel ?? formatPrice(price)}
|
||||
<span className="mr-1 text-sm font-normal text-muted">تومان</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-foreground">تعداد:</span>
|
||||
<div className="flex items-center rounded-xl border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.min(maxQty, q + 1))}
|
||||
disabled={quantity >= maxQty}
|
||||
className="px-3 py-2 text-lg text-foreground transition-colors hover:text-red disabled:cursor-not-allowed disabled:text-muted"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="min-w-8 text-center text-sm font-medium">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
disabled={quantity <= 1}
|
||||
className="px-3 py-2 text-lg text-foreground transition-colors hover:text-red disabled:cursor-not-allowed disabled:text-muted"
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<a
|
||||
href={`tel:${contactPhone}`}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-red py-3 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-xl bg-red/60 py-3 text-sm font-medium text-white"
|
||||
>
|
||||
تماس با فروشگاه
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { postComment, type EntityComment, type TechnicalInfoField } from "@/lib/meshkee";
|
||||
|
||||
type Tab = "specs" | "review" | "comments";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "specs", label: "مشخصات فنی" },
|
||||
{ id: "review", label: "نقد و بررسی" },
|
||||
{ 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 (
|
||||
<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}
|
||||
type="button"
|
||||
onClick={() => setActive(tab.id)}
|
||||
className={`relative pb-4 text-sm transition-colors ${
|
||||
active === tab.id ? "font-bold text-foreground" : "text-muted hover:text-red"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{active === tab.id && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</h2>
|
||||
|
||||
{active === "specs" &&
|
||||
(technicalInfo.length === 0 ? (
|
||||
<EmptyState message="هنوز مشخصات فنیای برای این محصول ثبت نشده است." />
|
||||
) : (
|
||||
<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>
|
||||
))}
|
||||
|
||||
{active === "review" &&
|
||||
(descriptionHtml ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message="هنوز نقد و بررسیای برای این محصول ثبت نشده است." />
|
||||
))}
|
||||
|
||||
{active === "comments" && <CommentsPanel entityId={entityId} initialComments={initialComments} />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
type BreadcrumbItem = { label: string; href?: string };
|
||||
|
||||
export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
|
||||
return (
|
||||
<nav aria-label="مسیر" className="mb-6 flex flex-wrap items-center gap-2 text-sm text-muted">
|
||||
{items.map((item, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
{i > 0 && <span aria-hidden>/</span>}
|
||||
{item.href ? (
|
||||
<a href={item.href} className="transition-colors hover:text-red">
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-foreground">{item.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { 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";
|
||||
import "swiper/css";
|
||||
import "swiper/css/free-mode";
|
||||
|
||||
type CarouselProps = {
|
||||
children: React.ReactNode;
|
||||
ariaLabel: string;
|
||||
slideClassName?: string;
|
||||
};
|
||||
|
||||
function ChevronLeftIcon() {
|
||||
@@ -35,45 +41,67 @@ function ChevronRightIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scroll = (direction: "prev" | "next") => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const amount = direction === "next" ? -280 : 280;
|
||||
track.scrollBy({ left: amount, behavior: "smooth" });
|
||||
};
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll("prev")}
|
||||
onClick={() => swiperRef.current?.slidePrev()}
|
||||
aria-label="قبلی"
|
||||
disabled={isLocked}
|
||||
className={`${btnClass} right-1`}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll("next")}
|
||||
onClick={() => swiperRef.current?.slideNext()}
|
||||
aria-label="بعدی"
|
||||
disabled={isLocked}
|
||||
className={`${btnClass} left-1`}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<div
|
||||
ref={trackRef}
|
||||
<Swiper
|
||||
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"
|
||||
aria-label={ariaLabel}
|
||||
className="carousel-track flex items-stretch gap-4 overflow-x-auto scroll-smooth px-1 py-2"
|
||||
className="!px-1 !py-2"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{Children.map(children, (child) => (
|
||||
// Swiper's own CSS pins slide height to 100%, which — with no definite
|
||||
// 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
|
||||
className={slideClassName}
|
||||
style={slideClassName ? { height: "auto" } : { width: "auto", height: "auto" }}
|
||||
>
|
||||
{child}
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
type EmptyStateProps = {
|
||||
message?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EmptyState({
|
||||
message = "هنوز محتوایی ثبت نشده است.",
|
||||
className = "",
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-2xl border border-dashed border-border bg-surface py-10 text-sm text-muted ${className}`}
|
||||
>
|
||||
{message}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ type PlaceholderProps = {
|
||||
height?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
fit?: "cover" | "contain";
|
||||
};
|
||||
|
||||
export function Placeholder({
|
||||
@@ -14,6 +15,7 @@ export function Placeholder({
|
||||
height,
|
||||
label,
|
||||
className = "",
|
||||
fit = "cover",
|
||||
}: PlaceholderProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -23,7 +25,7 @@ export function Placeholder({
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
className={`absolute inset-0 h-full w-full ${fit === "contain" ? "object-contain" : "object-cover"}`}
|
||||
/>
|
||||
{label && (
|
||||
<span className="font-display pointer-events-none relative z-10 rounded bg-white/70 px-1.5 py-0.5 text-xs tracking-wider text-red-dark/50 uppercase">
|
||||
|
||||
@@ -4,11 +4,16 @@ import { formatPrice } from "@/data/sample";
|
||||
type ProductCardProps = {
|
||||
name: string;
|
||||
brand: string;
|
||||
price: number;
|
||||
oldPrice?: number;
|
||||
price: number | null;
|
||||
oldPrice?: number | null;
|
||||
discount?: number;
|
||||
image: string;
|
||||
variant?: "sale" | "default" | "brand";
|
||||
href?: string;
|
||||
fluid?: boolean;
|
||||
contactPhone?: string;
|
||||
variantId?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export function ProductCard({
|
||||
@@ -19,54 +24,71 @@ export function ProductCard({
|
||||
discount,
|
||||
image,
|
||||
variant = "default",
|
||||
href,
|
||||
fluid = false,
|
||||
}: ProductCardProps) {
|
||||
return (
|
||||
<article className="group w-[220px] shrink-0 overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md md:w-[240px]">
|
||||
<div className="relative">
|
||||
const info = (
|
||||
<>
|
||||
<div className="relative p-[10px]">
|
||||
<Placeholder
|
||||
src={image}
|
||||
alt={name}
|
||||
width="100%"
|
||||
height="200px"
|
||||
label="240×200"
|
||||
className="w-full rounded-none border-0 border-b border-dashed"
|
||||
fit="contain"
|
||||
className="aspect-square w-full rounded-lg border-0"
|
||||
/>
|
||||
{discount && (
|
||||
<span className="absolute top-3 right-3 rounded-md bg-red px-2 py-0.5 text-xs font-bold text-white">
|
||||
{discount}٪
|
||||
</span>
|
||||
)}
|
||||
{variant === "brand" && (
|
||||
<span className="font-display absolute top-3 left-3 text-[10px] font-bold tracking-widest text-red-dark uppercase">
|
||||
{brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase">
|
||||
<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-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium">
|
||||
)}
|
||||
<h3 className="mb-2 line-clamp-2 h-11 text-sm leading-6 font-medium">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-bold text-red-dark">
|
||||
{formatPrice(price)}
|
||||
<span className="mr-0.5 text-xs font-normal text-muted">تومان</span>
|
||||
</span>
|
||||
{oldPrice && (
|
||||
<span className="text-xs text-muted line-through">
|
||||
{formatPrice(oldPrice)}
|
||||
<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-sm text-muted line-through">{formatPrice(oldPrice)}</span>
|
||||
{discount != null && (
|
||||
<span className="rounded-md bg-red px-1.5 py-0.5 text-[11px] font-bold text-white">
|
||||
{discount}٪
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
افزودن به سبد
|
||||
</button>
|
||||
)}
|
||||
<div className="text-base font-bold text-red-dark">
|
||||
{price != null ? (
|
||||
<>
|
||||
{formatPrice(price)}
|
||||
<span className="mr-0.5 text-xs font-normal text-muted">تومان</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-normal text-muted">تماس با فروشگاه</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group flex h-full flex-col ${fluid ? "w-full" : "w-[220px] shrink-0 md:w-[240px]"} overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md`}
|
||||
>
|
||||
{href ? (
|
||||
<a href={href} className="block flex-1">
|
||||
{info}
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex-1">{info}</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
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>
|
||||
{enTitle && (
|
||||
<span
|
||||
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
||||
lang="en"
|
||||
@@ -18,18 +22,20 @@ export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleP
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +1,24 @@
|
||||
export const categories = [
|
||||
{ id: 1, name: "یخچال و فریزر", count: 48, image: "/images/category-1.svg" },
|
||||
{ id: 2, name: "ماشین لباسشویی", count: 36, image: "/images/category-2.svg" },
|
||||
{ id: 3, name: "تلویزیون", count: 52, image: "/images/category-3.svg" },
|
||||
{ id: 4, name: "جاروبرقی", count: 29, image: "/images/category-4.svg" },
|
||||
{ id: 5, name: "مایکروویو", count: 18, image: "/images/category-5.svg" },
|
||||
{ id: 6, name: "ظرفشویی", count: 22, image: "/images/category-6.svg" },
|
||||
{ id: 7, name: "کولر و گرمایش", count: 31, image: "/images/category-7.svg" },
|
||||
{ id: 8, name: "لوازم آشپزخانه", count: 64, image: "/images/category-8.svg" },
|
||||
];
|
||||
|
||||
export const specialSaleProducts = [
|
||||
{
|
||||
id: 1,
|
||||
name: "یخچال فریزر ساید بای ساید سامسونگ",
|
||||
brand: "Samsung",
|
||||
price: 89500000,
|
||||
oldPrice: 102000000,
|
||||
discount: 12,
|
||||
image: "/images/product-1.svg",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "ماشین لباسشویی ۹ کیلویی الجی",
|
||||
brand: "LG",
|
||||
price: 42800000,
|
||||
oldPrice: 48500000,
|
||||
discount: 11,
|
||||
image: "/images/product-2.svg",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "تلویزیون ۵۵ اینچ QLED سامسونگ",
|
||||
brand: "Samsung",
|
||||
price: 56200000,
|
||||
oldPrice: 62000000,
|
||||
discount: 9,
|
||||
image: "/images/product-3.svg",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "جاروبرقی رباتیک شیائومی",
|
||||
brand: "Xiaomi",
|
||||
price: 18900000,
|
||||
oldPrice: 23500000,
|
||||
discount: 19,
|
||||
image: "/images/product-4.svg",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "مایکروویو ۳۰ لیتری بوش",
|
||||
brand: "Bosch",
|
||||
price: 12400000,
|
||||
oldPrice: 14800000,
|
||||
discount: 16,
|
||||
image: "/images/product-5.svg",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "ظرفشویی ۱۴ نفره بوش",
|
||||
brand: "Bosch",
|
||||
price: 35600000,
|
||||
oldPrice: 39900000,
|
||||
discount: 10,
|
||||
image: "/images/product-6.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export const topSellingProducts = [
|
||||
{
|
||||
id: 7,
|
||||
name: "یخچال دوقلو الجی",
|
||||
brand: "LG",
|
||||
price: 67800000,
|
||||
sold: 124,
|
||||
image: "/images/product-7.svg",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "تلویزیون ۶۵ اینچ OLED الجی",
|
||||
brand: "LG",
|
||||
price: 98500000,
|
||||
sold: 89,
|
||||
image: "/images/product-8.svg",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "ماشین لباسشویی ۸ کیلویی سامسونگ",
|
||||
brand: "Samsung",
|
||||
price: 38900000,
|
||||
sold: 156,
|
||||
image: "/images/product-9.svg",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "کولر گازی ۲۴ هزار اینورتر",
|
||||
brand: "Gree",
|
||||
price: 24500000,
|
||||
sold: 203,
|
||||
image: "/images/product-10.svg",
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: "اجاق گاز رومیزی بوش",
|
||||
brand: "Bosch",
|
||||
price: 16800000,
|
||||
sold: 67,
|
||||
image: "/images/product-11.svg",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: "جاروبرقی سایکلون سامسونگ",
|
||||
brand: "Samsung",
|
||||
price: 8900000,
|
||||
sold: 178,
|
||||
image: "/images/product-12.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export const samsungProducts = [
|
||||
{
|
||||
id: 13,
|
||||
name: "یخچال Bespoke سامسونگ",
|
||||
brand: "Samsung",
|
||||
price: 112000000,
|
||||
image: "/images/product-13.svg",
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: "ماشین لباسشویی AI EcoBubble",
|
||||
brand: "Samsung",
|
||||
price: 45600000,
|
||||
image: "/images/product-14.svg",
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: "تلویزیون Neo QLED 8K",
|
||||
brand: "Samsung",
|
||||
price: 185000000,
|
||||
image: "/images/product-15.svg",
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
name: "جاروبرقی Jet ۹۰",
|
||||
brand: "Samsung",
|
||||
price: 21500000,
|
||||
image: "/images/product-16.svg",
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
name: "مایکروویو Grill سامسونگ",
|
||||
brand: "Samsung",
|
||||
price: 13800000,
|
||||
image: "/images/product-17.svg",
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
name: "کولر گازی WindFree",
|
||||
brand: "Samsung",
|
||||
price: 32800000,
|
||||
image: "/images/product-18.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export const blogPosts = [
|
||||
{
|
||||
id: 1,
|
||||
title: "راهنمای خرید یخچال مناسب برای خانواده",
|
||||
excerpt: "نکات مهم برای انتخاب یخچال با توجه به فضای آشپزخانه و تعداد اعضای خانواده.",
|
||||
date: "۱۲ مرداد ۱۴۰۵",
|
||||
readTime: "۵ دقیقه",
|
||||
image: "/images/blog-1.svg",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "مقایسه تلویزیون QLED و OLED",
|
||||
excerpt: "تفاوتهای فنی و کاربردی دو تکنولوژی محبوب نمایشگر در سال ۱۴۰۵.",
|
||||
date: "۸ مرداد ۱۴۰۵",
|
||||
readTime: "۷ دقیقه",
|
||||
image: "/images/blog-2.svg",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "چگونه مصرف برق لوازم خانگی را کاهش دهیم؟",
|
||||
excerpt: "راهکارهای عملی برای صرفهجویی در قبض برق با استفاده هوشمند از لوازم برقی.",
|
||||
date: "۳ مرداد ۱۴۰۵",
|
||||
readTime: "۴ دقیقه",
|
||||
image: "/images/blog-3.svg",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "معرفی جدیدترین محصولات سامسونگ ۲۰۲۶",
|
||||
excerpt: "نگاهی به سری Bespoke و Neo QLED در نمایشگاه لوازم خانگی امسال.",
|
||||
date: "۲۸ تیر ۱۴۰۵",
|
||||
readTime: "۶ دقیقه",
|
||||
image: "/images/blog-4.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export const sliderSlides = [
|
||||
{
|
||||
id: 1,
|
||||
title: "جشنواره تابستانه لوازم خانگی",
|
||||
subtitle: "تا ۳۰٪ تخفیف روی برندهای منتخب",
|
||||
cta: "مشاهده پیشنهادها",
|
||||
image: "/images/slider-1.svg",
|
||||
image: "/images/slider/1.jpg",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "سری جدید Samsung Bespoke",
|
||||
subtitle: "طراحی هوشمند برای آشپزخانه مدرن",
|
||||
cta: "کاوش مجموعه",
|
||||
image: "/images/slider-2.svg",
|
||||
image: "/images/slider/2.jpg",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "ارسال رایگان به سراسر کشور",
|
||||
subtitle: "برای سفارشهای بالای ۵ میلیون تومان",
|
||||
cta: "خرید کنید",
|
||||
image: "/images/slider-3.svg",
|
||||
image: "/images/slider/3.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -1,7 +1,637 @@
|
||||
const API_BASE = "https://api.meshkee.com/api/v1";
|
||||
const DOMAIN = "sanihome.ir";
|
||||
|
||||
async function apiGet<T>(
|
||||
path: string,
|
||||
searchParams?: Record<string, string | number | boolean | undefined>,
|
||||
): Promise<T | null> {
|
||||
const url = new URL(`${API_BASE}${path}`);
|
||||
if (searchParams) {
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
if (value !== undefined) url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), { next: { revalidate: 300 } });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
nameFa: string;
|
||||
about: string;
|
||||
vision: string;
|
||||
logoUrl: string | null;
|
||||
logoDarkUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
emails: string[];
|
||||
phoneNumbers: string[];
|
||||
socialMedia: {
|
||||
whatsapp?: string;
|
||||
telegram?: string;
|
||||
instagram?: string;
|
||||
linkedin?: string;
|
||||
youtube?: string;
|
||||
aparat?: string;
|
||||
};
|
||||
addresses: string[];
|
||||
metaTags?: BusinessMetaTag[];
|
||||
trustBadges?: BusinessTrustBadge[];
|
||||
};
|
||||
|
||||
export async function getBusinessInfo(): Promise<BusinessInfo | null> {
|
||||
return apiGet<BusinessInfo>(`/tenants/${DOMAIN}/website/business-info`);
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
id: string;
|
||||
name: string;
|
||||
image?: string | null;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export async function getCategories(): Promise<Category[]> {
|
||||
const res = await apiGet<{ items: Category[] }>(`/tenants/${DOMAIN}/categories`, {
|
||||
entityType: "product",
|
||||
});
|
||||
return res?.items ?? [];
|
||||
}
|
||||
|
||||
// Real category hierarchy from /categories (id, parentId, name, nameFa, slug).
|
||||
// Level 1 = root, used only as an internal grouping key for menu columns — never
|
||||
// rendered. Level 2 items carry their level 3 children (if any) in `children`.
|
||||
export type CategoryNode = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
nameFa: string;
|
||||
children: CategoryNode[];
|
||||
};
|
||||
|
||||
export type CategoryTreeGroup = {
|
||||
id: string;
|
||||
items: CategoryNode[];
|
||||
};
|
||||
|
||||
export async function getCategoryTree(): Promise<CategoryTreeGroup[]> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(`/tenants/${DOMAIN}/categories`, {
|
||||
entityType: "product",
|
||||
});
|
||||
const raw = res?.items ?? [];
|
||||
|
||||
const toNode = (item: Record<string, unknown>): CategoryNode => ({
|
||||
id: String(item.id ?? ""),
|
||||
slug: String(item.slug ?? ""),
|
||||
name: String(item.name ?? ""),
|
||||
nameFa: String(item.nameFa ?? item.name ?? ""),
|
||||
children: [],
|
||||
});
|
||||
|
||||
const roots = raw.filter((item) => !item.parentId);
|
||||
const groups: CategoryTreeGroup[] = roots.map((root) => {
|
||||
const level2 = raw
|
||||
.filter((item) => String(item.parentId) === String(root.id))
|
||||
.map((item) => {
|
||||
const node = toNode(item);
|
||||
node.children = raw
|
||||
.filter((child) => String(child.parentId) === String(item.id))
|
||||
.map(toNode);
|
||||
return node;
|
||||
});
|
||||
return { id: String(root.id ?? ""), items: level2 };
|
||||
});
|
||||
|
||||
return groups.filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
price: number | null;
|
||||
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 {
|
||||
const images = raw.images as unknown[] | undefined;
|
||||
const firstImage = images?.[0];
|
||||
const brand = raw.brand;
|
||||
const store = raw.store as Record<string, unknown> | undefined;
|
||||
// Some sources (store-specials, brand-groups) nest price on the first variant
|
||||
// instead of the item itself.
|
||||
const variants = raw.variants as Record<string, unknown>[] | undefined;
|
||||
const firstVariant = variants?.[0];
|
||||
const price =
|
||||
store?.minPrice ?? raw.discountedPrice ?? raw.price ?? firstVariant?.discountedPrice ?? firstVariant?.price;
|
||||
const oldPrice =
|
||||
raw.discountedPrice != null && raw.price != null
|
||||
? raw.price
|
||||
: firstVariant?.discountedPrice != null && firstVariant?.price != null
|
||||
? firstVariant.price
|
||||
: raw.oldPrice;
|
||||
const numericPrice = price != null ? Number(price) : null;
|
||||
const numericOldPrice = oldPrice != null ? Number(oldPrice) : undefined;
|
||||
// The API doesn't always send a discount percent alongside price/oldPrice —
|
||||
// derive it from the two real numbers rather than leaving the badge blank.
|
||||
const discount =
|
||||
raw.discount != null
|
||||
? Number(raw.discount)
|
||||
: numericOldPrice != null && numericPrice != null && numericOldPrice > numericPrice
|
||||
? Math.round((1 - numericPrice / numericOldPrice) * 100)
|
||||
: undefined;
|
||||
return {
|
||||
// Group sources (store-specials, brand-groups, store-items) wrap a store-item
|
||||
// with its own `id`, but also carry `productId` pointing at the real product —
|
||||
// prefer that so productHref can resolve a detail page even without a slug.
|
||||
id: String(raw.productId ?? raw.id ?? ""),
|
||||
// Group sources don't include a product slug, so leave it empty — productHref
|
||||
// falls back to an id-based lookup (which redirects to the canonical slug)
|
||||
// rather than fabricating a broken URL from the id.
|
||||
slug: String(raw.slug ?? ""),
|
||||
// Prefer the Persian name — sanihome.ir is a Persian storefront.
|
||||
name: String(
|
||||
raw.nameFa || raw.productNameFa || raw.name || raw.title || raw.productTitle || "",
|
||||
),
|
||||
brand:
|
||||
typeof brand === "string"
|
||||
? 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,
|
||||
image:
|
||||
(raw.thumbnailUrl as string | undefined) ??
|
||||
(raw.image as string | undefined) ??
|
||||
(raw.productImage as string | undefined) ??
|
||||
(raw.imageUrl as string | undefined) ??
|
||||
(typeof firstImage === "string"
|
||||
? firstImage
|
||||
: ((firstImage as Record<string, unknown> | undefined)?.url as string | undefined)) ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
export function productHref(product: Product): string {
|
||||
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 `/store-items/${product.id}`;
|
||||
return `/store-items?q=${encodeURIComponent(product.name)}`;
|
||||
}
|
||||
|
||||
export async function getProducts(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
categoryId?: string;
|
||||
brandId?: string;
|
||||
tag?: string;
|
||||
inStore?: boolean;
|
||||
}): Promise<{ items: Product[]; total: number }> {
|
||||
const res = await apiGet<{
|
||||
items: Record<string, unknown>[];
|
||||
total: number;
|
||||
}>(`/tenants/${DOMAIN}/products`, params);
|
||||
return { items: (res?.items ?? []).map(normalizeProduct), total: res?.total ?? 0 };
|
||||
}
|
||||
|
||||
export type ProductDetail = Product & {
|
||||
descriptionHtml?: string;
|
||||
images: string[];
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
inStock: boolean;
|
||||
minPrice: number | null;
|
||||
maxPrice: number | null;
|
||||
};
|
||||
|
||||
function normalizeProductDetail(raw: Record<string, unknown>): ProductDetail {
|
||||
const store = raw.store as Record<string, unknown> | undefined;
|
||||
const images = raw.images as unknown[] | undefined;
|
||||
// Each entry is either a plain url string or a { url } media object.
|
||||
const imageUrls = (images ?? [])
|
||||
.map((i) =>
|
||||
typeof i === "string" ? i : ((i as Record<string, unknown> | undefined)?.url as string | undefined),
|
||||
)
|
||||
.filter((url): url is string => Boolean(url));
|
||||
return {
|
||||
...normalizeProduct(raw),
|
||||
descriptionHtml: raw.descriptionHtml as string | undefined,
|
||||
images: imageUrls,
|
||||
categoryId: raw.categoryId as string | undefined,
|
||||
categoryName:
|
||||
(raw.categoryNameFa as string | undefined) ?? (raw.categoryName as string | undefined),
|
||||
inStock: Boolean(store?.inStock),
|
||||
minPrice: store?.minPrice != null ? Number(store.minPrice) : null,
|
||||
maxPrice: store?.maxPrice != null ? Number(store.maxPrice) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProduct(slug: string): Promise<ProductDetail | null> {
|
||||
const res = await apiGet<{ product: Record<string, unknown> }>(
|
||||
`/tenants/${DOMAIN}/products/${slug}`,
|
||||
);
|
||||
return res?.product ? normalizeProductDetail(res.product) : null;
|
||||
}
|
||||
|
||||
// Used to resolve a canonical slug for cards sourced from an endpoint that
|
||||
// only gives us a product id (store-specials, brand-groups, store-items).
|
||||
export async function getProductById(id: string): Promise<ProductDetail | null> {
|
||||
const res = await apiGet<{ product: Record<string, unknown> }>(
|
||||
`/tenants/${DOMAIN}/products/by-id/${id}`,
|
||||
);
|
||||
return res?.product ? normalizeProductDetail(res.product) : null;
|
||||
}
|
||||
|
||||
export type VariationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
colorHex?: string | null;
|
||||
};
|
||||
|
||||
export type ProductVariation = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
options: VariationOption[];
|
||||
};
|
||||
|
||||
export async function getProductVariations(slug: string): Promise<ProductVariation[]> {
|
||||
const res = await apiGet<{ variations: Record<string, unknown>[] }>(
|
||||
`/tenants/${DOMAIN}/products/${slug}/variations`,
|
||||
);
|
||||
return (res?.variations ?? []).map((v) => ({
|
||||
id: String(v.id ?? ""),
|
||||
name: String(v.name ?? ""),
|
||||
type: String(v.type ?? ""),
|
||||
options: ((v.options as Record<string, unknown>[] | undefined) ?? []).map((o) => ({
|
||||
id: String(o.id ?? ""),
|
||||
label: String(o.label ?? o.value ?? ""),
|
||||
value: String(o.value ?? ""),
|
||||
colorHex: (o.colorHex as string | null | undefined) ?? null,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// One store-item per real purchasable variant combination (e.g. one per color).
|
||||
// `selections` ties it back to the ProductVariation/VariationOption ids above so
|
||||
// the UI can look up price/stock for whatever options the shopper picks.
|
||||
export type ProductStoreVariant = {
|
||||
id: string;
|
||||
price: number | null;
|
||||
discountedPrice: number | null;
|
||||
stockQuantity: number;
|
||||
selections: { variationId: string; optionId: string }[];
|
||||
};
|
||||
|
||||
export async function getProductStoreVariants(productId: string): Promise<ProductStoreVariant[]> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(`/tenants/${DOMAIN}/store-items`, {
|
||||
productId,
|
||||
pageSize: 100,
|
||||
});
|
||||
return (res?.items ?? []).map((raw) => ({
|
||||
id: String(raw.id ?? ""),
|
||||
price: raw.price != null ? Number(raw.price) : null,
|
||||
discountedPrice: raw.discountedPrice != null ? Number(raw.discountedPrice) : null,
|
||||
stockQuantity: Number(raw.stockQuantity ?? 0),
|
||||
selections: ((raw.selections as Record<string, unknown>[] | undefined) ?? []).map((s) => ({
|
||||
variationId: String(s.variationId ?? ""),
|
||||
optionId: String(s.optionId ?? ""),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export type TechnicalInfoField = { label: string; value: string };
|
||||
|
||||
export async function getProductTechnicalInfo(slug: string): Promise<TechnicalInfoField[]> {
|
||||
const res = await apiGet<{
|
||||
form: { fields?: Record<string, unknown>[] } | null;
|
||||
values: Record<string, unknown>[];
|
||||
}>(
|
||||
`/tenants/${DOMAIN}/products/${slug}/technical-info`,
|
||||
);
|
||||
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; // the store-item VARIANT id — what the cart API calls storeItemVariantId
|
||||
productId: string;
|
||||
name: string;
|
||||
label: string;
|
||||
image: string | null;
|
||||
price: 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 ?? ""),
|
||||
// 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: 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;
|
||||
categoryId?: string;
|
||||
brandId?: string;
|
||||
productId?: string;
|
||||
name?: string;
|
||||
inStock?: boolean;
|
||||
isFestival?: boolean;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
}): Promise<{ items: StoreItem[]; total: number }> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[]; total: number }>(
|
||||
`/tenants/${DOMAIN}/store-items`,
|
||||
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;
|
||||
title: string;
|
||||
abstract?: string;
|
||||
image?: string | null;
|
||||
publishedAt?: string;
|
||||
categoryName?: string;
|
||||
categoryId?: string;
|
||||
};
|
||||
|
||||
function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
slug: String(raw.slug ?? ""),
|
||||
title: String(raw.title ?? ""),
|
||||
abstract: raw.abstract as string | undefined,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBlogs(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
categoryId?: string;
|
||||
title?: string;
|
||||
}): Promise<{ items: BlogSummary[]; total: number }> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[]; total: number }>(
|
||||
`/tenants/${DOMAIN}/blogs`,
|
||||
params,
|
||||
);
|
||||
return { items: (res?.items ?? []).map(normalizeBlog), total: res?.total ?? 0 };
|
||||
}
|
||||
|
||||
export type BlogDetail = BlogSummary & {
|
||||
mainTextHtml: string;
|
||||
author?: string;
|
||||
};
|
||||
|
||||
export async function getBlog(slug: string): Promise<BlogDetail | null> {
|
||||
const res = await apiGet<{ blog: Record<string, unknown> }>(`/tenants/${DOMAIN}/blogs/${slug}`);
|
||||
if (!res?.blog) return null;
|
||||
const blog = res.blog;
|
||||
const author = blog.author as Record<string, unknown> | undefined;
|
||||
const authorName = author
|
||||
? `${(author.firstName as string | undefined) ?? ""} ${(author.lastName as string | undefined) ?? ""}`.trim()
|
||||
: undefined;
|
||||
return {
|
||||
...normalizeBlog(blog),
|
||||
mainTextHtml: String(blog.mainTextHtml ?? ""),
|
||||
author: authorName || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? ""),
|
||||
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`,
|
||||
);
|
||||
return normalizeProductGroups(res?.items);
|
||||
}
|
||||
|
||||
export type Slide = {
|
||||
id: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
image?: string | null;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
function normalizeSlide(raw: Record<string, unknown>): Slide {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
title: raw.title as string | undefined,
|
||||
subtitle: raw.subtitle as string | undefined,
|
||||
image: (raw.image as string | undefined) ?? (raw.imageUrl as string | undefined) ?? null,
|
||||
href: raw.link as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSliderSlides(): Promise<Slide[]> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(
|
||||
`/tenants/${DOMAIN}/website/sliders`,
|
||||
);
|
||||
const firstSlider = res?.items?.[0];
|
||||
const slides = (firstSlider?.slides ?? firstSlider?.items) as
|
||||
| Record<string, unknown>[]
|
||||
| undefined;
|
||||
return (slides ?? []).map(normalizeSlide);
|
||||
}
|
||||
|
||||
// Admin-managed static/marketing images (slider photos, promo banners) for
|
||||
// fixed homepage slots, managed by the client through the Meshkee admin panel.
|
||||
export type StaticImage = {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
title?: string | null;
|
||||
titleFa?: string | null;
|
||||
titleEn?: string | null;
|
||||
subtext?: string | null;
|
||||
linkUrl?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
@@ -13,83 +643,80 @@ export type StaticImageWithUrl = StaticImage & { url: string };
|
||||
export type StaticImageSlot = {
|
||||
id: string;
|
||||
key: string;
|
||||
pageKey?: string;
|
||||
kind: "single" | "list";
|
||||
label?: string;
|
||||
aspectRatio?: string;
|
||||
recommendedWidth?: number;
|
||||
itemCount?: number | null;
|
||||
sortOrder?: number;
|
||||
images?: StaticImage[];
|
||||
};
|
||||
|
||||
export const STATIC_IMAGE_KEYS = {
|
||||
slider: "slider",
|
||||
bannerInSpecialSale: "banner-in-special-sale",
|
||||
categories: "categories",
|
||||
twoBannerRow: "two-banner-row",
|
||||
homeHero: "home-hero",
|
||||
twoBanner: "two-banner",
|
||||
threeBannerRow: "three-banner-row",
|
||||
featuredCategories: "featured-categories",
|
||||
aboutUsImage: "about-us-image",
|
||||
} as const;
|
||||
|
||||
export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.slider,
|
||||
label: "Homepage slider",
|
||||
key: STATIC_IMAGE_KEYS.homeHero,
|
||||
label: "Homepage hero slider",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "16:9",
|
||||
aspectRatio: "21:9",
|
||||
itemCount: null,
|
||||
recommendedWidth: 1440,
|
||||
recommendedWidth: 1920,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.bannerInSpecialSale,
|
||||
label: "Banner in special sale",
|
||||
kind: "single" as const,
|
||||
key: STATIC_IMAGE_KEYS.twoBanner,
|
||||
label: "Two banner row",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "12:19",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 480,
|
||||
aspectRatio: "12:5",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.categories,
|
||||
label: "Featured category images",
|
||||
key: STATIC_IMAGE_KEYS.threeBannerRow,
|
||||
label: "Three banner row",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "12:5",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.featuredCategories,
|
||||
label: "Featured categories",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "1:1",
|
||||
itemCount: null,
|
||||
recommendedWidth: 400,
|
||||
recommendedWidth: 300,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.twoBannerRow,
|
||||
label: "Two banner row",
|
||||
kind: "list" as const,
|
||||
key: STATIC_IMAGE_KEYS.aboutUsImage,
|
||||
label: "Homepage about-us image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "16:5",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 1440,
|
||||
aspectRatio: "14:9",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 560,
|
||||
},
|
||||
];
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
const DOMAIN = process.env.NEXT_PUBLIC_DOMAIN ?? "";
|
||||
|
||||
export async function getStaticImageSlots(): Promise<StaticImageSlot[]> {
|
||||
if (!API_BASE || !DOMAIN) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/tenants/${DOMAIN}/website/static-images`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data: { items?: StaticImageSlot[] } = await res.json();
|
||||
return data.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const res = await apiGet<{ items: StaticImageSlot[] }>(`/tenants/${DOMAIN}/website/static-images`, {
|
||||
pageKey: "home",
|
||||
});
|
||||
return res?.items ?? [];
|
||||
}
|
||||
|
||||
export function imagesForSlot(
|
||||
slots: StaticImageSlot[],
|
||||
key: string,
|
||||
): StaticImageWithUrl[] {
|
||||
export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImageWithUrl[] {
|
||||
const images = slots.find((slot) => slot.key === key)?.images ?? [];
|
||||
return [...images]
|
||||
.filter((image): image is StaticImageWithUrl => Boolean(image.url))
|
||||
@@ -102,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 };
|
||||
}
|
||||
|
||||