Compare commits

...
25 Commits
Author SHA1 Message Date
Alireza HassaniandCursor 7ea3fe76c9 Inject CMS meta tags as full HTML from business-info.
metaTags is now { html }; parse into head <meta> elements instead of name/content metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:39:21 +03:30
Alireza HassaniandCursor f60c98184b Render CMS meta tags and trust badges from business-info.
Injects dashboard-managed meta tags in the head and eNamad/Samandehi HTML in the footer, and enables Next standalone output for deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:11:28 +03:30
amirhosein.ashourloo 804eb8702f feat: unify catalog and improve detail pages 2026-09-28 09:35:40 +03:30
amirhosein.ashourloo 3af4e04bdd feat: refine storefront product and sale sections 2026-09-23 13:43:18 +03:30
amirhosein.ashourlooandClaude Sonnet 5 69c54d3b17 Set footer top border color to #dddddd
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:41:15 +03:30
amirhosein.ashourlooandClaude Sonnet 5 c4411ebc28 Set mobile/non-desktop header logo height to 65px
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:40:17 +03:30
amirhosein.ashourlooandClaude Sonnet 5 69d17a6f8f Change footer background to #f1f1f1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:39:04 +03:30
amirhosein.ashourlooandClaude Sonnet 5 d292bfc74c Resize header login/cart icon buttons to 40px, cart bg to #f1f1f1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:35:15 +03:30
amirhosein.ashourlooandClaude Sonnet 5 d090f57540 Redesign header icons/account-cart chip, light footer, fix sticky-footer gap
- New shared SVG icon set (src/components/ui/icons.tsx): Menu, User,
  Cart, Close, ChevronLeft — replaces emoji (☰ 👤 🛒 ✕) across the
  header, and the literal "←" text arrow on every "view more"-style
  link (SectionTitle's action, the mega-menu link, BlogCarousel's
  "ادامه مطلب").
- Header desktop account/cart area redesigned as a borderless chip:
  cart icon button (right) + divider + account icon-badge/two-line
  "حساب کاربری / ورود/ثبت‌نام" (left), matching a supplied reference.
  Got the RTL DOM order backwards on the first pass (cart needs to be
  first in DOM to render rightmost) and corrected it.
- Header logo: -10px (was already doubled from an earlier request).
- Footer: background is now bg-[#9a21291a] (exact hex given, not a
  token substitute) with black text throughout instead of solid
  dark-red/white; footer logo doubled; the meshkee-credit logo gets
  brightness-0 so the white source asset stays visible on the new
  light background.
- Fixed a real layout bug: <main> had no flex-1, so on any page
  shorter than the viewport the footer stuck to the content instead of
  the bottom of the screen, leaving a gap of bare background beneath
  it. Standard sticky-footer flex fix.
- CONTEXT.md: documented the icon set, the account/cart chip's RTL DOM
  ordering (to stop this regressing again), and the footer's new
  color scheme + the flex-1 fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:31:00 +03:30
amirhosein.ashourlooandClaude Sonnet 5 7a78adc7f3 Footer Meshkee credit widget, fix carousel title row alignment/action button
- Footer bottom-right credit link now matches the badge used on other
  Meshkee storefronts (reference: safeteb.com) — logo + two-line label
  inside an animated corner-drawing border on hover, linking to
  https://meshkee.com. Added the shared credit-widget logo asset
  (public/images/meshkee-logo-white.png).
- SectionTitle: title/action row uses items-center instead of items-end
  (was visibly offset once a carousel title could wrap or carry a
  caption); the action button ("مشاهده همه" etc.) is now styled like
  the site's other primary buttons (filled red, white text, rounded)
  instead of a plain text link.
- Carousel English captions (SPECIAL SALE, TOP SELLING, FAVORITE SELL)
  moved back from a below-title "enSubtitle" line to the existing
  inline enTitle treatment, matching every other section heading's
  convention on the site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:14:10 +03:30
amirhosein.ashourlooandClaude Sonnet 5 ab1b07ff28 Footer credit line links to meshkee.com
Replace the static "Designed for Sanihome" text with a real link
("Powered by meshkee.com" -> https://meshkee.com).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 18:58:56 +03:30
amirhosein.ashourlooandClaude Sonnet 5 0de5abb879 Double desktop header logo, add English caption under each carousel title
- Header logo (desktop variant) doubled: 40/48px -> 80/96px.
- Each dynamic store-specials carousel now shows an English caption
  under its title at 50% opacity, derived from the group's real `key`
  (e.g. "top-selling" -> "TOP SELLING") rather than inventing a
  translation of the Persian title, which the API doesn't provide.
  New SectionTitle `enSubtitle` prop (separate from the existing
  inline `enTitle`) carries this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 18:06:42 +03:30
amirhosein.ashourlooandClaude Sonnet 5 7c04316f3a Interleave banners between carousels, real "view all" links, bigger desktop logo
- StoreSpecialCarousels now inserts one of the two banner sections
  (TwoBanners/ThreeBanners, cycling) between every pair of carousels
  instead of stacking all carousels together with the banners fixed
  elsewhere on the page — works for any number of dynamic carousels.
- SectionTitle's action button is a real link now (actionHref), not an
  inert <button>. Each carousel's "مشاهده همه" goes to
  /products?specialId={group.id}, which renders that exact
  admin-curated group in the full product grid — store-specials groups
  have no pagination, so this is genuinely the complete set, not a
  truncated preview. Also fixed BlogCarousel's dead "وبلاگ" button the
  same way, now linking to /blog.
- Reduced FeaturedCategories' vertical padding (py-10/14 -> py-4/6).
- Desktop header logo was smaller than the mobile one (36px vs 46px) —
  bumped to 40/48px so it reads properly at desktop width.
- CONTEXT.md: document the banner-interleaving and specialId patterns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:57:01 +03:30
amirhosein.ashourlooandClaude Sonnet 5 bc6cafaffa Make homepage carousels fully dynamic, drop brand carousels, dynamic logo/favicon
- Replace the two hardcoded homepage carousels (SpecialSaleCarousel
  pinned to store-specials group 0 only, TopSellingCarousel reading
  from an unrelated plain product query) with StoreSpecialCarousels:
  loops over every group GET /store-specials returns, using each
  group's own title. A new carousel created in the admin panel (e.g.
  the "بهترین‌ها از نگاه شما" one just added) shows up automatically,
  no code change needed.
- Remove the brand-groups integration and its "سامسونگ" carousel
  entirely (getBrandGroups, BrandGroupCarousels) — brand-name
  carousels aren't wanted on this site per explicit instruction.
- SectionTitle's enTitle is now optional, since dynamic store-specials
  groups only have one title string, not a Persian/English pair.
- Move featured categories above the hero-adjacent special carousels
  per the requested homepage order; drop the "دسته‌بندی‌های پرطرفدار"
  heading and its "همه دسته‌ها" button, per request.
- Wire the header/footer logo and the favicon to the real business
  data (logoUrl/logoDarkUrl/faviconUrl from GET business-info) instead
  of static files; removed src/app/favicon.ico, which was silently
  winning over the new dynamic <link rel="icon">.
- CONTEXT.md: document the dynamic-carousel contract and the
  logo/favicon wiring so neither regresses to hardcoded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:41:46 +03:30
amirhosein.ashourlooandClaude Sonnet 5 5152b16d61 Revert to guest-cart architecture, drop in-site auth/checkout
Per api.meshkee.com/docs/website/AI_PROMPT.md's hard rules (which an
earlier pass on this branch missed): login, register, OTP, the real
cart, addresses, and payment are the customer dashboard's job
(customer.sanihome.ir), not this storefront's. This site should only
ever run a local guest mini-cart and hand off to the dashboard.

- Remove: AuthContext, LoginModal, lib/auth.ts, lib/cart.ts (the real
  /businesses/{id}/cart integration), and the /checkout page entirely.
- Add lib/guestCart.ts: localStorage + shared cookie
  (meshkee-guest-cart, Domain=.sanihome.ir) cart, storeItemVariantId
  keyed, matching the spec's line shape and ~3500-char cookie-size
  cutoff.
