Compare commits

..
14 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
37 changed files with 1696 additions and 710 deletions
+202 -4
View File
@@ -156,6 +156,11 @@ persistent memory as "for all the user's sites", not just this one)
- **Breadcrumb on every page except the homepage** — above the `<h1>`, via - **Breadcrumb on every page except the homepage** — above the `<h1>`, via
`src/components/ui/Breadcrumb.tsx`. `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 - **Product detail layout**: 3-column grid (desktop) — gallery
(`ProductGallery`, ~1/3, includes hover-zoom + click-to-open lightbox) → (`ProductGallery`, ~1/3, includes hover-zoom + click-to-open lightbox) →
title + variation pickers (~5/12) → sticky buy-box (~1/4). One title + variation pickers (~5/12) → sticky buy-box (~1/4). One
@@ -180,6 +185,28 @@ persistent memory as "for all the user's sites", not just this one)
in the array — first DOM child renders rightmost under `dir="rtl"`). in the array — first DOM child renders rightmost under `dir="rtl"`).
- Category tile images: 110px, no border, no border-radius - Category tile images: 110px, no border, no border-radius
(`FeaturedCategories.tsx`). (`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 ## Static-image slots
@@ -188,21 +215,192 @@ catalog data. `STATIC_IMAGE_SLOT_CATALOG` in `meshkee.ts` is the source of
truth for which slots exist; publishing it via truth for which slots exist; publishing it via
`src/app/meshkee/static-image-slots/route.ts` (`/meshkee/static-image-slots`) `src/app/meshkee/static-image-slots/route.ts` (`/meshkee/static-image-slots`)
is how the admin dashboard's "refresh" imports new keys. Current keys: is how the admin dashboard's "refresh" imports new keys. Current keys:
`home-hero`, `two-banner`, `three-banner-row`, `featured-categories`. Adding `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 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 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 "current" behavior was before the slot existed when it's empty (see
`FeaturedCategories.tsx` for the pattern: real slot images when present, `FeaturedCategories.tsx` for the pattern: real slot images when present,
else the pre-existing UI unchanged). 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 map
| Route | Notes | | Route | Notes |
|---|---| |---|---|
| `/` | Homepage — all the carousels/banners | | `/` | Homepage — all the carousels/banners |
| `/products`, `/products/[slug]` | Catalog browsing, category filter via `?categoryId=` | | `/store-items`, `/store-items/[id]` | Canonical catalog and product detail; filters include category, price and stock; curated groups use `?specialId=` |
| `/products/id/[id]` | Redirect-only resolver, see "Group-source id vs productId" above | | `/products`, `/products/[slug]`, `/products/id/[id]` | Backwards-compatible redirects to the canonical `/store-items` routes |
| `/store-items`, `/store-items/[id]` | Variant-level browsing/detail, see above |
| `/blog`, `/blog/[slug]` | Blog listing/detail | | `/blog`, `/blog/[slug]` | Blog listing/detail |
| `/contact` | Contact form → `POST /contact-submissions`, toast + reset on success | | `/contact` | Contact form → `POST /contact-submissions`, toast + reset on success |
+1
View File
@@ -1,6 +1,7 @@
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", output: "standalone",
allowedDevOrigins: ["sanihome.com", "localhost"], allowedDevOrigins: ["sanihome.com", "localhost"],
}; };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+35 -13
View File
@@ -1,12 +1,21 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Breadcrumb } from "@/components/ui/Breadcrumb"; import Link from "next/link";
import { getBlog } from "@/lib/meshkee"; import { SimilarArticles } from "@/components/blog/SimilarArticles";
import { PageShell } from "@/components/ui/PageShell";
import { getBlog, getBlogs } from "@/lib/meshkee";
function formatDate(iso?: string) { function formatDate(iso?: string) {
if (!iso) return ""; if (!iso) return "";
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "long" }).format(new Date(iso)); 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({ export default async function BlogDetailPage({
params, params,
}: { }: {
@@ -17,17 +26,26 @@ export default async function BlogDetailPage({
if (!post) notFound(); 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 ( return (
<article className="mx-auto max-w-3xl px-4 py-10"> <PageShell
<Breadcrumb breadcrumbItems={[
items={[
{ label: "خانه", href: "/" }, { label: "خانه", href: "/" },
{ label: "وبلاگ", href: "/blog" }, { label: "وبلاگ", href: "/blog" },
{ label: post.title }, { label: post.title },
]} ]}
/> >
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_300px] lg:items-start">
<div className="mb-6 flex items-center gap-2 text-xs text-muted"> <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.categoryName && <span>{post.categoryName}</span>}
{post.publishedAt && ( {post.publishedAt && (
<> <>
@@ -43,20 +61,24 @@ export default async function BlogDetailPage({
)} )}
</div> </div>
<h1 className="mb-6 text-2xl font-bold leading-relaxed text-red-dark md:text-3xl"> <h1 className="mb-6 text-xl font-bold leading-relaxed text-red-dark md:text-3xl">
{post.title} {post.title}
</h1> </h1>
<div <div
className="prose max-w-none" className="prose article-content max-w-none"
dangerouslySetInnerHTML={{ __html: post.mainTextHtml }} dangerouslySetInnerHTML={{ __html: withResponsiveTables(post.mainTextHtml) }}
/> />
<div className="mt-10 border-t border-border pt-6"> <div className="mt-10 border-t border-border pt-6">
<a href="/blog" className="text-sm font-medium text-red hover:text-red-dark"> <Link href="/blog" className="text-sm font-medium text-red hover:text-red-dark">
→ بازگشت به وبلاگ → بازگشت به وبلاگ
</a> </Link>
</div> </div>
</article> </article>
<SimilarArticles posts={similarPosts} />
</div>
</PageShell>
); );
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+114
View File
@@ -100,3 +100,117 @@ h6 {
height: auto; height: auto;
border-radius: 1rem; 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;
}
}
+25 -6
View File
@@ -6,7 +6,12 @@ import { TopBar } from "@/components/home/TopBar";
import { Header } from "@/components/home/Header"; import { Header } from "@/components/home/Header";
import { Footer } from "@/components/home/Footer"; import { Footer } from "@/components/home/Footer";
import { Providers } from "@/context/Providers"; import { Providers } from "@/context/Providers";
import { getBusinessInfo, getCategories, getCategoryTree } from "@/lib/meshkee"; import {
getBusinessInfo,
getCategories,
getCategoryTree,
parseMetaTagHtml,
} from "@/lib/meshkee";
// Body text — headings use the bold weight instead, see globals.css. // Body text — headings use the bold weight instead, see globals.css.
const iranyekan = localFont({ const iranyekan = localFont({
@@ -27,10 +32,15 @@ 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();
return {
title: "صنیع هوم | فروشگاه لوازم خانگی",
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر", description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
}; icons: businessInfo?.faviconUrl ? [{ url: businessInfo.faviconUrl }] : undefined,
};
}
export default async function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const [categoryTree, categories, businessInfo] = await Promise.all([ const [categoryTree, categories, businessInfo] = await Promise.all([
@@ -39,17 +49,26 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
getBusinessInfo(), getBusinessInfo(),
]); ]);
const cmsMetaTags = (businessInfo?.metaTags ?? [])
.map((tag, index) => {
const attrs = parseMetaTagHtml(tag.html ?? "");
if (!attrs) return null;
return <meta key={`cms-meta-${index}`} {...attrs} />;
})
.filter(Boolean);
return ( return (
<html <html
lang="fa" lang="fa"
dir="rtl" dir="rtl"
className={`${iranyekan.variable} ${iranyekanBold.variable} ${syne.variable} h-full antialiased`} className={`${iranyekan.variable} ${iranyekanBold.variable} ${syne.variable} h-full antialiased`}
> >
{cmsMetaTags.length > 0 ? <head>{cmsMetaTags}</head> : null}
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Providers> <Providers>
<TopBar /> <TopBar />
<Header categoryTree={categoryTree} categories={categories} /> <Header categoryTree={categoryTree} categories={categories} logoUrl={businessInfo?.logoUrl} />
<main>{children}</main> <main className="flex-1">{children}</main>
<Footer businessInfo={businessInfo} categories={categories} /> <Footer businessInfo={businessInfo} categories={categories} />
</Providers> </Providers>
</body> </body>
+16 -30
View File
@@ -1,46 +1,30 @@
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 { BrandGroupCarousels } from "@/components/home/BrandGroupCarousels";
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 { import {
getBlogs, getBlogs,
getBusinessInfo, getBusinessInfo,
getCategories, getCategories,
getProducts,
getBrandGroups,
getStoreSpecials, getStoreSpecials,
getStaticImageSlots, getStaticImageSlots,
firstImageForSlot,
imagesForSlot, imagesForSlot,
STATIC_IMAGE_KEYS, STATIC_IMAGE_KEYS,
} from "@/lib/meshkee"; } from "@/lib/meshkee";
export default async function Home() { export default async function Home() {
const [ const [categories, storeSpecials, staticImageSlots, businessInfo, blogsResult] = await Promise.all([
categories,
storeSpecials,
productsResult,
brandGroups,
staticImageSlots,
businessInfo,
blogsResult,
] = await Promise.all([
getCategories(), getCategories(),
getStoreSpecials(), getStoreSpecials(),
getProducts({ pageSize: 12 }),
getBrandGroups(),
getStaticImageSlots(), getStaticImageSlots(),
getBusinessInfo(), getBusinessInfo(),
getBlogs({ pageSize: 8 }), getBlogs({ pageSize: 8 }),
]); ]);
const contactPhone = businessInfo?.phoneNumbers[0]; const contactPhone = businessInfo?.phoneNumbers[0];
const specialSaleProducts = storeSpecials[0]?.items ?? [];
const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner); const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner);
const threeBannerSlot = staticImageSlots.find( const threeBannerSlot = staticImageSlots.find(
(slot) => slot.key === STATIC_IMAGE_KEYS.threeBannerRow, (slot) => slot.key === STATIC_IMAGE_KEYS.threeBannerRow,
@@ -51,20 +35,22 @@ export default async function Home() {
return ( return (
<> <>
<HeroSlider images={heroImages} /> <HeroSlider images={heroImages} />
<SpecialSaleCarousel products={specialSaleProducts} contactPhone={contactPhone} />
<FeatureBar />
<FeaturedCategories categories={categories} images={featuredCategoryImages} /> <FeaturedCategories categories={categories} images={featuredCategoryImages} />
<TwoBanners <StoreSpecialCarousels
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)} groups={storeSpecials}
aspectRatio={twoBannerSlot?.aspectRatio} contactPhone={contactPhone}
twoBannerImages={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
twoBannerAspectRatio={twoBannerSlot?.aspectRatio}
threeBannerImages={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
threeBannerAspectRatio={threeBannerSlot?.aspectRatio}
/> />
<TopSellingCarousel products={productsResult.items} contactPhone={contactPhone} /> <FeatureBar />
<ThreeBanners <AboutSanihome
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)} image={firstImageForSlot(staticImageSlots, STATIC_IMAGE_KEYS.aboutUsImage)}
aspectRatio={threeBannerSlot?.aspectRatio} aspectRatio={staticImageSlots.find(
(slot) => slot.key === STATIC_IMAGE_KEYS.aboutUsImage,
)?.aspectRatio}
/> />
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
<AboutSanihome />
<BlogCarousel posts={blogsResult.items} /> <BlogCarousel posts={blogsResult.items} />
</> </>
); );
+2 -89
View File
@@ -1,17 +1,4 @@
import { notFound } from "next/navigation"; import { permanentRedirect } from "next/navigation";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { ProductGallery } from "@/components/product/ProductGallery";
import { ProductPurchasePanel } from "@/components/product/ProductPurchasePanel";
import { ProductTabs } from "@/components/product/ProductTabs";
import { SimilarProducts } from "@/components/product/SimilarProducts";
import {
getBusinessInfo,
getComments,
getProduct,
getProductStoreVariants,
getProductTechnicalInfo,
getProductVariations,
} from "@/lib/meshkee";
export default async function ProductDetailPage({ export default async function ProductDetailPage({
params, params,
@@ -19,79 +6,5 @@ export default async function ProductDetailPage({
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params; const { slug } = await params;
const [product, technicalInfo, variations, businessInfo] = await Promise.all([ permanentRedirect(`/store-items/${slug}`);
getProduct(slug),
getProductTechnicalInfo(slug),
getProductVariations(slug),
getBusinessInfo(),
]);
if (!product) notFound();
const [storeVariants, comments] = await Promise.all([
getProductStoreVariants(product.id),
getComments("product", product.id),
]);
const contactPhone = businessInfo?.phoneNumbers[0];
const images = product.images.length > 0 ? product.images : product.image ? [product.image] : [];
return (
<div className="mx-auto max-w-7xl px-4 py-10">
<Breadcrumb
items={[
{ label: "خانه", href: "/" },
...(product.categoryName
? [{ label: product.categoryName, href: `/products?categoryId=${product.categoryId}` }]
: []),
{ label: product.name },
]}
/>
<div className="grid gap-8 md:grid-cols-12">
<div className="md:col-span-4">
<ProductGallery images={images} alt={product.name} />
</div>
<ProductPurchasePanel
header={
<>
{product.brand && (
<p className="font-display mb-2 text-xs tracking-wide text-muted uppercase">
{product.brand}
</p>
)}
<h1 className="text-xl font-bold leading-relaxed md:text-2xl">{product.name}</h1>
</>
}
variations={variations}
storeVariants={storeVariants}
fallbackPrice={{
minPrice: product.minPrice,
maxPrice: product.maxPrice,
oldPrice: product.oldPrice,
discount: product.discount,
}}
inStock={product.inStock}
contactPhone={contactPhone}
storeName={businessInfo?.nameFa}
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}
/>
</div>
);
} }
+1 -1
View File
@@ -11,5 +11,5 @@ export default async function ProductByIdPage({
if (!product?.slug) notFound(); if (!product?.slug) notFound();
redirect(`/products/${product.slug}`); redirect(`/store-items/${product.slug}`);
} }
+9 -119
View File
@@ -1,128 +1,18 @@
import { Breadcrumb } from "@/components/ui/Breadcrumb"; import { permanentRedirect } from "next/navigation";
import { ProductCard } from "@/components/ui/ProductCard";
import { EmptyState } from "@/components/ui/EmptyState";
import {
type CategoryTreeGroup,
getBusinessInfo,
getCategoryTree,
getProducts,
productHref,
} from "@/lib/meshkee";
const PAGE_SIZE = 24;
function findCategoryLabel(tree: CategoryTreeGroup[], id: string): string | undefined {
for (const group of tree) {
for (const item of group.items) {
if (item.id === id) return item.nameFa;
const child = item.children.find((c) => c.id === id);
if (child) return child.nameFa;
}
}
return undefined;
}
export default async function ProductsPage({ export default async function ProductsPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ categoryId?: string; q?: string; page?: string }>; searchParams: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
const params = await searchParams; const params = await searchParams;
const page = Math.max(1, Number(params.page ?? 1) || 1); const query = new URLSearchParams();
const [{ items, total }, businessInfo, categoryTree] = await Promise.all([
getProducts({
categoryId: params.categoryId,
name: params.q,
page,
pageSize: PAGE_SIZE,
}),
getBusinessInfo(),
params.categoryId ? getCategoryTree() : Promise.resolve([]),
]);
const contactPhone = businessInfo?.phoneNumbers[0];
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const isFiltered = Boolean(params.q || params.categoryId);
const categoryLabel = params.categoryId ? findCategoryLabel(categoryTree, params.categoryId) : undefined;
const pageHref = (p: number) => { for (const [key, value] of Object.entries(params)) {
const sp = new URLSearchParams(); if (Array.isArray(value)) value.forEach((item) => query.append(key, item));
if (params.categoryId) sp.set("categoryId", params.categoryId); else if (value != null) query.set(key, value);
if (params.q) sp.set("q", params.q); }
sp.set("page", String(p));
return `/products?${sp.toString()}`;
};
return ( const suffix = query.toString();
<div className="mx-auto max-w-7xl px-4 py-10"> permanentRedirect(`/store-items${suffix ? `?${suffix}` : ""}`);
<Breadcrumb
items={[
{ label: "خانه", href: "/" },
{ label: "محصولات", href: isFiltered ? "/products" : undefined },
...(params.q ? [{ label: `نتایج جستجو برای «${params.q}»` }] : []),
...(categoryLabel ? [{ label: categoryLabel }] : []),
]}
/>
<h1 className="mb-6 text-2xl font-bold text-red-dark">
{params.q ? `نتایج جستجو برای «${params.q}»` : "محصولات"}
</h1>
{items.length === 0 ? (
<EmptyState message="محصولی یافت نشد." />
) : (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{items.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
href={productHref(product)}
fluid
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-3">
{page > 1 ? (
<a
href={pageHref(page - 1)}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
>
قبلی
</a>
) : (
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
قبلی
</span>
)}
<span className="text-sm text-muted">
صفحه {page} از {totalPages}
</span>
{page < totalPages ? (
<a
href={pageHref(page + 1)}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
>
بعدی
</a>
) : (
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
بعدی
</span>
)}
</div>
)}
</>
)}
</div>
);
} }
+11 -11
View File
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Breadcrumb } from "@/components/ui/Breadcrumb"; import { PageShell } from "@/components/ui/PageShell";
import { ProductGallery } from "@/components/product/ProductGallery"; import { ProductGallery } from "@/components/product/ProductGallery";
import { ProductPurchasePanel } from "@/components/product/ProductPurchasePanel"; import { ProductPurchasePanel } from "@/components/product/ProductPurchasePanel";
import { ProductTabs } from "@/components/product/ProductTabs"; import { ProductTabs } from "@/components/product/ProductTabs";
@@ -7,6 +7,7 @@ import { SimilarProducts } from "@/components/product/SimilarProducts";
import { import {
getBusinessInfo, getBusinessInfo,
getComments, getComments,
getProduct,
getProductById, getProductById,
getProductStoreVariants, getProductStoreVariants,
getProductTechnicalInfo, getProductTechnicalInfo,
@@ -21,9 +22,9 @@ export default async function StoreItemDetailPage({
}) { }) {
const { id } = await params; const { id } = await params;
const variant = await getStoreItemVariant(id); const variant = await getStoreItemVariant(id);
if (!variant) notFound(); const product = variant
? await getProductById(variant.productId)
const product = await getProductById(variant.productId); : (await getProduct(id)) ?? (await getProductById(id));
if (!product) notFound(); if (!product) notFound();
const [technicalInfo, variations, businessInfo, comments] = await Promise.all([ const [technicalInfo, variations, businessInfo, comments] = await Promise.all([
@@ -39,21 +40,20 @@ export default async function StoreItemDetailPage({
// Land on the exact variant the shopper opened, not the first option of // Land on the exact variant the shopper opened, not the first option of
// every variation. // every variation.
const initialSelections = Object.fromEntries( const initialSelections = Object.fromEntries(
variant.selections.map((s) => [s.variationId, s.optionId]), (variant?.selections ?? []).map((s) => [s.variationId, s.optionId]),
); );
return ( return (
<div className="mx-auto max-w-7xl px-4 py-10"> <PageShell
<Breadcrumb breadcrumbItems={[
items={[
{ label: "خانه", href: "/" }, { label: "خانه", href: "/" },
{ label: "فروشگاه", href: "/store-items" }, { label: "فروشگاه", href: "/store-items" },
...(product.categoryName ...(product.categoryName
? [{ label: product.categoryName, href: `/products?categoryId=${product.categoryId}` }] ? [{ label: product.categoryName, href: `/store-items?categoryId=${product.categoryId}` }]
: []), : []),
{ label: product.name }, { label: product.name },
]} ]}
/> >
<div className="grid gap-8 md:grid-cols-12"> <div className="grid gap-8 md:grid-cols-12">
<div className="md:col-span-4"> <div className="md:col-span-4">
@@ -101,6 +101,6 @@ export default async function StoreItemDetailPage({
excludeProductId={product.id} excludeProductId={product.id}
contactPhone={contactPhone} contactPhone={contactPhone}
/> />
</div> </PageShell>
); );
} }
+129 -18
View File
@@ -1,33 +1,110 @@
import { Breadcrumb } from "@/components/ui/Breadcrumb"; import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { ProductCard } from "@/components/ui/ProductCard"; import { ProductCard } from "@/components/ui/ProductCard";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { getBusinessInfo, getStoreItems, storeItemHref } from "@/lib/meshkee"; import {
ProductFilters,
type ProductFilterCategory,
} from "@/components/product/ProductFilters";
import {
getCategoryTree,
getStoreItems,
getStoreSpecials,
isSpecialSaleGroup,
productHref,
storeItemHref,
} from "@/lib/meshkee";
const PAGE_SIZE = 24; const PAGE_SIZE = 24;
export default async function StoreItemsPage({ export default async function StoreItemsPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ categoryId?: string; q?: string; page?: string }>; searchParams: Promise<{
categoryId?: string;
q?: string;
page?: string;
minPrice?: string;
maxPrice?: string;
inStock?: string;
specialId?: string;
special?: string;
}>;
}) { }) {
const params = await searchParams; const params = await searchParams;
const page = Math.max(1, Number(params.page ?? 1) || 1); const page = Math.max(1, Number(params.page ?? 1) || 1);
const [{ items, total }, businessInfo] = await Promise.all([ const parsePrice = (value?: string) => {
getStoreItems({ const number = Number(value);
categoryId: params.categoryId, 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, name: params.q,
minPrice,
maxPrice,
inStock: inStock || undefined,
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}), });
getBusinessInfo(), const totalPages = Math.max(1, Math.ceil(storeResult.total / PAGE_SIZE));
]);
const contactPhone = businessInfo?.phoneNumbers[0];
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const pageHref = (p: number) => { const pageHref = (p: number) => {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (params.categoryId) sp.set("categoryId", params.categoryId); if (categoryId) sp.set("categoryId", categoryId);
if (params.q) sp.set("q", params.q); 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)); sp.set("page", String(p));
return `/store-items?${sp.toString()}`; return `/store-items?${sp.toString()}`;
}; };
@@ -41,16 +118,49 @@ export default async function StoreItemsPage({
]} ]}
/> />
{params.q && (
<h1 className="mb-6 text-2xl font-bold text-red-dark"> <h1 className="mb-6 text-2xl font-bold text-red-dark">
{params.q ? `نتایج جستجو برای «${params.q}»` : "فروشگاه"} نتایج جستجو برای «{params.q}»
</h1> </h1>
)}
{items.length === 0 ? ( <div className="md:flex md:items-start md:gap-6">
<EmptyState message="کالایی یافت نشد." /> <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 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">
{items.map((item) => ( {storeResult.items.map((item) => (
<ProductCard <ProductCard
key={item.id} key={item.id}
name={item.name} name={item.name}
@@ -61,13 +171,12 @@ export default async function StoreItemsPage({
image={item.image ?? "/images/placeholder.svg"} image={item.image ?? "/images/placeholder.svg"}
href={storeItemHref(item)} href={storeItemHref(item)}
fluid fluid
contactPhone={contactPhone}
variantId={item.id} variantId={item.id}
/> />
))} ))}
</div> </div>
{totalPages > 1 && ( {!specialMode && totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-3"> <div className="mt-8 flex items-center justify-center gap-3">
{page > 1 ? ( {page > 1 ? (
<a <a
@@ -101,5 +210,7 @@ export default async function StoreItemsPage({
</> </>
)} )}
</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>
);
}
+5 -4
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCart } from "@/context/CartContext"; import { useCart } from "@/context/CartContext";
import { CartIcon } from "@/components/ui/icons";
export function CartButton() { export function CartButton() {
const { itemCount, openPopup } = useCart(); const { itemCount, openPopup } = useCart();
@@ -9,12 +10,12 @@ export function CartButton() {
<button <button
type="button" type="button"
onClick={openPopup} onClick={openPopup}
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" 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"
> >
<span aria-hidden>🛒</span> <CartIcon className="h-5 w-5" />
<span className="hidden sm:inline">سبد خرید</span>
{itemCount > 0 && ( {itemCount > 0 && (
<span className="absolute -top-1.5 -left-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red px-1 text-[10px] font-bold text-white"> <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} {itemCount}
</span> </span>
)} )}
+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;
}
+5 -2
View File
@@ -2,6 +2,7 @@ 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 { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { ChevronLeftIcon } from "@/components/ui/icons";
import type { BlogSummary } from "@/lib/meshkee"; import type { BlogSummary } from "@/lib/meshkee";
function formatDate(iso?: string) { function formatDate(iso?: string) {
@@ -17,6 +18,7 @@ export function BlogCarousel({ posts }: { posts: BlogSummary[] }) {
title="آخرین مطالب وبلاگ" title="آخرین مطالب وبلاگ"
enTitle="Latest Blog" enTitle="Latest Blog"
action="وبلاگ" action="وبلاگ"
actionHref="/blog"
/> />
{posts.length === 0 ? ( {posts.length === 0 ? (
<EmptyState message="هنوز مطلبی ثبت نشده است." /> <EmptyState message="هنوز مطلبی ثبت نشده است." />
@@ -49,8 +51,9 @@ export function BlogCarousel({ posts }: { posts: BlogSummary[] }) {
{post.abstract} {post.abstract}
</p> </p>
)} )}
<span className="mt-3 block text-xs font-medium text-red"> <span className="mt-3 flex items-center gap-1 text-xs font-medium text-red">
ادامه مطلب ← ادامه مطلب
<ChevronLeftIcon className="h-3.5 w-3.5" />
</span> </span>
</div> </div>
</a> </a>
@@ -1,54 +0,0 @@
import { Carousel } from "@/components/ui/Carousel";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { EmptyState } from "@/components/ui/EmptyState";
import { productHref, type ProductGroup } from "@/lib/meshkee";
export function BrandGroupCarousels({
brandGroups,
contactPhone,
}: {
brandGroups: ProductGroup[];
contactPhone?: string;
}) {
if (brandGroups.length === 0) {
return (
<section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle title="محصولات برندها" enTitle="Brand Collections" />
<EmptyState message="هنوز محصولی برای برندها ثبت نشده است." />
</div>
</section>
);
}
return (
<>
{brandGroups.map((group) => (
<section key={group.id} className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle title={group.title} enTitle={group.title} action="مشاهده همه" />
<Carousel ariaLabel={`کاروسل ${group.title}`}>
{group.items.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
variant="brand"
href={productHref(product)}
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</Carousel>
</div>
</section>
))}
</>
);
}
+1 -7
View File
@@ -4,7 +4,6 @@ import { Swiper, SwiperSlide } from "swiper/react";
import { FreeMode } from "swiper/modules"; import { FreeMode } from "swiper/modules";
import "swiper/css"; import "swiper/css";
import "swiper/css/free-mode"; import "swiper/css/free-mode";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import type { Category, StaticImageWithUrl } from "@/lib/meshkee"; import type { Category, StaticImageWithUrl } from "@/lib/meshkee";
@@ -34,13 +33,8 @@ export function FeaturedCategories({
images?: StaticImageWithUrl[]; images?: StaticImageWithUrl[];
}) { }) {
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
title="دسته‌بندی‌های پرطرفدار"
enTitle="Featured Categories"
action="همه دسته‌ها"
/>
{images.length > 0 ? ( {images.length > 0 ? (
<Swiper <Swiper
modules={[FreeMode]} modules={[FreeMode]}
+62 -19
View File
@@ -27,29 +27,35 @@ export function Footer({
); );
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
src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
alt={businessInfo.nameFa?.replace(/سانی[‌ ]?هوم/g, "صنیع هوم") || "صنیع هوم"}
className="h-[72px] w-auto object-contain"
/>
) : (
<span className="font-display text-lg font-bold tracking-tight"> <span className="font-display text-lg font-bold tracking-tight">
SANIHOME SANIHOME
</span> </span>
)}
</div> </div>
{businessInfo?.about && ( {businessInfo?.about && (
<p className="text-sm leading-relaxed text-white/70">{businessInfo.about}</p> <p className="text-sm leading-relaxed text-black/70">{businessInfo.about}</p>
)} )}
</div> </div>
<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">
{quickLinks.map((link) => ( {quickLinks.map((link) => (
<li key={link}> <li key={link}>
<a href="#" className="transition-colors hover:text-white"> <a href="#" className="transition-colors hover:text-black">
{link} {link}
</a> </a>
</li> </li>
@@ -60,12 +66,12 @@ export function Footer({
<div> <div>
<h4 className="mb-3 text-sm font-bold">دسته‌بندی‌ها</h4> <h4 className="mb-3 text-sm font-bold">دسته‌بندی‌ها</h4>
{categories.length === 0 ? ( {categories.length === 0 ? (
<p className="text-sm text-white/50">هنوز دسته‌بندی‌ای ثبت نشده است.</p> <p className="text-sm text-black/50">هنوز دسته‌بندی‌ای ثبت نشده است.</p>
) : ( ) : (
<ul className="space-y-2 text-sm text-white/70"> <ul className="space-y-2 text-sm text-black/70">
{categories.slice(0, 5).map((cat) => ( {categories.slice(0, 5).map((cat) => (
<li key={cat.id}> <li key={cat.id}>
<a href="#" className="transition-colors hover:text-white"> <a href="#" className="transition-colors hover:text-black">
{cat.name} {cat.name}
</a> </a>
</li> </li>
@@ -77,7 +83,7 @@ export function Footer({
<div> <div>
<h4 className="mb-3 text-sm font-bold">تماس با ما</h4> <h4 className="mb-3 text-sm font-bold">تماس با ما</h4>
{hasContactInfo ? ( {hasContactInfo ? (
<ul className="space-y-2 text-sm text-white/70"> <ul className="space-y-2 text-sm text-black/70">
{businessInfo?.addresses.map((address) => <li key={address}>{address}</li>)} {businessInfo?.addresses.map((address) => <li key={address}>{address}</li>)}
{businessInfo?.phoneNumbers.map((phone) => ( {businessInfo?.phoneNumbers.map((phone) => (
<li key={phone}>تلفن: {phone}</li> <li key={phone}>تلفن: {phone}</li>
@@ -85,7 +91,7 @@ export function Footer({
{businessInfo?.emails.map((email) => <li key={email}>ایمیل: {email}</li>)} {businessInfo?.emails.map((email) => <li key={email}>ایمیل: {email}</li>)}
</ul> </ul>
) : ( ) : (
<p className="text-sm text-white/50">هنوز اطلاعات تماس ثبت نشده است.</p> <p className="text-sm text-black/50">هنوز اطلاعات تماس ثبت نشده است.</p>
)} )}
{socialEntries.length > 0 && ( {socialEntries.length > 0 && (
<div className="mt-4 flex gap-3"> <div className="mt-4 flex gap-3">
@@ -95,7 +101,7 @@ export function Footer({
href={url} href={url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="font-display flex h-8 w-8 items-center justify-center rounded-full border border-white/20 text-[10px] font-bold transition-colors hover:border-white hover:bg-white/10" className="font-display flex h-8 w-8 items-center justify-center rounded-full border border-black/20 text-[10px] font-bold transition-colors hover:border-black hover:bg-black/5"
> >
{socialLabels[key] ?? key.slice(0, 2).toUpperCase()} {socialLabels[key] ?? key.slice(0, 2).toUpperCase()}
</a> </a>
@@ -105,11 +111,48 @@ export function Footer({
</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>
+47 -27
View File
@@ -5,11 +5,12 @@ import { createPortal } from "react-dom";
import type { Category, CategoryNode, CategoryTreeGroup } from "@/lib/meshkee"; import type { Category, CategoryNode, CategoryTreeGroup } from "@/lib/meshkee";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { CartButton } from "@/components/cart/CartButton"; import { CartButton } from "@/components/cart/CartButton";
import { ChevronLeftIcon, CloseIcon, MenuIcon, UserIcon } from "@/components/ui/icons";
import { SearchBar } from "./SearchBar"; import { SearchBar } from "./SearchBar";
const navLinks = [ const navLinks = [
{ label: "فروشگاه", href: "/store-items" }, { label: "فروشگاه", href: "/store-items" },
{ label: "فروش ویژه", href: "#" }, { label: "فروش ویژه", href: "/store-items?special=sale" },
{ label: "درباره ما", href: "#" }, { label: "درباره ما", href: "#" },
{ label: "سوالات متداول", href: "#" }, { label: "سوالات متداول", href: "#" },
{ label: "تماس با ما", href: "/contact" }, { label: "تماس با ما", href: "/contact" },
@@ -17,17 +18,17 @@ const navLinks = [
const LOGIN_URL = "https://customer.sanihome.ir"; const LOGIN_URL = "https://customer.sanihome.ir";
function Logo({ large = false }: { large?: boolean }) { function Logo({ src, large = false }: { src?: string | null; large?: boolean }) {
return ( return (
<a href="/" className="flex shrink-0 items-center"> <a href="/" className="flex shrink-0 items-center">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src="/images/logo.png" src={src || "/images/logo.png"}
alt="سانی‌هوم" alt="صنیع هوم"
className={ className={
large large
? "h-[42px] w-auto object-contain md:h-[46px]" ? "h-[65px] w-auto object-contain"
: "h-8 w-auto md:h-9" : "h-[65px] w-auto object-contain"
} }
/> />
</a> </a>
@@ -56,10 +57,12 @@ function MobileDrawer({
open, open,
onClose, onClose,
categoryTree, categoryTree,
logoUrl,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
categoryTree: CategoryTreeGroup[]; categoryTree: CategoryTreeGroup[];
logoUrl?: string | null;
}) { }) {
const [categoriesOpen, setCategoriesOpen] = useState(false); const [categoriesOpen, setCategoriesOpen] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -100,14 +103,14 @@ function MobileDrawer({
}`} }`}
> >
<div className="flex items-center justify-between border-b border-border p-5"> <div className="flex items-center justify-between border-b border-border p-5">
<Logo /> <Logo src={logoUrl} />
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="بستن منو" aria-label="بستن منو"
className="flex h-9 w-9 items-center justify-center rounded-lg text-xl text-muted transition-colors hover:bg-red-light hover:text-red" className="flex h-9 w-9 items-center justify-center rounded-lg text-muted transition-colors hover:bg-red-light hover:text-red"
> >
✕ <CloseIcon className="h-5 w-5" />
</button> </button>
</div> </div>
@@ -145,7 +148,7 @@ function MobileDrawer({
{level2.map((node) => ( {level2.map((node) => (
<li key={node.id}> <li key={node.id}>
<a <a
href={`/products?categoryId=${node.id}`} href={`/store-items?categoryId=${node.id}`}
className="block rounded-lg px-3 py-1.5 font-medium text-foreground transition-colors hover:bg-red-light hover:text-red" className="block rounded-lg px-3 py-1.5 font-medium text-foreground transition-colors hover:bg-red-light hover:text-red"
> >
{node.nameFa} {node.nameFa}
@@ -155,7 +158,7 @@ function MobileDrawer({
{node.children.map((child) => ( {node.children.map((child) => (
<li key={child.id}> <li key={child.id}>
<a <a
href={`/products?categoryId=${child.id}`} href={`/store-items?categoryId=${child.id}`}
className="block rounded-lg px-3 py-1 text-muted transition-colors hover:bg-red-light hover:text-red" className="block rounded-lg px-3 py-1 text-muted transition-colors hover:bg-red-light hover:text-red"
> >
{child.nameFa} {child.nameFa}
@@ -186,7 +189,7 @@ function MobileDrawer({
href={LOGIN_URL} 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" className="flex items-center gap-2 px-5 py-4 text-right font-medium transition-colors hover:bg-red-light/40 hover:text-red"
> >
<span aria-hidden>👤</span> <UserIcon className="h-4 w-4" />
ورود ورود
</a> </a>
</nav> </nav>
@@ -199,9 +202,11 @@ function MobileDrawer({
export function Header({ export function Header({
categoryTree, categoryTree,
categories, categories,
logoUrl,
}: { }: {
categoryTree: CategoryTreeGroup[]; categoryTree: CategoryTreeGroup[];
categories: Category[]; categories: Category[];
logoUrl?: string | null;
}) { }) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@@ -221,24 +226,24 @@ 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="grid grid-cols-3 items-center gap-4 py-4 min-[1200px]:flex min-[1200px]:justify-between"> <div className="grid grid-cols-3 items-center gap-4 py-2 min-[1200px]:flex min-[1200px]:justify-between">
<div className="flex items-center justify-start"> <div className="flex items-center justify-start">
<button <button
type="button" type="button"
onClick={() => setDrawerOpen(true)} onClick={() => setDrawerOpen(true)}
aria-label="منو" aria-label="منو"
className="flex h-10 w-10 items-center justify-center rounded-lg border border-border text-xl transition-colors hover:border-red hover:text-red min-[1200px]:hidden" className="flex h-10 w-10 items-center justify-center rounded-lg border border-border transition-colors hover:border-red hover:text-red min-[1200px]:hidden"
> >
<span aria-hidden>☰</span> <MenuIcon className="h-5 w-5" />
</button> </button>
<div className="hidden min-[1200px]:block"> <div className="hidden min-[1200px]:block">
<Logo /> <Logo src={logoUrl} />
</div> </div>
</div> </div>
<div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6"> <div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6">
<div className="min-[1200px]:hidden"> <div className="min-[1200px]:hidden">
<Logo large /> <Logo src={logoUrl} large />
</div> </div>
<div className="hidden w-full min-[1200px]:block"> <div className="hidden w-full min-[1200px]:block">
<SearchBar /> <SearchBar />
@@ -246,16 +251,29 @@ export function Header({
</div> </div>
<div className="flex items-center justify-end gap-3 text-sm"> <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 <a
href={LOGIN_URL} href={LOGIN_URL}
className="hidden items-center gap-1.5 text-muted transition-colors hover:text-red sm:flex" className="flex items-center gap-2 rounded-xl py-1 pr-1 transition-opacity hover:opacity-80"
> >
<span>👤</span> <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red text-white">
<span>ورود</span> <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> </a>
</div>
<div className="sm:hidden">
<CartButton /> <CartButton />
</div> </div>
</div> </div>
</div>
<div className="border-t border-border py-3 min-[1200px]:hidden"> <div className="border-t border-border py-3 min-[1200px]:hidden">
<SearchBar /> <SearchBar />
@@ -275,7 +293,7 @@ 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>
@@ -289,10 +307,11 @@ export function Header({
</p> </p>
</div> </div>
<a <a
href="/products" href="/store-items"
className="text-xs font-medium text-red hover:text-red-dark" 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>
@@ -303,7 +322,7 @@ export function Header({
{level2.map((node) => ( {level2.map((node) => (
<div key={node.id} className="mb-4 break-inside-avoid"> <div key={node.id} className="mb-4 break-inside-avoid">
<a <a
href={`/products?categoryId=${node.id}`} href={`/store-items?categoryId=${node.id}`}
className="text-sm font-bold text-red-dark transition-colors hover:text-red" className="text-sm font-bold text-red-dark transition-colors hover:text-red"
> >
{node.nameFa} {node.nameFa}
@@ -313,7 +332,7 @@ export function Header({
{node.children.map((child) => ( {node.children.map((child) => (
<li key={child.id}> <li key={child.id}>
<a <a
href={`/products?categoryId=${child.id}`} href={`/store-items?categoryId=${child.id}`}
className="text-sm text-muted transition-colors hover:text-red" className="text-sm text-muted transition-colors hover:text-red"
> >
{child.nameFa} {child.nameFa}
@@ -332,7 +351,7 @@ export function Header({
{categories.slice(0, 4).map((cat) => ( {categories.slice(0, 4).map((cat) => (
<a <a
key={cat.id} key={cat.id}
href={`/products?categoryId=${cat.id}`} href={`/store-items?categoryId=${cat.id}`}
className="rounded-lg bg-red-light/50 px-3 py-2 text-center text-xs font-medium text-red-dark transition-colors hover:bg-red-light" 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} {cat.name}
@@ -360,6 +379,7 @@ export function Header({
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
categoryTree={categoryTree} categoryTree={categoryTree}
logoUrl={logoUrl}
/> />
</header> </header>
); );
+12 -5
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Placeholder } from "@/components/ui/Placeholder"; import { Placeholder } from "@/components/ui/Placeholder";
import { formatPrice } from "@/data/sample"; import { formatPrice } from "@/data/sample";
import type { Product } from "@/lib/meshkee"; import type { Product } from "@/lib/meshkee";
export function SearchBar() { export function SearchBar() {
const router = useRouter();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<Product[]>([]); const [results, setResults] = useState<Product[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -15,8 +17,6 @@ export function SearchBar() {
useEffect(() => { useEffect(() => {
const trimmed = query.trim(); const trimmed = query.trim();
if (trimmed.length <= 2) { if (trimmed.length <= 2) {
setResults([]);
setOpen(false);
return; return;
} }
@@ -56,7 +56,7 @@ export function SearchBar() {
const submit = () => { const submit = () => {
const trimmed = query.trim(); const trimmed = query.trim();
if (trimmed) window.location.href = `/products?q=${encodeURIComponent(trimmed)}`; if (trimmed) router.push(`/store-items?q=${encodeURIComponent(trimmed)}`);
}; };
return ( return (
@@ -64,7 +64,14 @@ export function SearchBar() {
<input <input
type="search" type="search"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => {
const value = event.target.value;
setQuery(value);
if (value.trim().length <= 2) {
setResults([]);
setOpen(false);
}
}}
onFocus={() => results.length > 0 && setOpen(true)} onFocus={() => results.length > 0 && setOpen(true)}
onKeyDown={(event) => event.key === "Enter" && submit()} onKeyDown={(event) => event.key === "Enter" && submit()}
placeholder="جستجوی محصول، برند یا دسته‌بندی..." placeholder="جستجوی محصول، برند یا دسته‌بندی..."
@@ -89,7 +96,7 @@ export function SearchBar() {
{results.map((product) => ( {results.map((product) => (
<li key={product.id}> <li key={product.id}>
<a <a
href={`/products/${product.slug}`} href={`/store-items/${product.slug}`}
className="flex items-center gap-3 p-3 transition-colors hover:bg-red-light/40" className="flex items-center gap-3 p-3 transition-colors hover:bg-red-light/40"
> >
<Placeholder <Placeholder
@@ -1,65 +0,0 @@
import { Carousel } from "@/components/ui/Carousel";
import { Placeholder } from "@/components/ui/Placeholder";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { EmptyState } from "@/components/ui/EmptyState";
import { productHref, type Product } from "@/lib/meshkee";
function SpecialSaleBanner() {
return (
<a
href="#"
className="relative w-[220px] shrink-0 self-stretch overflow-hidden rounded-xl border border-border md:w-[240px]"
>
<Placeholder
src="/images/sale-banner.svg"
alt="فروش ویژه"
width="100%"
className="h-full min-h-[380px] w-full rounded-none border-0"
/>
</a>
);
}
export function SpecialSaleCarousel({
products,
contactPhone,
}: {
products: Product[];
contactPhone?: string;
}) {
return (
<section className="border-y border-red-light bg-red-light/30 py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle
title="فروش ویژه"
enTitle="Special Sale"
action="مشاهده همه"
/>
{products.length === 0 ? (
<EmptyState message="هنوز محصولی برای فروش ویژه ثبت نشده است." />
) : (
<Carousel ariaLabel="کاروسل فروش ویژه">
<SpecialSaleBanner />
{products.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
variant="sale"
href={productHref(product)}
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</Carousel>
)}
</div>
</section>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useState } from "react";
type RemainingTime = {
hours: number;
minutes: number;
seconds: number;
};
function getRemainingTime(): RemainingTime {
const now = new Date();
const endOfDay = new Date(now);
endOfDay.setHours(23, 59, 59, 999);
const totalSeconds = Math.max(0, Math.floor((endOfDay.getTime() - now.getTime()) / 1000));
return {
hours: Math.floor(totalSeconds / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
};
}
function TimeBox({ value, label }: { value: number; label: string }) {
return (
<div className="flex min-w-0 flex-1 flex-col items-center rounded-xl bg-white/15 px-1 py-2.5 md:py-3">
<strong className="text-lg leading-none tabular-nums md:text-2xl" suppressHydrationWarning>
{String(value).padStart(2, "0")}
</strong>
<span className="mt-2 text-[10px] text-white/80 md:text-xs">{label}</span>
</div>
);
}
export function SpecialSaleCountdown() {
const [remaining, setRemaining] = useState(getRemainingTime);
useEffect(() => {
const timer = window.setInterval(() => setRemaining(getRemainingTime()), 1000);
return () => window.clearInterval(timer);
}, []);
return (
<div className="grid w-full grid-cols-3 gap-1.5 md:gap-2" dir="ltr">
<TimeBox value={remaining.hours} label="ساعت" />
<TimeBox value={remaining.minutes} label="دقیقه" />
<TimeBox value={remaining.seconds} label="ثانیه" />
</div>
);
}
@@ -0,0 +1,157 @@
import { Fragment } from "react";
import { Carousel } from "@/components/ui/Carousel";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { ChevronLeftIcon } from "@/components/ui/icons";
import { TwoBanners } from "@/components/home/TwoBanners";
import { ThreeBanners } from "@/components/home/ThreeBanners";
import { SpecialSaleCountdown } from "@/components/home/SpecialSaleCountdown";
import {
isSpecialSaleGroup,
productHref,
type ProductGroup,
type StaticImageWithUrl,
} from "@/lib/meshkee";
// The API only gives one `title` string per group (sometimes already
// English, e.g. "Special Sale", sometimes Persian) — there's no separate
// localized English field to pull a real translation from, and inventing
// one would be fabricated data. `key` is real data and usually a readable
// English slug (e.g. "top-selling"), so it's what gets formatted into the
// English caption beside the title (same inline treatment as every other
// section heading on the site, e.g. BlogCarousel's "LATEST BLOG"). Skip it
// if the key has no letters at all (e.g. it fell back to a bare numeric id).
function englishLabelFromKey(key: string): string | undefined {
const label = key.replace(/[-_]+/g, " ").trim();
return /[a-zA-Z]/.test(label) ? label.toUpperCase() : undefined;
}
function SpecialSaleSection({ group }: { group: ProductGroup }) {
return (
<section className="bg-red py-6 md:py-8">
<div className="mx-auto flex max-w-7xl items-stretch gap-3 px-4 md:gap-5">
<aside className="my-2 flex w-[calc(50%-1rem)] shrink-0 flex-col items-center justify-center rounded-2xl bg-red-dark/35 p-3 text-center text-white md:w-[240px] md:p-6">
<svg
viewBox="0 0 32 32"
className="mb-3 h-11 w-11 md:h-14 md:w-14"
aria-hidden="true"
>
<g transform="matrix(1.05 0 0 1.05 -.8 -.8006)">
<path
fill="currentColor"
d="m30.25 14.182-.876-.876c-.36-.36-.577-1.168-.445-1.661l.32-1.197a2.54 2.54 0 0 0-.259-1.949 2.55 2.55 0 0 0-1.559-1.198l-1.196-.321c-.492-.132-1.084-.724-1.217-1.215l-.32-1.197C24.34 3.232 22.899 2.39 21.55 2.75l-1.196.321c-.459.122-1.316-.101-1.66-.445l-.877-.876c-.969-.969-2.666-.969-3.635 0l-.877.877c-.344.344-1.201.567-1.661.445l-1.195-.321c-1.349-.361-2.79.481-3.148 1.818l-.32 1.195c-.132.494-.724 1.085-1.216 1.217l-1.197.321c-.662.177-1.216.603-1.56 1.199s-.435 1.288-.257 1.947l.319 1.198c.132.493-.084 1.3-.444 1.661l-.876.876C1.267 14.667 1 15.313 1 16s.267 1.333.751 1.817l.876.876c.36.36.577 1.168.445 1.661l-.32 1.197a2.54 2.54 0 0 0 .259 1.949 2.55 2.55 0 0 0 1.559 1.198l1.195.32c.493.132 1.085.724 1.218 1.215l.319 1.197c.358 1.336 1.804 2.175 3.147 1.817l1.197-.32c.46-.122 1.317.101 1.661.445l.876.875c.484.486 1.13.753 1.818.753s1.334-.267 1.817-.75l.877-.876c.345-.344 1.205-.565 1.661-.445l1.195.32q.326.087.662.087h.001a2.575 2.575 0 0 0 2.485-1.905l.319-1.194c.133-.494.725-1.085 1.217-1.218l1.196-.32c.662-.177 1.216-.604 1.56-1.2s.435-1.288.257-1.947l-.319-1.198c-.132-.493.084-1.3.444-1.661l.876-.876c.484-.484.751-1.13.751-1.817a2.55 2.55 0 0 0-.75-1.818M9 12c0-1.654 1.346-3 3-3s3 1.346 3 3-1.346 3-3 3-3-1.346-3-3m3.707 8.707a.997.997 0 0 1-1.414 0 1 1 0 0 1 0-1.414l8-8a.999.999 0 1 1 1.414 1.414zM20 23c-1.654 0-3-1.346-3-3s1.346-3 3-3 3 1.346 3 3-1.346 3-3 3"
/>
<circle cx="20" cy="20" r="1" fill="currentColor" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</g>
</svg>
<h2 className="text-lg font-bold md:text-2xl">{group.title}</h2>
<p className="mt-1.5 mb-4 text-xs text-white/80 md:text-sm">تا پایان فروش ویژه</p>
<SpecialSaleCountdown />
<a
href={`/store-items?specialId=${group.id}`}
className="mt-4 flex shrink-0 items-center gap-1.5 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black transition-colors hover:bg-white/90"
>
مشاهده همه
<ChevronLeftIcon className="h-4 w-4" />
</a>
</aside>
<div className="min-w-0 flex-1">
<Carousel
ariaLabel={`کاروسل ${group.title}`}
slideClassName="!w-full md:!w-[240px]"
>
{group.items.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
href={productHref(product)}
variant="sale"
fluid
/>
))}
</Carousel>
</div>
</div>
</section>
);
}
// Renders every admin-managed carousel from GET /store-specials — whatever
// groups exist there, in their configured order. Nothing here is keyed off
// a specific group id/key/title, so a new carousel created in the Meshkee
// dashboard (any name) shows up on the homepage automatically, with no code
// change. Groups with no items are skipped (honest empty state — no heading
// over nothing).
//
// One of the two banner sections is inserted between every pair of
// carousels (never before the first or after the last), cycling if there
// end up being more than two carousels.
export function StoreSpecialCarousels({
groups,
contactPhone,
twoBannerImages,
twoBannerAspectRatio,
threeBannerImages,
threeBannerAspectRatio,
}: {
groups: ProductGroup[];
contactPhone?: string;
twoBannerImages?: StaticImageWithUrl[];
twoBannerAspectRatio?: string;
threeBannerImages?: StaticImageWithUrl[];
threeBannerAspectRatio?: string;
}) {
const visibleGroups = groups.filter((group) => group.items.length > 0);
const banners = [
<TwoBanners key="two" images={twoBannerImages} aspectRatio={twoBannerAspectRatio} />,
<ThreeBanners key="three" images={threeBannerImages} aspectRatio={threeBannerAspectRatio} />,
];
return (
<>
{visibleGroups.map((group, i) => (
<Fragment key={group.id}>
{i > 0 && banners[(i - 1) % banners.length]}
{isSpecialSaleGroup(group) ? (
<SpecialSaleSection group={group} />
) : (
<section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle
title={group.title}
enTitle={englishLabelFromKey(group.key)}
action="مشاهده همه"
actionHref={`/store-items?specialId=${group.id}`}
/>
<Carousel ariaLabel={`کاروسل ${group.title}`}>
{group.items.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
href={productHref(product)}
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</Carousel>
</div>
</section>
)}
</Fragment>
))}
</>
);
}
@@ -1,46 +0,0 @@
import { Carousel } from "@/components/ui/Carousel";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { EmptyState } from "@/components/ui/EmptyState";
import { productHref, type Product } from "@/lib/meshkee";
export function TopSellingCarousel({
products,
contactPhone,
}: {
products: Product[];
contactPhone?: string;
}) {
return (
<section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle
title="پرفروش‌ترین‌ها"
enTitle="Top Selling"
action="مشاهده همه"
/>
{products.length === 0 ? (
<EmptyState message="هنوز محصولی ثبت نشده است." />
) : (
<Carousel ariaLabel="کاروسل پرفروش‌ترین‌ها">
{products.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
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>
);
}
+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>
)}
</>
);
}
+4 -9
View File
@@ -33,11 +33,8 @@ function Lightbox({
onIndexChange: (i: number) => void; onIndexChange: (i: number) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [mounted, setMounted] = useState(false);
const hasMultiple = images.length > 1; const hasMultiple = images.length > 1;
useEffect(() => setMounted(true), []);
useEffect(() => { useEffect(() => {
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -52,8 +49,6 @@ function Lightbox({
}; };
}, [index, images.length, onClose, onIndexChange]); }, [index, images.length, onClose, onIndexChange]);
if (!mounted) return null;
return createPortal( return createPortal(
<div className="fixed inset-0 z-[120] flex flex-col bg-black/95" onClick={onClose}> <div className="fixed inset-0 z-[120] flex flex-col bg-black/95" onClick={onClose}>
<button <button
@@ -77,7 +72,7 @@ function Lightbox({
aria-label="تصویر بعدی" 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" 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="prev" className="h-6 w-6" /> <ChevronIcon direction="next" className="h-6 w-6" />
</button> </button>
<button <button
type="button" type="button"
@@ -85,7 +80,7 @@ function Lightbox({
aria-label="تصویر قبلی" 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" 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="next" className="h-6 w-6" /> <ChevronIcon direction="prev" className="h-6 w-6" />
</button> </button>
</> </>
)} )}
@@ -164,7 +159,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
aria-label="تصویر بعدی" 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" className="absolute top-1/2 right-3 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-surface/90 text-foreground shadow-sm transition-colors hover:border-red hover:text-red"
> >
<ChevronIcon direction="prev" className="h-5 w-5" /> <ChevronIcon direction="next" className="h-5 w-5" />
</button> </button>
<button <button
type="button" type="button"
@@ -172,7 +167,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
aria-label="تصویر قبلی" 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" className="absolute top-1/2 left-3 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-surface/90 text-foreground shadow-sm transition-colors hover:border-red hover:text-red"
> >
<ChevronIcon direction="next" className="h-5 w-5" /> <ChevronIcon direction="prev" className="h-5 w-5" />
</button> </button>
</> </>
)} )}
+24 -10
View File
@@ -119,8 +119,8 @@ export function ProductTabs({
const activeLabel = TABS.find((t) => t.id === active)?.label ?? ""; const activeLabel = TABS.find((t) => t.id === active)?.label ?? "";
return ( return (
<div className="mt-10"> <section className="mt-10 overflow-hidden rounded-2xl border border-border bg-surface">
<div className="flex items-center gap-8 border-b border-border"> <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) => ( {TABS.map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -138,7 +138,7 @@ export function ProductTabs({
))} ))}
</div> </div>
<div className="pt-8"> <div className="p-5 md:p-7">
<h2 className="mb-5 flex items-center gap-2 text-lg font-bold"> <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 /> <span className="h-2 w-2 shrink-0 rounded-full bg-red" aria-hidden />
{activeLabel} {activeLabel}
@@ -148,13 +148,27 @@ export function ProductTabs({
(technicalInfo.length === 0 ? ( (technicalInfo.length === 0 ? (
<EmptyState message="هنوز مشخصات فنی‌ای برای این محصول ثبت نشده است." /> <EmptyState message="هنوز مشخصات فنی‌ای برای این محصول ثبت نشده است." />
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2"> <div className="overflow-hidden rounded-xl border border-border">
{technicalInfo.map((field) => ( <table className="w-full border-collapse text-right text-sm">
<div key={field.label} className="rounded-xl bg-background p-4"> <tbody>
<p className="mb-1 text-sm font-bold">{field.label} :</p> {technicalInfo.map((field, index) => (
<p className="text-sm text-muted">{field.value}</p> <tr
</div> 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> </div>
))} ))}
@@ -170,6 +184,6 @@ export function ProductTabs({
{active === "comments" && <CommentsPanel entityId={entityId} initialComments={initialComments} />} {active === "comments" && <CommentsPanel entityId={entityId} initialComments={initialComments} />}
</div> </div>
</div> </section>
); );
} }
+20 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Children, useRef } from "react"; import { Children, useRef, useState } from "react";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { FreeMode } from "swiper/modules"; import { FreeMode } from "swiper/modules";
import type { Swiper as SwiperClass } from "swiper"; import type { Swiper as SwiperClass } from "swiper";
@@ -10,6 +10,7 @@ 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() {
@@ -40,11 +41,12 @@ function ChevronRightIcon() {
); );
} }
export function Carousel({ children, ariaLabel }: CarouselProps) { export function Carousel({ children, ariaLabel, slideClassName }: CarouselProps) {
const swiperRef = useRef<SwiperClass | null>(null); const swiperRef = useRef<SwiperClass | null>(null);
const [isLocked, setIsLocked] = useState(false);
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">
@@ -52,6 +54,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
type="button" type="button"
onClick={() => swiperRef.current?.slidePrev()} onClick={() => swiperRef.current?.slidePrev()}
aria-label="قبلی" aria-label="قبلی"
disabled={isLocked}
className={`${btnClass} right-1`} className={`${btnClass} right-1`}
> >
<ChevronRightIcon /> <ChevronRightIcon />
@@ -60,6 +63,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
type="button" type="button"
onClick={() => swiperRef.current?.slideNext()} onClick={() => swiperRef.current?.slideNext()}
aria-label="بعدی" aria-label="بعدی"
disabled={isLocked}
className={`${btnClass} left-1`} className={`${btnClass} left-1`}
> >
<ChevronLeftIcon /> <ChevronLeftIcon />
@@ -68,10 +72,17 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
modules={[FreeMode]} modules={[FreeMode]}
onSwiper={(swiper) => { onSwiper={(swiper) => {
swiperRef.current = swiper; swiperRef.current = swiper;
setIsLocked(swiper.isLocked);
}} }}
onLock={(swiper) => {
swiper.slideTo(0, 0);
setIsLocked(true);
}}
onUnlock={() => setIsLocked(false)}
dir="rtl" dir="rtl"
slidesPerView="auto" slidesPerView="auto"
spaceBetween={16} spaceBetween={16}
watchOverflow
freeMode={{ enabled: true, momentum: true, sticky: true }} freeMode={{ enabled: true, momentum: true, sticky: true }}
grabCursor grabCursor
role="region" role="region"
@@ -83,7 +94,12 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
// wrapper height — resolves per-slide instead of stretching to match // wrapper height — resolves per-slide instead of stretching to match
// the tallest sibling. height:auto (inline, so it wins) lets normal // the tallest sibling. height:auto (inline, so it wins) lets normal
// flex stretch equalize card heights across a row. // flex stretch equalize card heights across a row.
<SwiperSlide style={{ width: "auto", height: "auto" }}>{child}</SwiperSlide> <SwiperSlide
className={slideClassName}
style={slideClassName ? { height: "auto" } : { width: "auto", height: "auto" }}
>
{child}
</SwiperSlide>
))} ))}
</Swiper> </Swiper>
</div> </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>
);
}
+8 -68
View File
@@ -1,9 +1,5 @@
"use client";
import { useState } from "react";
import { Placeholder } from "./Placeholder"; import { Placeholder } from "./Placeholder";
import { formatPrice } from "@/data/sample"; import { formatPrice } from "@/data/sample";
import { useCart } from "@/context/CartContext";
type ProductCardProps = { type ProductCardProps = {
name: string; name: string;
@@ -16,10 +12,6 @@ type ProductCardProps = {
href?: string; href?: string;
fluid?: boolean; fluid?: boolean;
contactPhone?: string; contactPhone?: string;
// When known (store-specials/brand-groups/store-items cards already point
// at one purchasable variant), the button adds straight to the guest cart.
// Otherwise it just links to the detail page, where a variant can be
// picked first — never fabricate a variant id to force a quick-add.
variantId?: string; variantId?: string;
slug?: string; slug?: string;
}; };
@@ -34,27 +26,7 @@ export function ProductCard({
variant = "default", variant = "default",
href, href,
fluid = false, fluid = false,
contactPhone,
variantId,
slug,
}: ProductCardProps) { }: ProductCardProps) {
const { addItem } = useCart();
const [status, setStatus] = useState<"idle" | "done">("idle");
const handleAddToCart = () => {
if (!variantId || price == null) return;
addItem({
id: variantId,
name,
slug: slug ?? "",
price,
originalPrice: oldPrice ?? undefined,
image,
});
setStatus("done");
setTimeout(() => setStatus("idle"), 1500);
};
const info = ( const info = (
<> <>
<div className="relative p-[10px]"> <div className="relative p-[10px]">
@@ -71,19 +43,21 @@ export function ProductCard({
</span> </span>
)} )}
</div> </div>
<div className="p-4 pb-0"> <div className="px-4 pb-4">
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase"> {brand && (
<p className="font-display mb-1 line-clamp-2 h-8 text-[11px] leading-4 tracking-wide text-muted uppercase">
{brand} {brand}
</p> </p>
<h3 className="mb-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium"> )}
<h3 className="mb-2 line-clamp-2 h-11 text-sm leading-6 font-medium">
{name} {name}
</h3> </h3>
<div className="space-y-1"> <div className="flex h-12 flex-col items-end justify-end space-y-1 text-left">
{oldPrice != null && ( {oldPrice != null && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted line-through">{formatPrice(oldPrice)}</span> <span className="text-sm text-muted line-through">{formatPrice(oldPrice)}</span>
{discount != null && ( {discount != null && (
<span className="rounded-md bg-green-50 px-1.5 py-0.5 text-[11px] font-bold text-green-700"> <span className="rounded-md bg-red px-1.5 py-0.5 text-[11px] font-bold text-white">
{discount}٪ {discount}٪
</span> </span>
)} )}
@@ -115,40 +89,6 @@ export function ProductCard({
) : ( ) : (
<div className="flex-1">{info}</div> <div className="flex-1">{info}</div>
)} )}
<div className="p-4">
{price != null && variantId ? (
<button
type="button"
onClick={handleAddToCart}
className="w-full rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
>
{status === "done" ? "افزوده شد ✓" : "افزودن به سبد"}
</button>
) : price != null && href ? (
<a
href={href}
className="block w-full rounded-lg bg-red py-2 text-center text-sm font-medium text-white transition-colors hover:bg-red-dark"
>
افزودن به سبد
</a>
) : contactPhone ? (
<a
href={`tel:${contactPhone}`}
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
>
<span aria-hidden>📞</span>
تماس با فروشگاه
</a>
) : (
<button
type="button"
disabled
className="w-full cursor-not-allowed rounded-lg bg-red/60 py-2 text-sm font-medium text-white"
>
تماس با فروشگاه
</button>
)}
</div>
</article> </article>
); );
} }
+15 -9
View File
@@ -1,16 +1,20 @@
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>
{enTitle && (
<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"
lang="en" lang="en"
@@ -18,18 +22,20 @@ export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleP
> >
{enTitle} {enTitle}
</span> </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>
); );
+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>
);
}
+104 -17
View File
@@ -44,6 +44,31 @@ async function apiPost<T>(
} }
} }
/** 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 = { export type BusinessInfo = {
id: string; id: string;
name: string; name: string;
@@ -51,6 +76,7 @@ export type BusinessInfo = {
about: string; about: string;
vision: string; vision: string;
logoUrl: string | null; logoUrl: string | null;
logoDarkUrl: string | null;
faviconUrl: string | null; faviconUrl: string | null;
emails: string[]; emails: string[];
phoneNumbers: string[]; phoneNumbers: string[];
@@ -63,6 +89,8 @@ export type BusinessInfo = {
aparat?: string; aparat?: string;
}; };
addresses: string[]; addresses: string[];
metaTags?: BusinessMetaTag[];
trustBadges?: BusinessTrustBadge[];
}; };
export async function getBusinessInfo(): Promise<BusinessInfo | null> { export async function getBusinessInfo(): Promise<BusinessInfo | null> {
@@ -144,6 +172,7 @@ export type Product = {
// brand-groups, store-items — lets "add to cart" skip a variant picker). // brand-groups, store-items — lets "add to cart" skip a variant picker).
variantId?: string; variantId?: string;
stockQuantity?: number; stockQuantity?: number;
categoryId?: string;
}; };
function normalizeProduct(raw: Record<string, unknown>): Product { function normalizeProduct(raw: Record<string, unknown>): Product {
@@ -194,6 +223,12 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
variantId: firstVariant?.id != null ? String(firstVariant.id) : undefined, variantId: firstVariant?.id != null ? String(firstVariant.id) : undefined,
stockQuantity: stockQuantity:
firstVariant?.stockQuantity != null ? Number(firstVariant.stockQuantity) : undefined, 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, price: numericPrice,
oldPrice: numericOldPrice, oldPrice: numericOldPrice,
discount, discount,
@@ -210,11 +245,12 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
} }
export function productHref(product: Product): string { export function productHref(product: Product): string {
if (product.slug) return `/products/${product.slug}`; if (product.variantId) return `/store-items/${product.variantId}`;
if (product.slug) return `/store-items/${product.slug}`;
// No slug (store-specials/brand-groups/store-items don't return one) — route // 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. // through the id-based resolver, which redirects to the canonical slug page.
if (product.id) return `/products/id/${product.id}`; if (product.id) return `/store-items/${product.id}`;
return `/products?q=${encodeURIComponent(product.name)}`; return `/store-items?q=${encodeURIComponent(product.name)}`;
} }
export async function getProducts(params?: { export async function getProducts(params?: {
@@ -343,13 +379,47 @@ export async function getProductStoreVariants(productId: string): Promise<Produc
export type TechnicalInfoField = { label: string; value: string }; export type TechnicalInfoField = { label: string; value: string };
export async function getProductTechnicalInfo(slug: string): Promise<TechnicalInfoField[]> { export async function getProductTechnicalInfo(slug: string): Promise<TechnicalInfoField[]> {
const res = await apiGet<{ form: unknown; values: Record<string, unknown>[] }>( const res = await apiGet<{
form: { fields?: Record<string, unknown>[] } | null;
values: Record<string, unknown>[];
}>(
`/tenants/${DOMAIN}/products/${slug}/technical-info`, `/tenants/${DOMAIN}/products/${slug}/technical-info`,
); );
return (res?.values ?? []).map((v) => ({ const submittedValues = res?.values ?? [];
label: String(v.label ?? v.name ?? v.key ?? ""), const valuesByFieldId = new Map(
value: String(v.value ?? ""), 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 = { export type StoreItem = {
@@ -439,6 +509,7 @@ export type BlogSummary = {
image?: string | null; image?: string | null;
publishedAt?: string; publishedAt?: string;
categoryName?: string; categoryName?: string;
categoryId?: string;
}; };
function normalizeBlog(raw: Record<string, unknown>): BlogSummary { function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
@@ -450,6 +521,7 @@ function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
image: (raw.titleImageUrl as string | undefined) ?? null, image: (raw.titleImageUrl as string | undefined) ?? null,
publishedAt: raw.publishedAt as string | undefined, publishedAt: raw.publishedAt as string | undefined,
categoryName: raw.categoryName as string | undefined, categoryName: raw.categoryName as string | undefined,
categoryId: raw.categoryId != null ? String(raw.categoryId) : undefined,
}; };
} }
@@ -490,27 +562,32 @@ export type ProductGroup = {
id: string; id: string;
key: string; key: string;
title: string; title: string;
sortOrder: number;
items: Product[]; 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[] { function normalizeProductGroups(items: Record<string, unknown>[] | undefined): ProductGroup[] {
return (items ?? []).map((group) => ({ return (items ?? [])
.map((group) => ({
id: String(group.id ?? group.key ?? ""), id: String(group.id ?? group.key ?? ""),
key: String(group.key ?? group.id ?? ""), key: String(group.key ?? group.id ?? ""),
title: String(group.title ?? ""), title: String(group.title ?? ""),
sortOrder: Number(group.sortOrder ?? 0),
items: Array.isArray(group.items) items: Array.isArray(group.items)
? (group.items as Record<string, unknown>[]).map(normalizeProduct) ? (group.items as Record<string, unknown>[]).map(normalizeProduct)
: [], : [],
})); }))
} .sort((a, b) => a.sortOrder - b.sortOrder);
export async function getBrandGroups(): Promise<ProductGroup[]> {
const res = await apiGet<{ items: Record<string, unknown>[] }>(
`/tenants/${DOMAIN}/website/brand-groups`,
);
return normalizeProductGroups(res?.items);
} }
// 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[]> { export async function getStoreSpecials(): Promise<ProductGroup[]> {
const res = await apiGet<{ items: Record<string, unknown>[] }>( const res = await apiGet<{ items: Record<string, unknown>[] }>(
`/tenants/${DOMAIN}/store-specials`, `/tenants/${DOMAIN}/store-specials`,
@@ -581,6 +658,7 @@ export const STATIC_IMAGE_KEYS = {
twoBanner: "two-banner", twoBanner: "two-banner",
threeBannerRow: "three-banner-row", threeBannerRow: "three-banner-row",
featuredCategories: "featured-categories", featuredCategories: "featured-categories",
aboutUsImage: "about-us-image",
} as const; } as const;
export const STATIC_IMAGE_SLOT_CATALOG = [ export const STATIC_IMAGE_SLOT_CATALOG = [
@@ -620,6 +698,15 @@ export const STATIC_IMAGE_SLOT_CATALOG = [
itemCount: null, itemCount: null,
recommendedWidth: 300, recommendedWidth: 300,
}, },
{
key: STATIC_IMAGE_KEYS.aboutUsImage,
label: "Homepage about-us image",
kind: "single" as const,
pageKey: "home",
aspectRatio: "14:9",
itemCount: 1,
recommendedWidth: 560,
},
]; ];
export async function getStaticImageSlots(): Promise<StaticImageSlot[]> { export async function getStaticImageSlots(): Promise<StaticImageSlot[]> {