- CartContext is now a thin React wrapper over the guest cart (no
  server round-trip, so add/update/remove are synchronous); CartPopup's
  "Continue" builds the exact customer.sanihome.ir/checkout/cart
  (or /login?redirect=...) URL from the spec, base64url-encoding the
  cart and detecting an existing dashboard session via the shared
  meshkee_customer_access_token cookie.
- ProductCard and ProductPurchasePanel add to the guest cart directly;
  no login gate on add-to-cart anymore.
- Comments panel is a plain guest name/email/text form again (the
  comments API never required auth — gating it on the now-removed
  login system was wrong regardless).
- CONTEXT.md rewritten to document the guest-cart contract explicitly
  and flag this exact mistake so it doesn't get reintroduced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:01:22 +03:30
amirhosein.ashourlooandClaude Sonnet 5 053a9ba361 Add production build config (standalone output, .env.production)
Pulled in from the parallel auth/cart work on origin/main that this
branch is superseding — these two pieces are independent of the auth
architecture and worth keeping: Next.js standalone output for a leaner
production deploy, and the production env file (public URLs only, no
secrets) it and the app read from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 08:13:06 +03:30
amirhosein.ashourlooandClaude Sonnet 5 66f92b634e Add customer auth, real cart/checkout, store-items pages, comments, contact form
- Real in-site login/register (password + SMS OTP) against Meshkee's
  /auth API, with self-healing access-token refresh (15 min lifetime) and
  an in-flight-refresh guard so AuthContext and CartContext don't race
  each other into a failed concurrent refresh.
- Real cart: header cart button/badge, a cart popup, and a working
  "add to cart" on both product cards (where a specific variant id is
  known) and the product/store-item detail buy-box (always resolves a
  purchasable variant, even for single-SKU products with no selectable
  variation).
- /checkout: address + cash/bank-transfer payment, posts to the real
  checkout API and creates an order; refreshes the cart afterward so the
  header badge doesn't go stale.
- New /store-items and /store-items/[id] pages (variant-level browsing,
  separate from the product-level /products catalog), cross-referencing
  the parent product for images/description/technical-info.
- Product gallery: click-to-open fullscreen lightbox alongside the
  existing hover-zoom.
- Comments tab wired to the real comments API, gated to logged-in users
  (a product decision — the API itself doesn't require auth) with a
  login prompt otherwise.
- Similar-products row on product/store-item detail pages.
- Contact page wired to the real contact-submissions API, with a toast
  and inline confirmation on success, and the form resetting.
- CONTEXT.md: a maintained architecture/conventions doc for this repo,
  now auto-loaded via CLAUDE.md, covering the Meshkee integration quirks
  and UI conventions established across this project.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 08:06:30 +03:30
amirhosein.ashourlooandClaude Sonnet 5 c2552af079 Fix product links, equalize card heights, and switch to IRANYekan
- Resolve product cards from group endpoints (store-specials,
  brand-groups) that only return a productId, not a slug, via a new
  /products/id/[id] route backed by GET /products/by-id/{id} — clicking
  these was previously falling back to a name search instead of the
  detail page.
- Derive discount price/percent generically in normalizeProduct so any
  source with real old/new prices shows it, even without an explicit
  discount field from the API.
- Product cards and the detail page buy-box: show the discount badge
  beside the old (struck-through) price, current price alone on its
  own line, matching the reference design.
- Equalize product card heights within a carousel row (Swiper's own
  CSS doesn't stretch slides to the tallest sibling in slidesPerView:
  "auto" + freeMode mode) so the add-to-cart button lines up across
  cards regardless of whether a card has a discount line.
- Featured categories: 110px images with no border/radius, matching
  the site's other category card treatment.
- Replace Vazirmatn with the client's IRANYekan font files — light
  weight for body text, bold weight for headings only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 09:50:56 +03:30
amirhosein.ashourlooandClaude Sonnet 5 83809815c1 Wire featured categories to a static-image slot
Add a featured-categories list slot (published via /meshkee/static-image-slots
alongside the existing banner slots) so the homepage category strip can be
driven by admin-uploaded images instead of the category API, which has no
image field. Falls back to the current category list whenever the slot has
no images configured or the fetch fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 08:57:24 +03:30
amirhosein.ashourloo 60d7d55caa Edit in product detail and create breadcrumb 2026-08-19 09:47:07 +03:30
amirhosein.ashourlooandClaude Sonnet 5 7fb1f5a60f Polish product/blog detail pages: gallery, tabs, Persian names, image fixes
- Product detail: interactive image gallery (thumbnails, prev/next,
  hover-zoom), tabbed spec/review/comments section matching the site's
  standard pattern, variations/options display, and an availability
  label instead of duplicating the contact-store message.
- Fix product.images normalization — the API returns {url} media objects,
  not plain strings, so galleries were silently rendering empty.
- Prefer the Persian name (nameFa/productNameFa) everywhere a product
  name is shown.
- Product cards: padded, rounded images instead of edge-to-edge with a
  dashed divider.
- Blog: wire the homepage carousel to real posts (was static sample
  data), drop the detail page's separate cover image, and make images
  embedded in article/description HTML fill their container with
  rounded corners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 19:38:54 +03:30
amirhosein.ashourlooandClaude Sonnet 5 2b5f4e437b Connect product/blog pages, mega menu, and instant search to Meshkee backend
Add /products (list + detail) and /blog (list + detail) routes backed by
the real Meshkee API, a real category-hierarchy mega menu (level 2/3 only),
debounced instant search proxied through a same-origin API route, and
tel:-link fallbacks on product cards when no price is set. Also fixes
Product price normalization to read the real API shape instead of a
guessed one, and hardens product linking against catalog sources that
don't expose a slug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:42:08 +03:30
amirhosein.ashourloo 1f81236d0a set static images 2026-08-17 11:57:46 +03:30
amirhosein.ashourlooandClaude Sonnet 5 a2eb413157 Merge remote-tracking branch into main, resolving conflicts
Kept the local branch's homepage implementation (Meshkee product/category
API integration, redesigned header, Swiper carousels, hardcoded local
slider/banner images) as the source of truth for all conflicting sections,
since it reflects the most recent explicit direction.

Folded in the remote's non-conflicting additions: the static-image-slot
types/helpers in lib/meshkee.ts, the SlotImage component, and the
/meshkee/static-image-slots catalog route. These aren't wired into any
page yet since the live API's slot key ("home-hero") doesn't match the
keys this code expects ("slider", etc.) — worth reconciling once that's
settled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 10:45:54 +03:30
amirhosein.ashourloo e5f040d3eb create banner 2026-08-17 10:29:46 +03:30
71 changed files with 5008 additions and 1000 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "sanihome-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000,
"autoPort": true
}
]
}
+6
View File
@@ -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
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
banners.psd
+1
View File
@@ -1 +1,2 @@
@AGENTS.md @AGENTS.md
@CONTEXT.md
+418
View File
@@ -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.
+2
View File
@@ -1,6 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
output: "standalone",
allowedDevOrigins: ["sanihome.com", "localhost"], allowedDevOrigins: ["sanihome.com", "localhost"],
}; };
+71 -117
View File
@@ -1,19 +1,21 @@
{ {
"name": "sanihome-tmp", "name": "sanihome",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sanihome-tmp", "name": "sanihome",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"next": "16.3.0", "next": "16.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"swiper": "^14.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.20",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -631,9 +633,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -650,9 +649,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -669,9 +665,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -688,9 +681,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -707,9 +697,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -726,9 +713,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -745,9 +729,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -764,9 +745,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -783,9 +761,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -808,9 +783,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -833,9 +805,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -858,9 +827,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -883,9 +849,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -908,9 +871,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -933,9 +893,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -958,9 +915,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1228,9 +1182,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1247,9 +1198,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1266,9 +1214,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1285,9 +1230,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1526,9 +1468,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1546,9 +1485,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1566,9 +1502,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1586,9 +1519,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1676,6 +1606,19 @@
"tailwindcss": "4.3.3" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -2139,9 +2082,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2156,9 +2096,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2173,9 +2110,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2190,9 +2124,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2207,9 +2138,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2224,9 +2152,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2241,9 +2166,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2258,9 +2180,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2275,9 +2194,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2292,9 +2208,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2902,6 +2815,19 @@
"node": ">= 8" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4979,9 +4905,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5003,9 +4926,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5027,9 +4947,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5051,9 +4968,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5670,6 +5584,20 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6372,6 +6300,25 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tailwindcss": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@@ -6733,6 +6680,13 @@
"punycode": "^2.1.0" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+3 -1
View File
@@ -11,10 +11,12 @@
"dependencies": { "dependencies": {
"next": "16.3.0", "next": "16.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"swiper": "^14.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.20",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
+15
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+12
View File
@@ -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 });
}
+84
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Toast } from "@/components/ui/Toast";
import { submitContactForm } from "@/lib/meshkee";
const inputClass =
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-red";
const initialForm = { title: "", name: "", email: "", cellNumber: "", text: "" };
export default function ContactPage() {
const [form, setForm] = useState(initialForm);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [toast, setToast] = useState(false);
const update = (field: keyof typeof form) => (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((prev) => ({ ...prev, [field]: event.target.value }));
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
setError(null);
setSuccess(false);
const res = await submitContactForm({
title: form.title,
name: form.name,
email: form.email || undefined,
cellNumber: form.cellNumber || undefined,
text: form.text,
});
setBusy(false);
if (!res.ok) {
setError(res.message ?? "ارسال پیام ناموفق بود.");
return;
}
setSuccess(true);
setForm(initialForm);
setToast(true);
setTimeout(() => setToast(false), 3000);
};
return (
<div className="mx-auto max-w-2xl px-4 py-10">
<Breadcrumb items={[{ label: "خانه", href: "/" }, { label: "تماس با ما" }]} />
<h1 className="mb-2 text-2xl font-bold text-red-dark">تماس با ما</h1>
<p className="mb-6 text-sm text-muted">
سوال یا پیشنهادی دارید؟ فرم زیر را پر کنید تا در اسرع وقت پاسخ دهیم.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<input
required
placeholder="نام و نام خانوادگی"
value={form.name}
onChange={update("name")}
className={inputClass}
/>
<input
required
placeholder="موضوع پیام"
value={form.title}
onChange={update("title")}
className={inputClass}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<input
type="email"
placeholder="ایمیل (اختیاری)"
value={form.email}
onChange={update("email")}
className={inputClass}
/>
<input
type="tel"
placeholder="شماره تماس (اختیاری)"
value={form.cellNumber}
onChange={update("cellNumber")}
className={inputClass}
/>
</div>
<textarea
required
rows={5}
placeholder="متن پیام"
value={form.text}
onChange={update("text")}
className={inputClass}
/>
{error && <p className="text-sm text-red-dark">{error}</p>}
{success && <p className="text-sm text-green-700">پیام شما با موفقیت ارسال شد. متشکریم!</p>}
<button
type="submit"
disabled={busy}
className="rounded-xl bg-red px-8 py-3 text-sm font-medium text-white transition-colors hover:bg-red-dark disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? "در حال ارسال..." : "ارسال پیام"}
</button>
</form>
<Toast message="پیام شما با موفقیت ارسال شد ✓" show={toast} />
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+135 -3
View File
@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography";
:root { :root {
--background: #fafafa; --background: #fafafa;
@@ -22,7 +23,8 @@
--color-red-dark: var(--red-dark); --color-red-dark: var(--red-dark);
--color-red-light: var(--red-light); --color-red-light: var(--red-light);
--color-red-muted: var(--red-muted); --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); --font-display: var(--font-syne);
} }
@@ -37,11 +39,21 @@ html {
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); 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-display {
font-family: var(--font-syne), var(--font-vazirmatn), sans-serif; font-family: var(--font-syne), var(--font-iranyekan), sans-serif;
} }
@keyframes fade-up { @keyframes fade-up {
@@ -82,3 +94,123 @@ body {
.carousel-track::-webkit-scrollbar { .carousel-track::-webkit-scrollbar {
display: none; 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;
}
}
+57 -12
View File
@@ -1,11 +1,29 @@
import type { Metadata } from "next"; 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 "./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({ // Body text — headings use the bold weight instead, see globals.css.
variable: "--font-vazirmatn", const iranyekan = localFont({
subsets: ["arabic", "latin"], variable: "--font-iranyekan",
weight: ["400", "500", "600", "700"], src: "../../public/fonts/iranyekanweblightfanum.woff",
weight: "400",
});
const iranyekanBold = localFont({
variable: "--font-iranyekan-bold",
src: "../../public/fonts/iranyekanwebboldfanum.woff",
weight: "700",
}); });
const syne = Syne({ const syne = Syne({
@@ -14,19 +32,46 @@ const syne = Syne({
weight: ["600", "700", "800"], weight: ["600", "700", "800"],
}); });
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "سانی‌هوم | فروشگاه لوازم خانگی", const businessInfo = await getBusinessInfo();
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
}; 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 ( return (
<html <html
lang="fa" lang="fa"
dir="rtl" 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> </html>
); );
} }
+40 -30
View File
@@ -1,47 +1,57 @@
import { TopBar } from "@/components/home/TopBar";
import { Header } from "@/components/home/Header";
import { FeatureBar } from "@/components/home/FeatureBar"; import { FeatureBar } from "@/components/home/FeatureBar";
import { HeroSlider } from "@/components/home/HeroSlider"; import { HeroSlider } from "@/components/home/HeroSlider";
import { FeaturedCategories } from "@/components/home/FeaturedCategories"; import { FeaturedCategories } from "@/components/home/FeaturedCategories";
import { SpecialSaleCarousel } from "@/components/home/SpecialSaleCarousel"; import { StoreSpecialCarousels } from "@/components/home/StoreSpecialCarousels";
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 { AboutSanihome } from "@/components/home/AboutSanihome"; import { AboutSanihome } from "@/components/home/AboutSanihome";
import { BlogCarousel } from "@/components/home/BlogCarousel"; import { BlogCarousel } from "@/components/home/BlogCarousel";
import { Footer } from "@/components/home/Footer";
import { import {
STATIC_IMAGE_KEYS, getBlogs,
firstImageForSlot, getBusinessInfo,
getCategories,
getStoreSpecials,
getStaticImageSlots, getStaticImageSlots,
firstImageForSlot,
imagesForSlot, imagesForSlot,
STATIC_IMAGE_KEYS,
} from "@/lib/meshkee"; } from "@/lib/meshkee";
export default async function Home() { 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 ( return (
<> <>
<TopBar /> <HeroSlider images={heroImages} />
<Header /> <FeaturedCategories categories={categories} images={featuredCategoryImages} />
<main> <StoreSpecialCarousels
<HeroSlider images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)} /> groups={storeSpecials}
<SpecialSaleCarousel contactPhone={contactPhone}
banner={firstImageForSlot(slots, STATIC_IMAGE_KEYS.bannerInSpecialSale)} twoBannerImages={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
/> twoBannerAspectRatio={twoBannerSlot?.aspectRatio}
<FeatureBar /> threeBannerImages={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
<FeaturedCategories threeBannerAspectRatio={threeBannerSlot?.aspectRatio}
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)} />
/> <FeatureBar />
<TwoBanners images={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerRow)} /> <AboutSanihome
<TopSellingCarousel /> image={firstImageForSlot(staticImageSlots, STATIC_IMAGE_KEYS.aboutUsImage)}
<ThreeBanners /> aspectRatio={staticImageSlots.find(
<SamsungCarousel /> (slot) => slot.key === STATIC_IMAGE_KEYS.aboutUsImage,
<AboutSanihome /> )?.aspectRatio}
<BlogCarousel /> />
</main> <BlogCarousel posts={blogsResult.items} />
<Footer />
</> </>
); );
} }
+10
View File
@@ -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}`);
}
+15
View File
@@ -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}`);
}
+18
View File
@@ -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}` : ""}`);
}
+106
View File
@@ -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>
);
}
+216
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+126
View File
@@ -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,
);
}
+12 -12
View File
@@ -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 ( 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"> <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"> <div className="mx-auto max-w-7xl px-4">
@@ -8,7 +15,7 @@ export function AboutSanihome() {
<div className="animate-fade-up"> <div className="animate-fade-up">
<div className="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1"> <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 className="text-2xl font-bold text-red-dark md:text-3xl">
درباره سانی‌هوم درباره صنیع هوم
</h2> </h2>
<span <span
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base" 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> </span>
</div> </div>
<p className="mb-4 leading-relaxed text-muted"> <p className="mb-4 leading-relaxed text-muted">
سانی‌هوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاه‌های آنلاین لوازم صنیع هوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاه‌های آنلاین لوازم
خانگی در ایران فعالیت می‌کند. ما با ارائه محصولات اصل از برندهای خانگی در ایران فعالیت می‌کند. ما با ارائه محصولات اصل از برندهای
معتبر جهانی، گارانتی رسمی و خدمات پس از فروش، تجربه‌ای مطمئن برای معتبر جهانی، گارانتی رسمی و خدمات پس از فروش، تجربه‌ای مطمئن برای
خرید لوازم خانگی فراهم کرده‌ایم. خرید لوازم خانگی فراهم کرده‌ایم.
@@ -43,14 +50,7 @@ export function AboutSanihome() {
))} ))}
</div> </div>
</div> </div>
<Placeholder <AboutSlotImage image={image} aspectRatio={aspectRatio} />
src="/images/about.svg"
alt="درباره سانی‌هوم"
width="100%"
height="360px"
label="560×360"
className="w-full rounded-2xl"
/>
</div> </div>
</div> </div>
</section> </section>
+42
View File
@@ -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;
}
+51 -38
View File
@@ -1,9 +1,16 @@
import { Placeholder } from "@/components/ui/Placeholder"; import { Placeholder } from "@/components/ui/Placeholder";
import { SectionTitle } from "@/components/ui/SectionTitle"; import { SectionTitle } from "@/components/ui/SectionTitle";
import { Carousel } from "@/components/ui/Carousel"; 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 ( return (
<section className="py-10 md:py-14"> <section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4"> <div className="mx-auto max-w-7xl px-4">
@@ -11,43 +18,49 @@ export function BlogCarousel() {
title="آخرین مطالب وبلاگ" title="آخرین مطالب وبلاگ"
enTitle="Latest Blog" enTitle="Latest Blog"
action="وبلاگ" action="وبلاگ"
actionHref="/blog"
/> />
<Carousel ariaLabel="کاروسل وبلاگ"> {posts.length === 0 ? (
{blogPosts.map((post) => ( <EmptyState message="هنوز مطلبی ثبت نشده است." />
<article ) : (
key={post.id} <Carousel ariaLabel="کاروسل وبلاگ">
className="w-[280px] shrink-0 overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md md:w-[300px]" {posts.map((post) => (
> <article
<Placeholder key={post.id}
src={post.image} className="w-[280px] shrink-0 overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md md:w-[300px]"
alt={post.title} >
width="100%" <a href={`/blog/${post.slug}`}>
height="160px" <div className="p-[10px]">
label="300×160" <Placeholder
className="w-full rounded-none border-0 border-b border-dashed" src={post.image ?? "/images/placeholder.svg"}
/> alt={post.title}
<div className="p-4"> width="100%"
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted"> fit="contain"
<span>{post.date}</span> className="aspect-square w-full rounded-lg border-0"
<span>•</span> />
<span>{post.readTime} مطالعه</span> </div>
</div> <div className="p-4 pt-0">
<h3 className="mb-2 line-clamp-2 text-sm font-bold leading-relaxed"> {post.publishedAt && (
{post.title} <div className="mb-2 text-[11px] text-muted">{formatDate(post.publishedAt)}</div>
</h3> )}
<p className="line-clamp-2 text-xs leading-relaxed text-muted"> <h3 className="mb-2 line-clamp-2 text-sm font-bold leading-relaxed">
{post.excerpt} {post.title}
</p> </h3>
<button {post.abstract && (
type="button" <p className="line-clamp-2 text-xs leading-relaxed text-muted">
className="mt-3 text-xs font-medium text-red transition-colors hover:text-red-dark" {post.abstract}
> </p>
ادامه مطلب ← )}
</button> <span className="mt-3 flex items-center gap-1 text-xs font-medium text-red">
</div> ادامه مطلب
</article> <ChevronLeftIcon className="h-3.5 w-3.5" />
))} </span>
</Carousel> </div>
</a>
</article>
))}
</Carousel>
)}
</div> </div>
</section> </section>
); );
+89 -50
View File
@@ -1,65 +1,104 @@
import { Placeholder } from "@/components/ui/Placeholder"; "use client";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { SlotImage } from "@/components/ui/SlotImage";
import { categories } from "@/data/sample";
import type { StaticImageWithUrl } from "@/lib/meshkee";
type FeaturedCategoriesProps = { 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";
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>
{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[]; images?: StaticImageWithUrl[];
}; }) {
export function FeaturedCategories({ images = [] }: FeaturedCategoriesProps) {
return ( return (
<section className="py-10 md:py-14"> <section className="py-4 md:py-6">
<div className="mx-auto max-w-7xl px-4"> <div className="mx-auto max-w-7xl px-4">
<SectionTitle {images.length > 0 ? (
title="دسته‌بندی‌های پرطرفدار" <Swiper
enTitle="Featured Categories" modules={[FreeMode]}
action="همه دسته‌ها" dir="rtl"
/> slidesPerView={2.3}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8"> spaceBetween={12}
{images.length > 0 breakpoints={{
? images.map((image, i) => ( 640: { slidesPerView: 4.3, spaceBetween: 12 },
<div 1024: { slidesPerView: 8, spaceBetween: 12 },
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" freeMode={{ enabled: true, momentum: true, sticky: true }}
style={{ animationDelay: `${i * 60}ms` }} grabCursor
> className="!px-1 !py-2"
<SlotImage >
url={image.url} {images.map((image, i) => (
title={image.title} <SwiperSlide key={image.id}>
linkUrl={image.linkUrl} <div className="animate-slide-in h-full" style={{ animationDelay: `${i * 60}ms` }}>
imgClassName="mb-3 h-[72px] w-[72px] rounded-full object-cover transition-colors group-hover:border-red" <CategorySlotCard image={image} />
/>
{image.title ? (
<span className="text-center text-xs font-medium leading-relaxed md:text-sm">
{image.title}
</span>
) : null}
</div> </div>
)) </SwiperSlide>
: categories.map((cat, i) => ( ))}
</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 <a
key={cat.id}
href="#" 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` }} style={{ animationDelay: `${i * 60}ms` }}
> >
<Placeholder <div className="mb-3 w-[110px] overflow-hidden">
src={cat.image} {/* eslint-disable-next-line @next/next/no-img-element */}
alt={cat.name} <img
width="72px" src={cat.image ?? "/images/placeholder.svg"}
height="72px" alt={cat.name}
label="72×72" className="h-full w-full object-contain"
className="mb-3 rounded-full transition-colors group-hover:border-red" />
/> </div>
<span className="text-center text-xs font-medium leading-relaxed md:text-sm"> <span className="w-full truncate text-center text-xs font-medium leading-relaxed md:text-sm">
{cat.name} {cat.name}
</span> </span>
<span className="mt-1 text-[11px] text-muted">{cat.count} محصول</span> {cat.count != null && (
<span className="mt-1 text-[11px] text-muted">{cat.count} محصول</span>
)}
</a> </a>
))} </SwiperSlide>
</div> ))}
</Swiper>
)}
</div> </div>
</section> </section>
); );
+132 -53
View File
@@ -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 ( return (
<footer className="border-t border-red-dark/20 bg-red-dark text-white"> <footer className="border-t border-[#dddddd] bg-[#f1f1f1] text-black">
<div className="mx-auto max-w-7xl px-4 py-12"> <div className="mx-auto max-w-7xl px-4 pt-10 pb-3">
<div className="grid gap-8 md:grid-cols-4"> <div className="grid gap-8 md:grid-cols-4">
<div> <div>
<div className="mb-4 flex items-center gap-2"> <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"> {businessInfo?.logoDarkUrl || businessInfo?.logoUrl ? (
S // eslint-disable-next-line @next/next/no-img-element
</div> <img
<span className="font-display text-lg font-bold tracking-tight"> src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
SANIHOME alt={businessInfo.nameFa?.replace(/سانی[‌ ]?هوم/g, "صنیع هوم") || "صنیع هوم"}
</span> className="h-[72px] w-auto object-contain"
/>
) : (
<span className="font-display text-lg font-bold tracking-tight">
SANIHOME
</span>
)}
</div> </div>
<p className="text-sm leading-relaxed text-white/70"> {businessInfo?.about && (
فروشگاه آنلاین لوازم خانگی با بیش از ۱۵ سال سابقه. محصولات اصل، <p className="text-sm leading-relaxed text-black/70">{businessInfo.about}</p>
گارانتی معتبر، ارسال سریع. )}
</p>
</div> </div>
<div> <div>
<h4 className="mb-3 text-sm font-bold">دسترسی سریع</h4> <h4 className="mb-3 text-sm font-bold">دسترسی سریع</h4>
<ul className="space-y-2 text-sm text-white/70"> <ul className="space-y-2 text-sm text-black/70">
{["درباره ما", "تماس با ما", "سوالات متداول", "قوانین و مقررات", "پیگیری سفارش"].map( {quickLinks.map((link) => (
(link) => ( <li key={link}>
<li key={link}> <a href="#" className="transition-colors hover:text-black">
<a href="#" className="transition-colors hover:text-white"> {link}
{link} </a>
</a> </li>
</li> ))}
),
)}
</ul> </ul>
</div> </div>
<div> <div>
<h4 className="mb-3 text-sm font-bold">دسته‌بندی‌ها</h4> <h4 className="mb-3 text-sm font-bold">دسته‌بندی‌ها</h4>
<ul className="space-y-2 text-sm text-white/70"> {categories.length === 0 ? (
{["یخچال و فریزر", "ماشین لباسشویی", "تلویزیون", "جاروبرقی", "لوازم آشپزخانه"].map( <p className="text-sm text-black/50">هنوز دسته‌بندی‌ای ثبت نشده است.</p>
(link) => ( ) : (
<li key={link}> <ul className="space-y-2 text-sm text-black/70">
<a href="#" className="transition-colors hover:text-white"> {categories.slice(0, 5).map((cat) => (
{link} <li key={cat.id}>
<a href="#" className="transition-colors hover:text-black">
{cat.name}
</a> </a>
</li> </li>
), ))}
)} </ul>
</ul> )}
</div> </div>
<div> <div>
<h4 className="mb-3 text-sm font-bold">تماس با ما</h4> <h4 className="mb-3 text-sm font-bold">تماس با ما</h4>
<ul className="space-y-2 text-sm text-white/70"> {hasContactInfo ? (
<li>تهران، خیابان ولیعصر</li> <ul className="space-y-2 text-sm text-black/70">
<li>تلفن: ۰۲۱-۸۸۰۰۱۲۳۴</li> {businessInfo?.addresses.map((address) => <li key={address}>{address}</li>)}
<li>ایمیل: info@sanihome.ir</li> {businessInfo?.phoneNumbers.map((phone) => (
</ul> <li key={phone}>تلفن: {phone}</li>
<div className="mt-4 flex gap-3"> ))}
{["IG", "TG", "WA"].map((social) => ( {businessInfo?.emails.map((email) => <li key={email}>ایمیل: {email}</li>)}
<a </ul>
key={social} ) : (
href="#" <p className="text-sm text-black/50">هنوز اطلاعات تماس ثبت نشده است.</p>
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" )}
> {socialEntries.length > 0 && (
{social} <div className="mt-4 flex gap-3">
</a> {socialEntries.map(([key, url]) => (
))} <a
</div> 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"
>
{socialLabels[key] ?? key.slice(0, 2).toUpperCase()}
</a>
))}
</div>
)}
</div> </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"> {businessInfo?.trustBadges && businessInfo.trustBadges.length > 0 ? (
<p>© ۱۴۰۵ سانی‌هوم — تمامی حقوق محفوظ است.</p> <div className="mt-10 flex flex-wrap items-center justify-center gap-4 border-t border-black/10 pt-6">
<p className="font-display tracking-wide uppercase"> {businessInfo.trustBadges.map((badge) => (
Designed for Sanihome <div
</p> 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>
</div> </div>
</footer> </footer>
+306 -107
View File
@@ -1,41 +1,217 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; 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 = [ const navLinks = [
{ label: "فروش ویژه", href: "#" }, { label: "فروشگاه", href: "/store-items" },
{ label: "فروش ویژه", href: "/store-items?special=sale" },
{ label: "درباره ما", href: "#" }, { label: "درباره ما", href: "#" },
{ label: "سوالات متداول", href: "#" }, { label: "سوالات متداول", href: "#" },
{ label: "تماس با ما", href: "#" }, { label: "تماس با ما", href: "/contact" },
]; ];
const megaColumns = [ const LOGIN_URL = "https://customer.sanihome.ir";
{
title: "سرمایش و گرمایش",
en: "Cooling & Heating",
items: ["یخچال و فریزر", "کولر گازی", "آبگرمکن", "بخاری"],
},
{
title: "شستشو",
en: "Laundry & Care",
items: ["ماشین لباسشویی", "ماشین ظرفشویی", "خشک‌کن", "اتو بخار"],
},
{
title: "صوتی و تصویری",
en: "AV & Display",
items: ["تلویزیون", "ساندبار", "سینمای خانگی", "پروژکتور"],
},
{
title: "آشپزخانه",
en: "Kitchen",
items: ["مایکروویو", "جاروبرقی", "هود", "اجاق گاز"],
},
];
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 [menuOpen, setMenuOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const level2 = flattenLevel2(categoryTree);
useEffect(() => { useEffect(() => {
const onClickOutside = (event: MouseEvent) => { const onClickOutside = (event: MouseEvent) => {
@@ -50,58 +226,60 @@ export function Header() {
return ( return (
<header className="sticky top-0 z-50 border-b border-border bg-surface/95 backdrop-blur-sm"> <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="mx-auto max-w-7xl px-4">
<div className="flex items-center justify-between gap-4 py-4"> <div className="grid grid-cols-3 items-center gap-4 py-2 min-[1200px]:flex min-[1200px]:justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center justify-start">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red text-lg font-bold text-white"> <button
S type="button"
</div> onClick={() => setDrawerOpen(true)}
<div> aria-label="منو"
<p className="font-display text-xl font-bold tracking-tight"> className="flex h-10 w-10 items-center justify-center rounded-lg border border-border transition-colors hover:border-red hover:text-red min-[1200px]:hidden"
<span className="text-red">SANI</span> >
<span className="text-foreground">HOME</span> <MenuIcon className="h-5 w-5" />
</p> </button>
<p className="text-xs text-muted">فروشگاه لوازم خانگی</p> <div className="hidden min-[1200px]:block">
<Logo src={logoUrl} />
</div> </div>
</div> </div>
<div className="hidden flex-1 px-6 md:block"> <div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6">
<div className="flex overflow-hidden rounded-xl border border-border bg-background"> <div className="min-[1200px]:hidden">
<input <Logo src={logoUrl} large />
type="search" </div>
placeholder="جستجوی محصول، برند یا دسته‌بندی..." <div className="hidden w-full min-[1200px]:block">
className="flex-1 bg-transparent px-4 py-2.5 text-sm outline-none placeholder:text-muted" <SearchBar />
/> </div>
<button </div>
type="button"
className="bg-red px-5 text-sm font-medium text-white transition-colors hover:bg-red-dark" <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 className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red text-white">
</button> <UserIcon className="h-5 w-5" />
</span>
<span className="text-right leading-tight">
<span className="block text-sm font-bold whitespace-nowrap text-foreground">
حساب کاربری
</span>
<span className="block text-xs whitespace-nowrap text-muted">ورود/ثبت‌نام</span>
</span>
</a>
</div>
<div className="sm:hidden">
<CartButton />
</div> </div>
</div>
<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"
>
<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>
</button>
</div> </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 <div
ref={menuRef} ref={menuRef}
className="relative" className="relative"
@@ -115,12 +293,12 @@ export function Header() {
aria-haspopup="true" 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" 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> </button>
{menuOpen && ( {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 className="mb-4 flex items-end justify-between gap-3 border-b border-border pb-3">
<div> <div>
<p className="font-bold text-red-dark">همه دسته‌بندی‌ها</p> <p className="font-bold text-red-dark">همه دسته‌بندی‌ها</p>
@@ -128,45 +306,59 @@ export function Header() {
Shop by Category Shop by Category
</p> </p>
</div> </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> </a>
</div> </div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4"> {level2.length === 0 ? (
{megaColumns.map((column) => ( <EmptyState message="هنوز دسته‌بندی‌ای ثبت نشده است." />
<div key={column.title}> ) : (
<p className="mb-0.5 text-sm font-bold text-red-dark">{column.title}</p> <div className="columns-1 gap-6 sm:columns-2 lg:columns-4">
<p className="font-display mb-3 text-[10px] tracking-wider text-muted uppercase"> {level2.map((node) => (
{column.en} <div key={node.id} className="mb-4 break-inside-avoid">
</p> <a
<ul className="space-y-2"> href={`/store-items?categoryId=${node.id}`}
{column.items.map((item) => ( className="text-sm font-bold text-red-dark transition-colors hover:text-red"
<li key={item}> >
<a {node.nameFa}
href="#" </a>
className="text-sm text-muted transition-colors hover:text-red" {node.children.length > 0 && (
> <ul className="mt-1.5 space-y-1.5">
{item} {node.children.map((child) => (
</a> <li key={child.id}>
</li> <a
))} href={`/store-items?categoryId=${child.id}`}
</ul> className="text-sm text-muted transition-colors hover:text-red"
</div> >
))} {child.nameFa}
</div> </a>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
<div className="mt-5 grid grid-cols-2 gap-2 border-t border-border pt-4 sm:grid-cols-4"> {categories.length > 0 && (
{categories.slice(0, 4).map((cat) => ( <div className="mt-5 grid grid-cols-2 gap-2 border-t border-border pt-4 sm:grid-cols-4">
<a {categories.slice(0, 4).map((cat) => (
key={cat.id} <a
href="#" key={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" 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> {cat.name}
))} </a>
</div> ))}
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -182,6 +374,13 @@ export function Header() {
))} ))}
</nav> </nav>
</div> </div>
<MobileDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
categoryTree={categoryTree}
logoUrl={logoUrl}
/>
</header> </header>
); );
} }
+49 -56
View File
@@ -2,85 +2,78 @@
import { useState } from "react"; import { useState } from "react";
import { Placeholder } from "@/components/ui/Placeholder"; import { Placeholder } from "@/components/ui/Placeholder";
import { SlotImage } from "@/components/ui/SlotImage";
import { sliderSlides } from "@/data/sample"; import { sliderSlides } from "@/data/sample";
import type { StaticImageWithUrl } from "@/lib/meshkee"; import type { StaticImageWithUrl } from "@/lib/meshkee";
type HeroSliderProps = { const PEEK_OFFSET = 70;
images?: StaticImageWithUrl[];
};
export function HeroSlider({ images = [] }: HeroSliderProps) { type Slide = { id: string; title: string; image: string; href?: string };
const remote = images.length > 0;
const slides = remote export function HeroSlider({ images = [] }: { images?: StaticImageWithUrl[] }) {
? images.map((image) => ({ const slides: Slide[] =
id: image.id, images.length > 0
title: image.title ?? "", ? images.map((image) => ({
src: image.url, id: image.id,
linkUrl: image.linkUrl, title: image.titleFa ?? image.titleEn ?? "",
})) image: image.url,
: sliderSlides.map((slide) => ({ href: image.linkUrl ?? undefined,
id: String(slide.id), }))
title: slide.title, : sliderSlides.map((slide) => ({
src: slide.image, id: String(slide.id),
linkUrl: undefined as string | null | undefined, title: slide.title,
})); image: slide.image,
}));
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const len = slides.length; const len = slides.length;
const getIndex = (offset: number) => (active + offset + len) % len; const getDiff = (i: number) => {
let diff = i - active;
const positions = [ if (diff > len / 2) diff -= len;
{ offset: -1, scale: "scale-[0.92]", opacity: "opacity-55", z: "z-10" }, if (diff < -len / 2) diff += len;
{ offset: 0, scale: "scale-100", opacity: "opacity-100", z: "z-20" }, return diff;
{ offset: 1, scale: "scale-[0.92]", opacity: "opacity-55", z: "z-10" }, };
];
return ( return (
<section className="overflow-x-clip bg-gradient-to-b from-red-light/30 to-background py-4 md:py-6"> <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 mx-auto w-full max-w-7xl px-4">
<div className="relative h-[220px] sm:h-[280px] md:h-[340px] lg:h-[380px]"> <div className="relative h-[220px] sm:h-[280px] md:h-[340px] lg:h-[380px]">
{positions.map(({ offset, scale, opacity, z }) => { {slides.map((slide, i) => {
const isCenter = offset === 0; const diff = getDiff(i);
if (!isCenter && len < 2) return null; const isCenter = diff === 0;
const slide = slides[getIndex(offset)]; const isVisible = Math.abs(diff) <= 1;
const heightClass = `w-full h-[200px] sm:h-[260px] md:h-[310px] ${ const media = (
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`}
/>
) : (
<Placeholder <Placeholder
src={slide.src} src={slide.image}
alt={slide.title} alt={slide.title}
width="100%" width="100%"
label={isCenter ? "1280×380" : "880×350"} className="h-full w-full rounded-none border-0"
className={`rounded-none border-0 ${heightClass}`}
/> />
); );
return ( return (
<div <div
key={offset} key={slide.id}
className={`absolute top-1/2 -translate-y-1/2 transition-all duration-500 ease-out ${scale} ${opacity} ${z} ${ className={`absolute inset-0 flex items-center justify-center ${
isCenter isCenter ? "z-20" : "z-10 pointer-events-none"
? "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"}`}
> >
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-lg"> <div
{media} 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>
</div> </div>
); );
-30
View File
@@ -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>
);
}
+124
View File
@@ -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>
))}
</>
);
}
+44 -26
View File
@@ -1,36 +1,54 @@
import { Placeholder } from "@/components/ui/Placeholder"; import type { StaticImageWithUrl } from "@/lib/meshkee";
const banners = [ const fallbackBanners = [
{ title: "ماشین لباسشویی", subtitle: "از ۲۸ میلیون", label: "420×160", image: "/images/promo-1.svg" }, { src: "/images/banner3/6.jpg", alt: "خانه‌ای تمیزتر، نفس‌هایی آسوده‌تر" },
{ title: "جاروبرقی", subtitle: "ارسال فوری", label: "420×160", image: "/images/promo-2.svg" }, { src: "/images/banner3/7.jpg", alt: "خنکای دلنشین، در تمام لحظه‌ها" },
{ title: "لوازم آشپزخانه", subtitle: "تا ۲۰٪ تخفیف", label: "420×160", image: "/images/promo-3.svg" }, { 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 ( return (
<section className="border-y border-border bg-background py-10 md:py-14"> <section className="border-y border-border bg-background py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4"> <div className="mx-auto max-w-7xl px-4">
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
{banners.map((banner) => ( {images.length > 0
<a ? images.map((image) => {
key={banner.title} const frame = (
href="#" // eslint-disable-next-line @next/next/no-img-element
className="group relative overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-red hover:shadow-md" <img
> src={image.url}
<Placeholder alt={image.titleFa ?? image.titleEn ?? ""}
src={banner.image} style={style}
alt={banner.title} className="h-auto w-full object-cover"
width="100%" />
height="160px" );
label={banner.label} return (
className="w-full rounded-none border-0" <div
/> key={image.id}
<div className="absolute inset-0 flex flex-col items-start justify-end bg-gradient-to-t from-black/60 to-transparent p-5"> className="group overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-red hover:shadow-md"
<h3 className="font-bold text-white">{banner.title}</h3> >
<p className="text-sm text-white/75">{banner.subtitle}</p> {image.linkUrl ? <a href={image.linkUrl}>{frame}</a> : frame}
</div> </div>
</a> );
))} })
: 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> </div>
</div> </div>
</section> </section>
@@ -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>
);
}
+32 -50
View File
@@ -1,69 +1,51 @@
import { Placeholder } from "@/components/ui/Placeholder";
import { SlotImage } from "@/components/ui/SlotImage";
import type { StaticImageWithUrl } from "@/lib/meshkee"; import type { StaticImageWithUrl } from "@/lib/meshkee";
type TwoBannersProps = {
images?: StaticImageWithUrl[];
};
const fallbackBanners = [ const fallbackBanners = [
{ { src: "/images/banner/5.jpg", alt: "وقتی که انرژی لازمی" },
src: "/images/banner-1.svg", { src: "/images/banner/4.jpg", alt: "قدرت بخار، درخشش بی‌نقص" },
alt: "تخفیف ویژه یخچال و فریزر",
kicker: "Summer Sale",
title: "تخفیف ویژه یخچال و فریزر",
subtitle: "تا ۲۵٪ تخفیف — تا پایان مرداد",
},
{
src: "/images/banner-2.svg",
alt: "تلویزیون‌های هوشمند ۲۰۲۶",
kicker: "Smart TV",
title: "تلویزیون‌های هوشمند ۲۰۲۶",
subtitle: "QLED و OLED با اقساط ۱۲ ماهه",
},
]; ];
export function TwoBanners({ images = [] }: TwoBannersProps) { export function TwoBanners({
images = [],
aspectRatio,
}: {
images?: StaticImageWithUrl[];
aspectRatio?: string;
}) {
const style = aspectRatio ? { aspectRatio: aspectRatio.replace(":", " / ") } : undefined;
return ( return (
<section className="py-10 md:py-14"> <section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4"> <div className="mx-auto max-w-7xl px-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{images.length > 0 {images.length > 0
? images.map((image) => ( ? images.map((image) => {
<div const frame = (
key={image.id} // eslint-disable-next-line @next/next/no-img-element
className="group relative overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg" <img
> src={image.url}
<SlotImage alt={image.titleFa ?? image.titleEn ?? ""}
url={image.url} style={style}
title={image.title} className="h-auto w-full object-cover"
linkUrl={image.linkUrl}
className="block"
imgClassName="block h-[200px] w-full object-cover"
/> />
</div> );
)) return (
<div
key={image.id}
className="group overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
>
{image.linkUrl ? <a href={image.linkUrl}>{frame}</a> : frame}
</div>
);
})
: fallbackBanners.map((banner) => ( : fallbackBanners.map((banner) => (
<a <a
key={banner.src} key={banner.src}
href="#" 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 {/* eslint-disable-next-line @next/next/no-img-element */}
src={banner.src} <img src={banner.src} alt={banner.alt} className="h-auto w-full" />
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>
</a> </a>
))} ))}
</div> </div>
+360
View File
@@ -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>
)}
</>
);
}
+206
View File
@@ -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>
</>
);
}
+189
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+46 -18
View File
@@ -1,10 +1,16 @@
"use client"; "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 = { type CarouselProps = {
children: React.ReactNode; children: React.ReactNode;
ariaLabel: string; ariaLabel: string;
slideClassName?: string;
}; };
function ChevronLeftIcon() { function ChevronLeftIcon() {
@@ -35,45 +41,67 @@ function ChevronRightIcon() {
); );
} }
export function Carousel({ children, ariaLabel }: CarouselProps) { export function Carousel({ children, ariaLabel, slideClassName }: CarouselProps) {
const trackRef = useRef<HTMLDivElement>(null); const swiperRef = useRef<SwiperClass | null>(null);
const [isLocked, setIsLocked] = useState(false);
const scroll = (direction: "prev" | "next") => {
const track = trackRef.current;
if (!track) return;
const amount = direction === "next" ? -280 : 280;
track.scrollBy({ left: amount, behavior: "smooth" });
};
const btnClass = 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 ( return (
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => scroll("prev")} onClick={() => swiperRef.current?.slidePrev()}
aria-label="قبلی" aria-label="قبلی"
disabled={isLocked}
className={`${btnClass} right-1`} className={`${btnClass} right-1`}
> >
<ChevronRightIcon /> <ChevronRightIcon />
</button> </button>
<button <button
type="button" type="button"
onClick={() => scroll("next")} onClick={() => swiperRef.current?.slideNext()}
aria-label="بعدی" aria-label="بعدی"
disabled={isLocked}
className={`${btnClass} left-1`} className={`${btnClass} left-1`}
> >
<ChevronLeftIcon /> <ChevronLeftIcon />
</button> </button>
<div <Swiper
ref={trackRef} 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" role="region"
aria-label={ariaLabel} 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} {Children.map(children, (child) => (
</div> // 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> </div>
); );
} }
+17
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+3 -1
View File
@@ -5,6 +5,7 @@ type PlaceholderProps = {
height?: string; height?: string;
label?: string; label?: string;
className?: string; className?: string;
fit?: "cover" | "contain";
}; };
export function Placeholder({ export function Placeholder({
@@ -14,6 +15,7 @@ export function Placeholder({
height, height,
label, label,
className = "", className = "",
fit = "cover",
}: PlaceholderProps) { }: PlaceholderProps) {
return ( return (
<div <div
@@ -23,7 +25,7 @@ export function Placeholder({
<img <img
src={src} src={src}
alt={alt} 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 && ( {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"> <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">
+55 -33
View File
@@ -4,11 +4,16 @@ import { formatPrice } from "@/data/sample";
type ProductCardProps = { type ProductCardProps = {
name: string; name: string;
brand: string; brand: string;
price: number; price: number | null;
oldPrice?: number; oldPrice?: number | null;
discount?: number; discount?: number;
image: string; image: string;
variant?: "sale" | "default" | "brand"; variant?: "sale" | "default" | "brand";
href?: string;
fluid?: boolean;
contactPhone?: string;
variantId?: string;
slug?: string;
}; };
export function ProductCard({ export function ProductCard({
@@ -19,54 +24,71 @@ export function ProductCard({
discount, discount,
image, image,
variant = "default", variant = "default",
href,
fluid = false,
}: ProductCardProps) { }: ProductCardProps) {
return ( const info = (
<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"> <div className="relative p-[10px]">
<Placeholder <Placeholder
src={image} src={image}
alt={name} alt={name}
width="100%" width="100%"
height="200px" fit="contain"
label="240×200" className="aspect-square w-full rounded-lg border-0"
className="w-full rounded-none border-0 border-b border-dashed"
/> />
{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" && ( {variant === "brand" && (
<span className="font-display absolute top-3 left-3 text-[10px] font-bold tracking-widest text-red-dark uppercase"> <span className="font-display absolute top-3 left-3 text-[10px] font-bold tracking-widest text-red-dark uppercase">
{brand} {brand}
</span> </span>
)} )}
</div> </div>
<div className="p-4"> <div className="px-4 pb-4">
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase"> {brand && (
{brand} <p className="font-display mb-1 line-clamp-2 h-8 text-[11px] leading-4 tracking-wide text-muted uppercase">
</p> {brand}
<h3 className="mb-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium"> </p>
)}
<h3 className="mb-2 line-clamp-2 h-11 text-sm leading-6 font-medium">
{name} {name}
</h3> </h3>
<div className="flex flex-wrap items-center gap-2"> <div className="flex h-12 flex-col items-end justify-end space-y-1 text-left">
<span className="text-base font-bold text-red-dark"> {oldPrice != null && (
{formatPrice(price)} <div className="flex items-center gap-2">
<span className="mr-0.5 text-xs font-normal text-muted">تومان</span> <span className="text-sm text-muted line-through">{formatPrice(oldPrice)}</span>
</span> {discount != null && (
{oldPrice && ( <span className="rounded-md bg-red px-1.5 py-0.5 text-[11px] font-bold text-white">
<span className="text-xs text-muted line-through"> {discount}٪
{formatPrice(oldPrice)} </span>
</span> )}
</div>
)} )}
<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>
<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> </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> </article>
); );
} }
+22 -16
View File
@@ -1,35 +1,41 @@
import { ChevronLeftIcon } from "@/components/ui/icons";
type SectionTitleProps = { type SectionTitleProps = {
title: string; title: string;
enTitle: string; enTitle?: string;
subtitle?: string; subtitle?: string;
action?: string; action?: string;
actionHref?: string;
}; };
export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleProps) { export function SectionTitle({ title, enTitle, subtitle, action, actionHref }: SectionTitleProps) {
return ( 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>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> <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> <h2 className="text-xl font-bold text-red-dark md:text-2xl">{title}</h2>
<span {enTitle && (
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base" <span
lang="en" className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
dir="ltr" lang="en"
> dir="ltr"
{enTitle} >
</span> {enTitle}
</span>
)}
</div> </div>
{subtitle && ( {subtitle && (
<p className="mt-1 text-sm text-muted">{subtitle}</p> <p className="mt-1 text-sm text-muted">{subtitle}</p>
)} )}
</div> </div>
{action && ( {action && actionHref && (
<button <a
type="button" href={actionHref}
className="shrink-0 text-sm font-medium text-red transition-colors hover:text-red-dark" 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} ← {action}
</button> <ChevronLeftIcon className="h-4 w-4" />
</a>
)} )}
</div> </div>
); );
+13
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+74
View File
@@ -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;
}
+13
View File
@@ -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>
);
}
+3 -202
View File
@@ -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 = [ export const sliderSlides = [
{ {
id: 1, id: 1,
title: "جشنواره تابستانه لوازم خانگی", title: "جشنواره تابستانه لوازم خانگی",
subtitle: "تا ۳۰٪ تخفیف روی برندهای منتخب", subtitle: "تا ۳۰٪ تخفیف روی برندهای منتخب",
cta: "مشاهده پیشنهادها", cta: "مشاهده پیشنهادها",
image: "/images/slider-1.svg", image: "/images/slider/1.jpg",
}, },
{ {
id: 2, id: 2,
title: "سری جدید Samsung Bespoke", title: "سری جدید Samsung Bespoke",
subtitle: "طراحی هوشمند برای آشپزخانه مدرن", subtitle: "طراحی هوشمند برای آشپزخانه مدرن",
cta: "کاوش مجموعه", cta: "کاوش مجموعه",
image: "/images/slider-2.svg", image: "/images/slider/2.jpg",
}, },
{ {
id: 3, id: 3,
title: "ارسال رایگان به سراسر کشور", title: "ارسال رایگان به سراسر کشور",
subtitle: "برای سفارش‌های بالای ۵ میلیون تومان", subtitle: "برای سفارش‌های بالای ۵ میلیون تومان",
cta: "خرید کنید", cta: "خرید کنید",
image: "/images/slider-3.svg", image: "/images/slider/3.jpg",
}, },
]; ];
+126
View File
@@ -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)}`;
}
+720 -44
View File
@@ -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 = { export type StaticImage = {
id: string; id: string;
url?: string | null; url?: string | null;
title?: string | null; titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null; linkUrl?: string | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
@@ -13,83 +643,80 @@ export type StaticImageWithUrl = StaticImage & { url: string };
export type StaticImageSlot = { export type StaticImageSlot = {
id: string; id: string;
key: string; key: string;
pageKey?: string;
kind: "single" | "list"; kind: "single" | "list";
label?: string; label?: string;
aspectRatio?: string; aspectRatio?: string;
recommendedWidth?: number; recommendedWidth?: number;
itemCount?: number | null;
sortOrder?: number; sortOrder?: number;
images?: StaticImage[]; images?: StaticImage[];
}; };
export const STATIC_IMAGE_KEYS = { export const STATIC_IMAGE_KEYS = {
slider: "slider", homeHero: "home-hero",
bannerInSpecialSale: "banner-in-special-sale", twoBanner: "two-banner",
categories: "categories", threeBannerRow: "three-banner-row",
twoBannerRow: "two-banner-row", featuredCategories: "featured-categories",
aboutUsImage: "about-us-image",
} as const; } as const;
export const STATIC_IMAGE_SLOT_CATALOG = [ export const STATIC_IMAGE_SLOT_CATALOG = [
{ {
key: STATIC_IMAGE_KEYS.slider, key: STATIC_IMAGE_KEYS.homeHero,
label: "Homepage slider", label: "Homepage hero slider",
kind: "list" as const, kind: "list" as const,
pageKey: "home", pageKey: "home",
aspectRatio: "16:9", aspectRatio: "21:9",
itemCount: null, itemCount: null,
recommendedWidth: 1440, recommendedWidth: 1920,
}, },
{ {
key: STATIC_IMAGE_KEYS.bannerInSpecialSale, key: STATIC_IMAGE_KEYS.twoBanner,
label: "Banner in special sale", label: "Two banner row",
kind: "single" as const, kind: "list" as const,
pageKey: "home", pageKey: "home",
aspectRatio: "12:19", aspectRatio: "12:5",
itemCount: 1, itemCount: 2,
recommendedWidth: 480, recommendedWidth: 1200,
}, },
{ {
key: STATIC_IMAGE_KEYS.categories, key: STATIC_IMAGE_KEYS.threeBannerRow,
label: "Featured category images", 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, kind: "list" as const,
pageKey: "home", pageKey: "home",
aspectRatio: "1:1", aspectRatio: "1:1",
itemCount: null, itemCount: null,
recommendedWidth: 400, recommendedWidth: 300,
}, },
{ {
key: STATIC_IMAGE_KEYS.twoBannerRow, key: STATIC_IMAGE_KEYS.aboutUsImage,
label: "Two banner row", label: "Homepage about-us image",
kind: "list" as const, kind: "single" as const,
pageKey: "home", pageKey: "home",
aspectRatio: "16:5", aspectRatio: "14:9",
itemCount: 2, itemCount: 1,
recommendedWidth: 1440, 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[]> { export async function getStaticImageSlots(): Promise<StaticImageSlot[]> {
if (!API_BASE || !DOMAIN) return []; const res = await apiGet<{ items: StaticImageSlot[] }>(`/tenants/${DOMAIN}/website/static-images`, {
pageKey: "home",
try { });
const res = await fetch( return res?.items ?? [];
`${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 [];
}
} }
export function imagesForSlot( export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImageWithUrl[] {
slots: StaticImageSlot[],
key: string,
): StaticImageWithUrl[] {
const images = slots.find((slot) => slot.key === key)?.images ?? []; const images = slots.find((slot) => slot.key === key)?.images ?? [];
return [...images] return [...images]
.filter((image): image is StaticImageWithUrl => Boolean(image.url)) .filter((image): image is StaticImageWithUrl => Boolean(image.url))
@@ -102,3 +729,52 @@ export function firstImageForSlot(
): StaticImageWithUrl | undefined { ): StaticImageWithUrl | undefined {
return imagesForSlot(slots, key)[0]; 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 };
}