Compare commits
11
Commits
0de5abb879
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea3fe76c9 | ||
|
|
f60c98184b | ||
|
|
804eb8702f | ||
|
|
3af4e04bdd | ||
|
|
69c54d3b17 | ||
|
|
c4411ebc28 | ||
|
|
69d17a6f8f | ||
|
|
d292bfc74c | ||
|
|
d090f57540 | ||
|
|
7a78adc7f3 | ||
|
|
ab1b07ff28 |
+146
-16
@@ -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
|
||||
`src/components/ui/Breadcrumb.tsx`.
|
||||
- Detail pages use `src/components/ui/PageShell.tsx` so the max width, page
|
||||
padding, and breadcrumb position cannot drift between product and blog
|
||||
detail layouts. The blog detail page also renders category-matched related
|
||||
posts in the collapsible `SimilarArticles` sidebar; its DOM position follows
|
||||
the article so it naturally moves below the article body on smaller screens.
|
||||
- **Product detail layout**: 3-column grid (desktop) — gallery
|
||||
(`ProductGallery`, ~1/3, includes hover-zoom + click-to-open lightbox) →
|
||||
title + variation pickers (~5/12) → sticky buy-box (~1/4). One
|
||||
@@ -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"`).
|
||||
- Category tile images: 110px, no border, no border-radius
|
||||
(`FeaturedCategories.tsx`).
|
||||
- **No emoji as icons** — `src/components/ui/icons.tsx` has the shared
|
||||
light/outline SVG set (`MenuIcon`, `UserIcon`, `CartIcon`, `CloseIcon`,
|
||||
`ChevronLeftIcon`), 24x24 viewBox, `stroke="currentColor"`. Use these
|
||||
instead of characters like "☰"/"👤"/"🛒"/"✕". Every "view more"-style
|
||||
link/button (`SectionTitle`'s `action`, the mega-menu's "مشاهده همه",
|
||||
`BlogCarousel`'s "ادامه مطلب") uses `ChevronLeftIcon`, not a literal "←"
|
||||
character — keep new ones consistent with that.
|
||||
- **Header account/cart cluster** (`Header.tsx`, desktop only —
|
||||
`hidden ... sm:flex`): a borderless `bg-surface` chip holding, in this
|
||||
exact DOM order, `CartButton`, a divider, then the login link. That DOM
|
||||
order is deliberate and easy to get backwards: under `dir="rtl"` the
|
||||
first DOM child renders rightmost, so cart-first-in-DOM is what actually
|
||||
puts the cart icon on the visual **right** and the login block on the
|
||||
**left** (this was built backwards once and corrected). Same rule inside
|
||||
the login link itself — the icon badge comes before the two-line text in
|
||||
DOM so the icon renders right of the text. Neither `CartButton` nor this
|
||||
wrapper has a border (removed on request — don't reintroduce one).
|
||||
`CartButton` is a fixed 44px (`h-11 w-11`) icon-only square everywhere it
|
||||
renders (also stands alone, un-wrapped, below `sm`); its unread-count
|
||||
badge sits at `-top-1.5 -right-1.5`, not the product-card cart badge's
|
||||
`-left-1.5` — don't copy that positioning between the two, they're
|
||||
intentionally different corners for different-looking buttons.
|
||||
|
||||
## Static-image slots
|
||||
|
||||
@@ -188,13 +215,61 @@ catalog data. `STATIC_IMAGE_SLOT_CATALOG` in `meshkee.ts` is the source of
|
||||
truth for which slots exist; publishing it via
|
||||
`src/app/meshkee/static-image-slots/route.ts` (`/meshkee/static-image-slots`)
|
||||
is how the admin dashboard's "refresh" imports new keys. Current keys:
|
||||
`home-hero`, `two-banner`, `three-banner-row`, `featured-categories`. 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
|
||||
them in the dashboard; the code path just needs to fall back to whatever the
|
||||
"current" behavior was before the slot existed when it's empty (see
|
||||
`FeaturedCategories.tsx` for the pattern: real slot images when present,
|
||||
else the pre-existing UI unchanged).
|
||||
|
||||
**Static-image sizing rule:** whenever a slot's rendered image dimensions or
|
||||
aspect ratio change during development, update the matching
|
||||
`STATIC_IMAGE_SLOT_CATALOG` entry (`aspectRatio` and `recommendedWidth`) in the
|
||||
same change so `/meshkee/static-image-slots` stays accurate for the dashboard.
|
||||
|
||||
## Store-item filters
|
||||
|
||||
`/store-items` has a desktop sidebar and a mobile filter drawer implemented by
|
||||
`components/product/ProductFilters.tsx`. Categories are rendered as a nested
|
||||
accordion (CMS parent categories with their children), not one long flat list.
|
||||
The three currently supported filters
|
||||
are category (`categoryId`), price range (`minPrice` / `maxPrice`), and in-stock
|
||||
only (`inStock=true`). They are submitted as URL search params and passed to
|
||||
`getStoreItems`, so filtering happens in the API before pagination. Pagination
|
||||
links must preserve every active filter. Categories come from `getCategoryTree`
|
||||
and therefore follow CMS data rather than a hardcoded list. On mobile the
|
||||
"فیلتر محصولات" button opens a right-side modal; its submit/reset actions stay
|
||||
fixed at the bottom while filter fields scroll. Desktop filters apply in real
|
||||
time: category and stock changes submit immediately, while price inputs use a
|
||||
short debounce; only the mobile drawer keeps an explicit apply button. Desktop
|
||||
submissions use `scroll={false}` and the client component must not be keyed by
|
||||
active filters, otherwise both the page and the category list jump back to the
|
||||
top. When a parent category or one of its children becomes active, its matching
|
||||
`details` element is opened imperatively without closing other accordions.
|
||||
|
||||
Do not simulate technical-property filters by fetching every product's
|
||||
`technical-info`: the public API currently exposes technical schema/values only
|
||||
per product, and `/store-items` does not accept technical field filters. When a
|
||||
faceted category-filter endpoint and technical-field query parameters are added
|
||||
to the API, render those returned fields as additional accordions in the same
|
||||
component.
|
||||
|
||||
## Product detail tabs and technical specifications
|
||||
|
||||
`ProductTabs` wraps the specifications, review, and comments UI in one white,
|
||||
rounded, bordered card. Technical specifications render as a responsive
|
||||
two-column table (field label and submitted value), not a loose card grid.
|
||||
`getProductTechnicalInfo()` must join `values[].fieldId` to
|
||||
`form.fields[].id`, resolve option labels from each field's `options`, and sort
|
||||
by the CMS field `sortOrder`; technical `values` do not reliably carry their
|
||||
own labels. If the form or submitted values are empty, keep the existing empty
|
||||
state rather than fabricating rows.
|
||||
|
||||
## Homepage product carousels — fully dynamic, don't hardcode a name/key
|
||||
|
||||
`GET /tenants/{domain}/store-specials` returns **every** admin-managed named
|
||||
@@ -222,14 +297,17 @@ time the page renders, with zero code changes.
|
||||
|
||||
`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, so the inline English-caps subtitle just doesn't render for
|
||||
them. Instead, each carousel shows an English caption *under* the title
|
||||
(`SectionTitle`'s separate `enSubtitle` prop, `opacity-50`, its own line) —
|
||||
`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).
|
||||
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
|
||||
@@ -238,15 +316,33 @@ 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
|
||||
`/products?specialId={group.id}`. `store-specials` groups are admin-curated,
|
||||
`/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 `/products` with
|
||||
"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
|
||||
instead of calling `getProducts()`. This is why the pattern is safe to keep
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -263,14 +359,48 @@ render a `<link rel="icon">` and browsers aren't guaranteed to prefer the
|
||||
second one, so keeping both was silently serving the stale static icon.
|
||||
Don't add `app/favicon.ico` (or `public/favicon.ico`) back.
|
||||
|
||||
## Footer: light background, black text, sticky to viewport bottom
|
||||
|
||||
The footer is `bg-[#f1f1f1]` (exact hex given by the user, same neutral
|
||||
gray as `CartButton`'s background — not swapped for a design-token color;
|
||||
an earlier version used a red-tinted `#9a21291a`, since replaced) with
|
||||
black text throughout (`text-black`, `/70`, `/50`, `/10`, `/20` opacity
|
||||
steps) — it used to be a solid dark-red block with white text; don't revert
|
||||
to that. The footer logo (`logoDarkUrl ?? logoUrl`) is `h-[72px]`, doubled
|
||||
from its original size on request. Because `public/images/meshkee-logo-
|
||||
white.png` (the credit-widget logo below) is a solid-white asset, it gets
|
||||
`brightness-0` on this light background to render as a black silhouette
|
||||
instead of invisible white-on-near-white — the same `opacity-60 →
|
||||
group-hover:opacity-100` fade still applies on top of that.
|
||||
|
||||
`<main>` has `flex-1` in `layout.tsx` (`body` is `flex flex-col`) so the
|
||||
footer sits flush at the bottom of the viewport on short pages instead of
|
||||
leaving a gap of bare `background` color beneath it — this is the standard
|
||||
sticky-footer flex pattern. Don't remove `flex-1` from `main` without
|
||||
re-checking a short page (e.g. `/contact`) for that gap coming back.
|
||||
|
||||
## Footer: Meshkee credit widget
|
||||
|
||||
The footer's bottom-right link ("Designed And Developed By Meshkee
|
||||
E-Commerce Team" → `https://meshkee.com`) intentionally matches the credit
|
||||
badge Meshkee's own storefronts use elsewhere (reference: safeteb.com) —
|
||||
same markup shape (frame + logo + two-line label), same animated corner
|
||||
border (four 1px lines that draw in on hover, staggered L→B→R→T, and
|
||||
un-draw in reverse on mouse-leave via matching `transition-delay`s on the
|
||||
resting state). `public/images/meshkee-logo-white.png` is that reference
|
||||
site's own logo asset, pulled down and committed here since it's the
|
||||
platform's shared credit-widget logo, not something unique to safeteb.com.
|
||||
If this ever needs to change, re-derive the exact hover-timing values from
|
||||
a live Meshkee site rather than guessing new ones — the staggered reverse
|
||||
un-draw is the distinctive part, not just "a border that appears."
|
||||
|
||||
## Route map
|
||||
|
||||
| Route | Notes |
|
||||
|---|---|
|
||||
| `/` | Homepage — all the carousels/banners |
|
||||
| `/products`, `/products/[slug]` | Catalog browsing, category filter via `?categoryId=`, one store-specials group via `?specialId=` (see above) |
|
||||
| `/products/id/[id]` | Redirect-only resolver, see "Group-source id vs productId" above |
|
||||
| `/store-items`, `/store-items/[id]` | Variant-level browsing/detail, see above |
|
||||
| `/store-items`, `/store-items/[id]` | Canonical catalog and product detail; filters include category, price and stock; curated groups use `?specialId=` |
|
||||
| `/products`, `/products/[slug]`, `/products/id/[id]` | Backwards-compatible redirects to the canonical `/store-items` routes |
|
||||
| `/blog`, `/blog/[slug]` | Blog listing/detail |
|
||||
| `/contact` | Contact form → `POST /contact-submissions`, toast + reset on success |
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
|
||||
output: "standalone",
|
||||
allowedDevOrigins: ["sanihome.com", "localhost"],
|
||||
};
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -1,12 +1,21 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { getBlog } from "@/lib/meshkee";
|
||||
import Link from "next/link";
|
||||
import { SimilarArticles } from "@/components/blog/SimilarArticles";
|
||||
import { PageShell } from "@/components/ui/PageShell";
|
||||
import { getBlog, getBlogs } from "@/lib/meshkee";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "long" }).format(new Date(iso));
|
||||
}
|
||||
|
||||
function withResponsiveTables(html: string) {
|
||||
return html.replace(
|
||||
/(<table\b[\s\S]*?<\/table>)/gi,
|
||||
'<div class="article-table-scroll" role="region" aria-label="جدول مقاله" tabindex="0">$1</div>',
|
||||
);
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -17,46 +26,59 @@ export default async function BlogDetailPage({
|
||||
|
||||
if (!post) notFound();
|
||||
|
||||
const { items: relatedCandidates } = await getBlogs({
|
||||
categoryId: post.categoryId,
|
||||
pageSize: post.categoryId ? 8 : 50,
|
||||
});
|
||||
const similarPosts = relatedCandidates
|
||||
.filter((candidate) => candidate.id !== post.id)
|
||||
.filter((candidate) => !post.categoryName || candidate.categoryName === post.categoryName)
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-4 py-10">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "وبلاگ", href: "/blog" },
|
||||
{ label: post.title },
|
||||
]}
|
||||
/>
|
||||
<PageShell
|
||||
breadcrumbItems={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "وبلاگ", href: "/blog" },
|
||||
{ label: post.title },
|
||||
]}
|
||||
>
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_300px] lg:items-start">
|
||||
<article className="min-w-0">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
{post.categoryName && <span>{post.categoryName}</span>}
|
||||
{post.publishedAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{post.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.author}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-2 text-xs text-muted">
|
||||
{post.categoryName && <span>{post.categoryName}</span>}
|
||||
{post.publishedAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{post.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.author}</span>
|
||||
</>
|
||||
)}
|
||||
<h1 className="mb-6 text-xl font-bold leading-relaxed text-red-dark md:text-3xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="prose article-content max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: withResponsiveTables(post.mainTextHtml) }}
|
||||
/>
|
||||
|
||||
<div className="mt-10 border-t border-border pt-6">
|
||||
<Link href="/blog" className="text-sm font-medium text-red hover:text-red-dark">
|
||||
→ بازگشت به وبلاگ
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<SimilarArticles posts={similarPosts} />
|
||||
</div>
|
||||
|
||||
<h1 className="mb-6 text-2xl font-bold leading-relaxed text-red-dark md:text-3xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: post.mainTextHtml }}
|
||||
/>
|
||||
|
||||
<div className="mt-10 border-t border-border pt-6">
|
||||
<a href="/blog" className="text-sm font-medium text-red hover:text-red-dark">
|
||||
→ بازگشت به وبلاگ
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,3 +100,117 @@ h6 {
|
||||
height: auto;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/* Rich-text tables arrive as raw CMS HTML. Keep every article table aligned
|
||||
with the full article column while allowing a readable horizontal scroll on
|
||||
narrow screens instead of squeezing several columns into tiny cells. */
|
||||
.article-table-scroll {
|
||||
width: 100%;
|
||||
margin-block: 2rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.875rem;
|
||||
background: var(--surface);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--red-muted) transparent;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.article-table-scroll:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.article-content table {
|
||||
width: 100% !important;
|
||||
min-width: 600px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-spacing: 0;
|
||||
border-collapse: separate;
|
||||
overflow: hidden;
|
||||
border-radius: 0.875rem;
|
||||
background: var(--surface);
|
||||
font-size: 0.925rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article-content thead {
|
||||
background: var(--red-light);
|
||||
}
|
||||
|
||||
.article-content th,
|
||||
.article-content td {
|
||||
padding: 0.9rem 1rem;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.article-content th {
|
||||
color: var(--red-dark);
|
||||
font-family: var(--font-iranyekan-bold), var(--font-iranyekan), system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-content th:last-child,
|
||||
.article-content td:last-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.article-content tbody tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.article-content tbody tr {
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
|
||||
.article-content tbody tr:hover {
|
||||
background: var(--red-light);
|
||||
}
|
||||
|
||||
.article-content tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.article-content h2 {
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article-content h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.article-content h4 {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.925rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.article-table-scroll {
|
||||
margin-inline: 0;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.article-content table {
|
||||
min-width: 560px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.article-content th,
|
||||
.article-content td {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -6,7 +6,12 @@ import { TopBar } from "@/components/home/TopBar";
|
||||
import { Header } from "@/components/home/Header";
|
||||
import { Footer } from "@/components/home/Footer";
|
||||
import { Providers } from "@/context/Providers";
|
||||
import { getBusinessInfo, getCategories, getCategoryTree } from "@/lib/meshkee";
|
||||
import {
|
||||
getBusinessInfo,
|
||||
getCategories,
|
||||
getCategoryTree,
|
||||
parseMetaTagHtml,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
// Body text — headings use the bold weight instead, see globals.css.
|
||||
const iranyekan = localFont({
|
||||
@@ -29,8 +34,9 @@ const syne = Syne({
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const businessInfo = await getBusinessInfo();
|
||||
|
||||
return {
|
||||
title: "سانیهوم | فروشگاه لوازم خانگی",
|
||||
title: "صنیع هوم | فروشگاه لوازم خانگی",
|
||||
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
||||
icons: businessInfo?.faviconUrl ? [{ url: businessInfo.faviconUrl }] : undefined,
|
||||
};
|
||||
@@ -43,17 +49,26 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
getBusinessInfo(),
|
||||
]);
|
||||
|
||||
const cmsMetaTags = (businessInfo?.metaTags ?? [])
|
||||
.map((tag, index) => {
|
||||
const attrs = parseMetaTagHtml(tag.html ?? "");
|
||||
if (!attrs) return null;
|
||||
return <meta key={`cms-meta-${index}`} {...attrs} />;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
className={`${iranyekan.variable} ${iranyekanBold.variable} ${syne.variable} h-full antialiased`}
|
||||
>
|
||||
{cmsMetaTags.length > 0 ? <head>{cmsMetaTags}</head> : null}
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>
|
||||
<TopBar />
|
||||
<Header categoryTree={categoryTree} categories={categories} logoUrl={businessInfo?.logoUrl} />
|
||||
<main>{children}</main>
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer businessInfo={businessInfo} categories={categories} />
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ import {
|
||||
getCategories,
|
||||
getStoreSpecials,
|
||||
getStaticImageSlots,
|
||||
firstImageForSlot,
|
||||
imagesForSlot,
|
||||
STATIC_IMAGE_KEYS,
|
||||
} from "@/lib/meshkee";
|
||||
@@ -44,7 +45,12 @@ export default async function Home() {
|
||||
threeBannerAspectRatio={threeBannerSlot?.aspectRatio}
|
||||
/>
|
||||
<FeatureBar />
|
||||
<AboutSanihome />
|
||||
<AboutSanihome
|
||||
image={firstImageForSlot(staticImageSlots, STATIC_IMAGE_KEYS.aboutUsImage)}
|
||||
aspectRatio={staticImageSlots.find(
|
||||
(slot) => slot.key === STATIC_IMAGE_KEYS.aboutUsImage,
|
||||
)?.aspectRatio}
|
||||
/>
|
||||
<BlogCarousel posts={blogsResult.items} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { ProductGallery } from "@/components/product/ProductGallery";
|
||||
import { ProductPurchasePanel } from "@/components/product/ProductPurchasePanel";
|
||||
import { ProductTabs } from "@/components/product/ProductTabs";
|
||||
import { SimilarProducts } from "@/components/product/SimilarProducts";
|
||||
import {
|
||||
getBusinessInfo,
|
||||
getComments,
|
||||
getProduct,
|
||||
getProductStoreVariants,
|
||||
getProductTechnicalInfo,
|
||||
getProductVariations,
|
||||
} from "@/lib/meshkee";
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function ProductDetailPage({
|
||||
params,
|
||||
@@ -19,79 +6,5 @@ export default async function ProductDetailPage({
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const [product, technicalInfo, variations, businessInfo] = await Promise.all([
|
||||
getProduct(slug),
|
||||
getProductTechnicalInfo(slug),
|
||||
getProductVariations(slug),
|
||||
getBusinessInfo(),
|
||||
]);
|
||||
|
||||
if (!product) notFound();
|
||||
|
||||
const [storeVariants, 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>
|
||||
);
|
||||
permanentRedirect(`/store-items/${slug}`);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default async function ProductByIdPage({
|
||||
|
||||
if (!product?.slug) notFound();
|
||||
|
||||
redirect(`/products/${product.slug}`);
|
||||
redirect(`/store-items/${product.slug}`);
|
||||
}
|
||||
|
||||
+8
-166
@@ -1,176 +1,18 @@
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import {
|
||||
type CategoryTreeGroup,
|
||||
getBusinessInfo,
|
||||
getCategoryTree,
|
||||
getProducts,
|
||||
getStoreSpecials,
|
||||
productHref,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
function findCategoryLabel(tree: CategoryTreeGroup[], id: string): string | undefined {
|
||||
for (const group of tree) {
|
||||
for (const item of group.items) {
|
||||
if (item.id === id) return item.nameFa;
|
||||
const child = item.children.find((c) => c.id === id);
|
||||
if (child) return child.nameFa;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function ProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ categoryId?: string; q?: string; page?: string; specialId?: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number(params.page ?? 1) || 1);
|
||||
const query = new URLSearchParams();
|
||||
|
||||
// "مشاهده همه" from a homepage carousel — those are admin-curated,
|
||||
// already-complete lists (GET /store-specials has no pagination), so show
|
||||
// that exact group's items rather than re-querying the general catalog.
|
||||
if (params.specialId) {
|
||||
const groups = await getStoreSpecials();
|
||||
const group = groups.find((g) => g.id === params.specialId);
|
||||
const businessInfo = await getBusinessInfo();
|
||||
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||
const items = group?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "محصولات", href: "/products" },
|
||||
{ label: group?.title ?? "کروسل" },
|
||||
]}
|
||||
/>
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">{group?.title ?? "محصولات"}</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (Array.isArray(value)) value.forEach((item) => query.append(key, item));
|
||||
else if (value != null) query.set(key, value);
|
||||
}
|
||||
|
||||
const [{ 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) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.categoryId) sp.set("categoryId", params.categoryId);
|
||||
if (params.q) sp.set("q", params.q);
|
||||
sp.set("page", String(p));
|
||||
return `/products?${sp.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "محصولات", href: isFiltered ? "/products" : undefined },
|
||||
...(params.q ? [{ label: `نتایج جستجو برای «${params.q}»` }] : []),
|
||||
...(categoryLabel ? [{ label: categoryLabel }] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">
|
||||
{params.q ? `نتایج جستجو برای «${params.q}»` : "محصولات"}
|
||||
</h1>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState message="محصولی یافت نشد." />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
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>
|
||||
);
|
||||
const suffix = query.toString();
|
||||
permanentRedirect(`/store-items${suffix ? `?${suffix}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { PageShell } from "@/components/ui/PageShell";
|
||||
import { ProductGallery } from "@/components/product/ProductGallery";
|
||||
import { ProductPurchasePanel } from "@/components/product/ProductPurchasePanel";
|
||||
import { ProductTabs } from "@/components/product/ProductTabs";
|
||||
@@ -7,6 +7,7 @@ import { SimilarProducts } from "@/components/product/SimilarProducts";
|
||||
import {
|
||||
getBusinessInfo,
|
||||
getComments,
|
||||
getProduct,
|
||||
getProductById,
|
||||
getProductStoreVariants,
|
||||
getProductTechnicalInfo,
|
||||
@@ -21,9 +22,9 @@ export default async function StoreItemDetailPage({
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const variant = await getStoreItemVariant(id);
|
||||
if (!variant) notFound();
|
||||
|
||||
const product = await getProductById(variant.productId);
|
||||
const product = variant
|
||||
? await getProductById(variant.productId)
|
||||
: (await getProduct(id)) ?? (await getProductById(id));
|
||||
if (!product) notFound();
|
||||
|
||||
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
|
||||
// every variation.
|
||||
const initialSelections = Object.fromEntries(
|
||||
variant.selections.map((s) => [s.variationId, s.optionId]),
|
||||
(variant?.selections ?? []).map((s) => [s.variationId, s.optionId]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
<PageShell
|
||||
breadcrumbItems={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "فروشگاه", href: "/store-items" },
|
||||
...(product.categoryName
|
||||
? [{ label: product.categoryName, href: `/products?categoryId=${product.categoryId}` }]
|
||||
? [{ label: product.categoryName, href: `/store-items?categoryId=${product.categoryId}` }]
|
||||
: []),
|
||||
{ label: product.name },
|
||||
]}
|
||||
/>
|
||||
]}
|
||||
>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-4">
|
||||
@@ -101,6 +101,6 @@ export default async function StoreItemDetailPage({
|
||||
excludeProductId={product.id}
|
||||
contactPhone={contactPhone}
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
+137
-26
@@ -1,33 +1,110 @@
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
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;
|
||||
|
||||
export default async function StoreItemsPage({
|
||||
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 page = Math.max(1, Number(params.page ?? 1) || 1);
|
||||
const [{ items, total }, businessInfo] = await Promise.all([
|
||||
getStoreItems({
|
||||
categoryId: params.categoryId,
|
||||
name: params.q,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
getBusinessInfo(),
|
||||
const parsePrice = (value?: string) => {
|
||||
const number = Number(value);
|
||||
return value && Number.isFinite(number) && number >= 0 ? number : undefined;
|
||||
};
|
||||
const minPrice = parsePrice(params.minPrice);
|
||||
const maxPrice = parsePrice(params.maxPrice);
|
||||
const inStock = params.inStock === "true";
|
||||
const categoryId = params.categoryId || undefined;
|
||||
|
||||
const specialMode = Boolean(params.specialId || params.special === "sale");
|
||||
const [categoryTree, specialGroups] = await Promise.all([
|
||||
getCategoryTree(),
|
||||
specialMode ? getStoreSpecials() : Promise.resolve([]),
|
||||
]);
|
||||
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const categories: ProductFilterCategory[] = categoryTree.flatMap((group) =>
|
||||
group.items.map((category) => ({
|
||||
id: category.id,
|
||||
label: category.nameFa,
|
||||
children: category.children.map((child) => ({
|
||||
id: child.id,
|
||||
label: child.nameFa,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
const filterValues = {
|
||||
categoryId,
|
||||
minPrice: minPrice != null ? String(minPrice) : undefined,
|
||||
maxPrice: maxPrice != null ? String(maxPrice) : undefined,
|
||||
inStock,
|
||||
q: params.q,
|
||||
specialId: params.specialId,
|
||||
special: params.special,
|
||||
};
|
||||
|
||||
const specialGroup = params.specialId
|
||||
? specialGroups.find((group) => group.id === params.specialId)
|
||||
: specialGroups.find(isSpecialSaleGroup);
|
||||
const selectedCategoryIds = new Set<string>();
|
||||
if (categoryId) {
|
||||
selectedCategoryIds.add(categoryId);
|
||||
const parent = categories.find((item) => item.id === categoryId);
|
||||
parent?.children.forEach((child) => selectedCategoryIds.add(child.id));
|
||||
}
|
||||
|
||||
const specialItems = (specialGroup?.items ?? []).filter((product) => {
|
||||
const matchesCategory = !categoryId || (product.categoryId && selectedCategoryIds.has(product.categoryId));
|
||||
const matchesQuery = !params.q || product.name.toLocaleLowerCase("fa").includes(params.q.toLocaleLowerCase("fa"));
|
||||
const matchesMin = minPrice == null || (product.price != null && product.price >= minPrice);
|
||||
const matchesMax = maxPrice == null || (product.price != null && product.price <= maxPrice);
|
||||
const matchesStock = !inStock || (product.stockQuantity != null && product.stockQuantity > 0);
|
||||
return matchesCategory && matchesQuery && matchesMin && matchesMax && matchesStock;
|
||||
});
|
||||
|
||||
const storeResult = specialMode
|
||||
? { items: [], total: 0 }
|
||||
: await getStoreItems({
|
||||
categoryId,
|
||||
name: params.q,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
inStock: inStock || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(storeResult.total / PAGE_SIZE));
|
||||
|
||||
const pageHref = (p: number) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.categoryId) sp.set("categoryId", params.categoryId);
|
||||
if (categoryId) sp.set("categoryId", categoryId);
|
||||
if (params.q) sp.set("q", params.q);
|
||||
if (minPrice != null) sp.set("minPrice", String(minPrice));
|
||||
if (maxPrice != null) sp.set("maxPrice", String(maxPrice));
|
||||
if (inStock) sp.set("inStock", "true");
|
||||
sp.set("page", String(p));
|
||||
return `/store-items?${sp.toString()}`;
|
||||
};
|
||||
@@ -41,16 +118,49 @@ export default async function StoreItemsPage({
|
||||
]}
|
||||
/>
|
||||
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">
|
||||
{params.q ? `نتایج جستجو برای «${params.q}»` : "فروشگاه"}
|
||||
</h1>
|
||||
{params.q && (
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">
|
||||
نتایج جستجو برای «{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((item) => (
|
||||
<div className="md:flex md:items-start md:gap-6">
|
||||
<ProductFilters
|
||||
categories={categories}
|
||||
values={filterValues}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{specialMode && (
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">
|
||||
{specialGroup?.title ?? "فروش ویژه"}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{(specialMode ? specialItems : storeResult.items).length === 0 ? (
|
||||
<EmptyState message="کالایی با این فیلترها یافت نشد." />
|
||||
) : specialMode ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{specialItems.map((product) => (
|
||||
<ProductCard
|
||||
key={`${product.id}-${product.variantId ?? "product"}`}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
oldPrice={product.oldPrice}
|
||||
discount={product.discount}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
href={productHref(product)}
|
||||
fluid
|
||||
variantId={product.variantId}
|
||||
slug={product.slug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{storeResult.items.map((item) => (
|
||||
<ProductCard
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
@@ -61,13 +171,12 @@ export default async function StoreItemsPage({
|
||||
image={item.image ?? "/images/placeholder.svg"}
|
||||
href={storeItemHref(item)}
|
||||
fluid
|
||||
contactPhone={contactPhone}
|
||||
variantId={item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
{!specialMode && totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
{page > 1 ? (
|
||||
<a
|
||||
@@ -98,8 +207,10 @@ export default async function StoreItemsPage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import type { BlogSummary } from "@/lib/meshkee";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium" }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function SimilarArticles({ posts }: { posts: BlogSummary[] }) {
|
||||
if (posts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<aside className="lg:sticky lg:top-32">
|
||||
<details open className="group rounded-2xl border border-border bg-surface">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-4 font-bold text-red-dark">
|
||||
مقالات مشابه
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="h-5 w-5 shrink-0 text-muted transition-transform group-open:rotate-180"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="m7 10 5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="divide-y divide-border">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group/article flex gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Placeholder
|
||||
src={post.image ?? "/images/placeholder.svg"}
|
||||
alt={post.title}
|
||||
width="72px"
|
||||
height="72px"
|
||||
fit="cover"
|
||||
className="shrink-0 rounded-lg"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 text-sm font-bold leading-relaxed transition-colors group-hover/article:text-red">
|
||||
{post.title}
|
||||
</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
|
||||
{post.categoryName && <span>{post.categoryName}</span>}
|
||||
{post.categoryName && post.publishedAt && <span>•</span>}
|
||||
{post.publishedAt && <span>{formatDate(post.publishedAt)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import { CartIcon } from "@/components/ui/icons";
|
||||
|
||||
export function CartButton() {
|
||||
const { itemCount, openPopup } = useCart();
|
||||
@@ -9,12 +10,12 @@ export function CartButton() {
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<span className="hidden sm:inline">سبد خرید</span>
|
||||
<CartIcon className="h-5 w-5" />
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { AboutSlotImage } from "@/components/home/AboutSlotImage";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
export function AboutSanihome() {
|
||||
export function AboutSanihome({
|
||||
image,
|
||||
aspectRatio,
|
||||
}: {
|
||||
image?: StaticImageWithUrl;
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-y border-red-light bg-gradient-to-br from-red-light/40 via-surface to-background py-12 md:py-16">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
@@ -8,7 +15,7 @@ export function AboutSanihome() {
|
||||
<div className="animate-fade-up">
|
||||
<div className="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 className="text-2xl font-bold text-red-dark md:text-3xl">
|
||||
درباره سانیهوم
|
||||
درباره صنیع هوم
|
||||
</h2>
|
||||
<span
|
||||
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
||||
@@ -19,7 +26,7 @@ export function AboutSanihome() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-4 leading-relaxed text-muted">
|
||||
سانیهوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاههای آنلاین لوازم
|
||||
صنیع هوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاههای آنلاین لوازم
|
||||
خانگی در ایران فعالیت میکند. ما با ارائه محصولات اصل از برندهای
|
||||
معتبر جهانی، گارانتی رسمی و خدمات پس از فروش، تجربهای مطمئن برای
|
||||
خرید لوازم خانگی فراهم کردهایم.
|
||||
@@ -43,14 +50,7 @@ export function AboutSanihome() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Placeholder
|
||||
src="/images/about.svg"
|
||||
alt="درباره سانیهوم"
|
||||
width="100%"
|
||||
height="360px"
|
||||
label="560×360"
|
||||
className="w-full rounded-2xl"
|
||||
/>
|
||||
<AboutSlotImage image={image} aspectRatio={aspectRatio} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
export function AboutSlotImage({
|
||||
image,
|
||||
aspectRatio,
|
||||
}: {
|
||||
image?: StaticImageWithUrl;
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!image || failed) {
|
||||
return (
|
||||
<Placeholder
|
||||
src="/images/about.svg"
|
||||
alt="درباره صنیع هوم"
|
||||
width="100%"
|
||||
height="360px"
|
||||
label="560×360"
|
||||
className="w-full rounded-2xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const frame = (
|
||||
// Meshkee slot URLs are rendered as-is; a failed remote image falls back locally.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.titleFa ?? image.titleEn ?? image.subtext ?? "درباره صنیع هوم"}
|
||||
onError={() => setFailed(true)}
|
||||
style={{ aspectRatio: (aspectRatio ?? "14:9").replace(":", " / ") }}
|
||||
className="h-auto w-full rounded-2xl object-cover"
|
||||
/>
|
||||
);
|
||||
|
||||
return image.linkUrl ? <a href={image.linkUrl}>{frame}</a> : frame;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||
import type { BlogSummary } from "@/lib/meshkee";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
@@ -50,8 +51,9 @@ export function BlogCarousel({ posts }: { posts: BlogSummary[] }) {
|
||||
{post.abstract}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-3 block text-xs font-medium text-red">
|
||||
ادامه مطلب ←
|
||||
<span className="mt-3 flex items-center gap-1 text-xs font-medium text-red">
|
||||
ادامه مطلب
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -27,8 +27,8 @@ export function Footer({
|
||||
);
|
||||
|
||||
return (
|
||||
<footer className="border-t border-red-dark/20 bg-red-dark text-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||
<footer className="border-t border-[#dddddd] bg-[#f1f1f1] text-black">
|
||||
<div className="mx-auto max-w-7xl px-4 pt-10 pb-3">
|
||||
<div className="grid gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
@@ -36,8 +36,8 @@ export function Footer({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
|
||||
alt={businessInfo.nameFa || "سانیهوم"}
|
||||
className="h-9 w-auto object-contain"
|
||||
alt={businessInfo.nameFa?.replace(/سانی[ ]?هوم/g, "صنیع هوم") || "صنیع هوم"}
|
||||
className="h-[72px] w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-display text-lg font-bold tracking-tight">
|
||||
@@ -46,16 +46,16 @@ export function Footer({
|
||||
)}
|
||||
</div>
|
||||
{businessInfo?.about && (
|
||||
<p className="text-sm leading-relaxed text-white/70">{businessInfo.about}</p>
|
||||
<p className="text-sm leading-relaxed text-black/70">{businessInfo.about}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-bold">دسترسی سریع</h4>
|
||||
<ul className="space-y-2 text-sm text-white/70">
|
||||
<ul className="space-y-2 text-sm text-black/70">
|
||||
{quickLinks.map((link) => (
|
||||
<li key={link}>
|
||||
<a href="#" className="transition-colors hover:text-white">
|
||||
<a href="#" className="transition-colors hover:text-black">
|
||||
{link}
|
||||
</a>
|
||||
</li>
|
||||
@@ -66,12 +66,12 @@ export function Footer({
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-bold">دستهبندیها</h4>
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-sm text-white/50">هنوز دستهبندیای ثبت نشده است.</p>
|
||||
<p className="text-sm text-black/50">هنوز دستهبندیای ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm text-white/70">
|
||||
<ul className="space-y-2 text-sm text-black/70">
|
||||
{categories.slice(0, 5).map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<a href="#" className="transition-colors hover:text-white">
|
||||
<a href="#" className="transition-colors hover:text-black">
|
||||
{cat.name}
|
||||
</a>
|
||||
</li>
|
||||
@@ -83,7 +83,7 @@ export function Footer({
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-bold">تماس با ما</h4>
|
||||
{hasContactInfo ? (
|
||||
<ul className="space-y-2 text-sm text-white/70">
|
||||
<ul className="space-y-2 text-sm text-black/70">
|
||||
{businessInfo?.addresses.map((address) => <li key={address}>{address}</li>)}
|
||||
{businessInfo?.phoneNumbers.map((phone) => (
|
||||
<li key={phone}>تلفن: {phone}</li>
|
||||
@@ -91,7 +91,7 @@ export function Footer({
|
||||
{businessInfo?.emails.map((email) => <li key={email}>ایمیل: {email}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-white/50">هنوز اطلاعات تماس ثبت نشده است.</p>
|
||||
<p className="text-sm text-black/50">هنوز اطلاعات تماس ثبت نشده است.</p>
|
||||
)}
|
||||
{socialEntries.length > 0 && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
@@ -101,7 +101,7 @@ export function Footer({
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-display flex h-8 w-8 items-center justify-center rounded-full border border-white/20 text-[10px] font-bold transition-colors hover:border-white hover:bg-white/10"
|
||||
className="font-display flex h-8 w-8 items-center justify-center rounded-full border border-black/20 text-[10px] font-bold transition-colors hover:border-black hover:bg-black/5"
|
||||
>
|
||||
{socialLabels[key] ?? key.slice(0, 2).toUpperCase()}
|
||||
</a>
|
||||
@@ -111,11 +111,48 @@ export function Footer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-white/10 pt-6 text-xs text-white/50 md:flex-row">
|
||||
<p>© ۱۴۰۵ سانیهوم — تمامی حقوق محفوظ است.</p>
|
||||
<p className="font-display tracking-wide uppercase">
|
||||
Designed for Sanihome
|
||||
</p>
|
||||
{businessInfo?.trustBadges && businessInfo.trustBadges.length > 0 ? (
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-4 border-t border-black/10 pt-6">
|
||||
{businessInfo.trustBadges.map((badge) => (
|
||||
<div
|
||||
key={badge.kind}
|
||||
className="trust-badge [&_img]:max-h-16 [&_a]:inline-block"
|
||||
dangerouslySetInnerHTML={{ __html: badge.embedHtml }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-black/10 pt-6 text-xs text-black/50 md:flex-row">
|
||||
<p>© ۱۴۰۵ صنیع هوم — تمامی حقوق محفوظ است.</p>
|
||||
<a
|
||||
href="https://meshkee.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
dir="ltr"
|
||||
className="group relative inline-flex items-center gap-2.5 px-2.5 py-1.5 text-black/50 transition-colors duration-300 hover:text-black"
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||
<span className="absolute top-0 left-0 h-full w-px origin-top scale-y-0 bg-black/40 transition-transform duration-[280ms] delay-[420ms] group-hover:scale-y-100 group-hover:delay-0" />
|
||||
<span className="absolute bottom-0 left-0 h-px w-full origin-left scale-x-0 bg-black/40 transition-transform duration-[280ms] delay-[280ms] group-hover:scale-x-100 group-hover:delay-[140ms]" />
|
||||
<span className="absolute top-0 right-0 h-full w-px origin-bottom scale-y-0 bg-black/40 transition-transform duration-[280ms] delay-[140ms] group-hover:scale-y-100 group-hover:delay-[280ms]" />
|
||||
<span className="absolute top-0 left-0 h-px w-full origin-right scale-x-0 bg-black/40 transition-transform duration-[280ms] delay-0 group-hover:scale-x-100 group-hover:delay-[420ms]" />
|
||||
</span>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/images/meshkee-logo-white.png"
|
||||
alt="Meshkee"
|
||||
width={42}
|
||||
height={42}
|
||||
className="relative block h-[42px] w-auto shrink-0 object-contain opacity-60 brightness-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
/>
|
||||
<span className="relative flex min-h-[42px] flex-col justify-center leading-[1.25]">
|
||||
<span className="text-[11px]">Designed And Developed By</span>
|
||||
<span className="text-[13px] font-semibold text-black/70 transition-colors duration-300 group-hover:text-black">
|
||||
Meshkee E-Commerce Team
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -5,11 +5,12 @@ import { createPortal } from "react-dom";
|
||||
import type { Category, CategoryNode, CategoryTreeGroup } from "@/lib/meshkee";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { CartButton } from "@/components/cart/CartButton";
|
||||
import { ChevronLeftIcon, CloseIcon, MenuIcon, UserIcon } from "@/components/ui/icons";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
|
||||
const navLinks = [
|
||||
{ label: "فروشگاه", href: "/store-items" },
|
||||
{ label: "فروش ویژه", href: "#" },
|
||||
{ label: "فروش ویژه", href: "/store-items?special=sale" },
|
||||
{ label: "درباره ما", href: "#" },
|
||||
{ label: "سوالات متداول", href: "#" },
|
||||
{ label: "تماس با ما", href: "/contact" },
|
||||
@@ -23,11 +24,11 @@ function Logo({ src, large = false }: { src?: string | null; large?: boolean })
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src || "/images/logo.png"}
|
||||
alt="سانیهوم"
|
||||
alt="صنیع هوم"
|
||||
className={
|
||||
large
|
||||
? "h-[42px] w-auto object-contain md:h-[46px]"
|
||||
: "h-20 w-auto object-contain md:h-24"
|
||||
? "h-[65px] w-auto object-contain"
|
||||
: "h-[65px] w-auto object-contain"
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
@@ -107,9 +108,9 @@ function MobileDrawer({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن منو"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-xl text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||
>
|
||||
✕
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +148,7 @@ function MobileDrawer({
|
||||
{level2.map((node) => (
|
||||
<li key={node.id}>
|
||||
<a
|
||||
href={`/products?categoryId=${node.id}`}
|
||||
href={`/store-items?categoryId=${node.id}`}
|
||||
className="block rounded-lg px-3 py-1.5 font-medium text-foreground transition-colors hover:bg-red-light hover:text-red"
|
||||
>
|
||||
{node.nameFa}
|
||||
@@ -157,7 +158,7 @@ function MobileDrawer({
|
||||
{node.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<a
|
||||
href={`/products?categoryId=${child.id}`}
|
||||
href={`/store-items?categoryId=${child.id}`}
|
||||
className="block rounded-lg px-3 py-1 text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||
>
|
||||
{child.nameFa}
|
||||
@@ -188,7 +189,7 @@ function MobileDrawer({
|
||||
href={LOGIN_URL}
|
||||
className="flex items-center gap-2 px-5 py-4 text-right font-medium transition-colors hover:bg-red-light/40 hover:text-red"
|
||||
>
|
||||
<span aria-hidden>👤</span>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
ورود
|
||||
</a>
|
||||
</nav>
|
||||
@@ -225,15 +226,15 @@ export function Header({
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-surface/95 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="grid grid-cols-3 items-center gap-4 py-4 min-[1200px]:flex min-[1200px]:justify-between">
|
||||
<div className="grid grid-cols-3 items-center gap-4 py-2 min-[1200px]:flex min-[1200px]:justify-between">
|
||||
<div className="flex items-center justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
aria-label="منو"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-border text-xl transition-colors hover:border-red hover:text-red min-[1200px]:hidden"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-border transition-colors hover:border-red hover:text-red min-[1200px]:hidden"
|
||||
>
|
||||
<span aria-hidden>☰</span>
|
||||
<MenuIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="hidden min-[1200px]:block">
|
||||
<Logo src={logoUrl} />
|
||||
@@ -250,14 +251,27 @@ export function Header({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 text-sm">
|
||||
<a
|
||||
href={LOGIN_URL}
|
||||
className="hidden items-center gap-1.5 text-muted transition-colors hover:text-red sm:flex"
|
||||
>
|
||||
<span>👤</span>
|
||||
<span>ورود</span>
|
||||
</a>
|
||||
<CartButton />
|
||||
<div className="hidden items-center gap-2 rounded-2xl bg-surface p-1.5 sm:flex">
|
||||
<CartButton />
|
||||
<span className="h-8 w-px bg-border" aria-hidden />
|
||||
<a
|
||||
href={LOGIN_URL}
|
||||
className="flex items-center gap-2 rounded-xl py-1 pr-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red text-white">
|
||||
<UserIcon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-right leading-tight">
|
||||
<span className="block text-sm font-bold whitespace-nowrap text-foreground">
|
||||
حساب کاربری
|
||||
</span>
|
||||
<span className="block text-xs whitespace-nowrap text-muted">ورود/ثبتنام</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
<CartButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -279,7 +293,7 @@ export function Header({
|
||||
aria-haspopup="true"
|
||||
className="flex shrink-0 items-center gap-2 rounded-lg bg-red px-4 py-2 font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
<span aria-hidden>☰</span>
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
دستهبندیها
|
||||
</button>
|
||||
|
||||
@@ -293,10 +307,11 @@ export function Header({
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/products"
|
||||
className="text-xs font-medium text-red hover:text-red-dark"
|
||||
href="/store-items"
|
||||
className="flex items-center gap-1 text-xs font-medium text-red hover:text-red-dark"
|
||||
>
|
||||
مشاهده همه ←
|
||||
مشاهده همه
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -307,7 +322,7 @@ export function Header({
|
||||
{level2.map((node) => (
|
||||
<div key={node.id} className="mb-4 break-inside-avoid">
|
||||
<a
|
||||
href={`/products?categoryId=${node.id}`}
|
||||
href={`/store-items?categoryId=${node.id}`}
|
||||
className="text-sm font-bold text-red-dark transition-colors hover:text-red"
|
||||
>
|
||||
{node.nameFa}
|
||||
@@ -317,7 +332,7 @@ export function Header({
|
||||
{node.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<a
|
||||
href={`/products?categoryId=${child.id}`}
|
||||
href={`/store-items?categoryId=${child.id}`}
|
||||
className="text-sm text-muted transition-colors hover:text-red"
|
||||
>
|
||||
{child.nameFa}
|
||||
@@ -336,7 +351,7 @@ export function Header({
|
||||
{categories.slice(0, 4).map((cat) => (
|
||||
<a
|
||||
key={cat.id}
|
||||
href={`/products?categoryId=${cat.id}`}
|
||||
href={`/store-items?categoryId=${cat.id}`}
|
||||
className="rounded-lg bg-red-light/50 px-3 py-2 text-center text-xs font-medium text-red-dark transition-colors hover:bg-red-light"
|
||||
>
|
||||
{cat.name}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { formatPrice } from "@/data/sample";
|
||||
import type { Product } from "@/lib/meshkee";
|
||||
|
||||
export function SearchBar() {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Product[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -15,8 +17,6 @@ export function SearchBar() {
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length <= 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function SearchBar() {
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) window.location.href = `/products?q=${encodeURIComponent(trimmed)}`;
|
||||
if (trimmed) router.push(`/store-items?q=${encodeURIComponent(trimmed)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -64,7 +64,14 @@ export function SearchBar() {
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setQuery(value);
|
||||
if (value.trim().length <= 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onFocus={() => results.length > 0 && setOpen(true)}
|
||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||
placeholder="جستجوی محصول، برند یا دستهبندی..."
|
||||
@@ -89,7 +96,7 @@ export function SearchBar() {
|
||||
{results.map((product) => (
|
||||
<li key={product.id}>
|
||||
<a
|
||||
href={`/products/${product.slug}`}
|
||||
href={`/store-items/${product.slug}`}
|
||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-red-light/40"
|
||||
>
|
||||
<Placeholder
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,87 @@ 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 { productHref, type ProductGroup, type StaticImageWithUrl } from "@/lib/meshkee";
|
||||
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 under the title. Skip it if the key has no letters at
|
||||
// all (e.g. it fell back to a bare numeric id).
|
||||
// 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
|
||||
@@ -54,33 +119,37 @@ export function StoreSpecialCarousels({
|
||||
{visibleGroups.map((group, i) => (
|
||||
<Fragment key={group.id}>
|
||||
{i > 0 && banners[(i - 1) % banners.length]}
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title={group.title}
|
||||
enSubtitle={englishLabelFromKey(group.key)}
|
||||
action="مشاهده همه"
|
||||
actionHref={`/products?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>
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,11 +33,8 @@ function Lightbox({
|
||||
onIndexChange: (i: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const hasMultiple = images.length > 1;
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -52,8 +49,6 @@ function Lightbox({
|
||||
};
|
||||
}, [index, images.length, onClose, onIndexChange]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[120] flex flex-col bg-black/95" onClick={onClose}>
|
||||
<button
|
||||
@@ -77,7 +72,7 @@ function Lightbox({
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute top-1/2 right-4 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
<ChevronIcon direction="prev" className="h-6 w-6" />
|
||||
<ChevronIcon direction="next" className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -85,7 +80,7 @@ function Lightbox({
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute top-1/2 left-4 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
<ChevronIcon direction="next" className="h-6 w-6" />
|
||||
<ChevronIcon direction="prev" className="h-6 w-6" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -164,7 +159,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute top-1/2 right-3 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-surface/90 text-foreground shadow-sm transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
<ChevronIcon direction="prev" className="h-5 w-5" />
|
||||
<ChevronIcon direction="next" className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -172,7 +167,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute top-1/2 left-3 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-surface/90 text-foreground shadow-sm transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
<ChevronIcon direction="next" className="h-5 w-5" />
|
||||
<ChevronIcon direction="prev" className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -119,8 +119,8 @@ export function ProductTabs({
|
||||
const activeLabel = TABS.find((t) => t.id === active)?.label ?? "";
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="flex items-center gap-8 border-b border-border">
|
||||
<section className="mt-10 overflow-hidden rounded-2xl border border-border bg-surface">
|
||||
<div className="flex flex-wrap items-center gap-x-7 border-b border-border px-5 pt-5 md:gap-x-10 md:px-7">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -138,7 +138,7 @@ export function ProductTabs({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-8">
|
||||
<div className="p-5 md:p-7">
|
||||
<h2 className="mb-5 flex items-center gap-2 text-lg font-bold">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red" aria-hidden />
|
||||
{activeLabel}
|
||||
@@ -148,13 +148,27 @@ export function ProductTabs({
|
||||
(technicalInfo.length === 0 ? (
|
||||
<EmptyState message="هنوز مشخصات فنیای برای این محصول ثبت نشده است." />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{technicalInfo.map((field) => (
|
||||
<div key={field.label} className="rounded-xl bg-background p-4">
|
||||
<p className="mb-1 text-sm font-bold">{field.label} :</p>
|
||||
<p className="text-sm text-muted">{field.value}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-right text-sm">
|
||||
<tbody>
|
||||
{technicalInfo.map((field, index) => (
|
||||
<tr
|
||||
key={`${field.label}-${index}`}
|
||||
className="border-b border-border last:border-b-0 odd:bg-background/60"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-[30%] px-4 py-3.5 align-top font-bold text-foreground md:w-[22%] md:px-5"
|
||||
>
|
||||
{field.label}
|
||||
</th>
|
||||
<td className="px-4 py-3.5 leading-7 text-muted md:px-5">
|
||||
{field.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -170,6 +184,6 @@ export function ProductTabs({
|
||||
|
||||
{active === "comments" && <CommentsPanel entityId={entityId} initialComments={initialComments} />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Children, useRef } from "react";
|
||||
import { Children, useRef, useState } from "react";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { FreeMode } from "swiper/modules";
|
||||
import type { Swiper as SwiperClass } from "swiper";
|
||||
@@ -10,6 +10,7 @@ import "swiper/css/free-mode";
|
||||
type CarouselProps = {
|
||||
children: React.ReactNode;
|
||||
ariaLabel: string;
|
||||
slideClassName?: string;
|
||||
};
|
||||
|
||||
function ChevronLeftIcon() {
|
||||
@@ -40,11 +41,12 @@ function ChevronRightIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
export function Carousel({ children, ariaLabel, slideClassName }: CarouselProps) {
|
||||
const swiperRef = useRef<SwiperClass | null>(null);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
|
||||
const btnClass =
|
||||
"absolute top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/40 bg-white/40 text-red shadow-sm backdrop-blur-md transition-colors hover:border-red/40 hover:bg-white/60 hover:text-red-dark";
|
||||
"absolute top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/40 bg-white/40 text-red shadow-sm backdrop-blur-md transition-colors hover:border-red/40 hover:bg-white/60 hover:text-red-dark disabled:pointer-events-none disabled:opacity-0";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -52,6 +54,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
type="button"
|
||||
onClick={() => swiperRef.current?.slidePrev()}
|
||||
aria-label="قبلی"
|
||||
disabled={isLocked}
|
||||
className={`${btnClass} right-1`}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
@@ -60,6 +63,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
type="button"
|
||||
onClick={() => swiperRef.current?.slideNext()}
|
||||
aria-label="بعدی"
|
||||
disabled={isLocked}
|
||||
className={`${btnClass} left-1`}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
@@ -68,10 +72,17 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
modules={[FreeMode]}
|
||||
onSwiper={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
setIsLocked(swiper.isLocked);
|
||||
}}
|
||||
onLock={(swiper) => {
|
||||
swiper.slideTo(0, 0);
|
||||
setIsLocked(true);
|
||||
}}
|
||||
onUnlock={() => setIsLocked(false)}
|
||||
dir="rtl"
|
||||
slidesPerView="auto"
|
||||
spaceBetween={16}
|
||||
watchOverflow
|
||||
freeMode={{ enabled: true, momentum: true, sticky: true }}
|
||||
grabCursor
|
||||
role="region"
|
||||
@@ -83,7 +94,12 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
// wrapper height — resolves per-slide instead of stretching to match
|
||||
// the tallest sibling. height:auto (inline, so it wins) lets normal
|
||||
// flex stretch equalize card heights across a row.
|
||||
<SwiperSlide style={{ width: "auto", height: "auto" }}>{child}</SwiperSlide>
|
||||
<SwiperSlide
|
||||
className={slideClassName}
|
||||
style={slideClassName ? { height: "auto" } : { width: "auto", height: "auto" }}
|
||||
>
|
||||
{child}
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Breadcrumb } from "./Breadcrumb";
|
||||
|
||||
type PageShellBreadcrumbItem = { label: string; href?: string };
|
||||
|
||||
export function PageShell({
|
||||
breadcrumbItems,
|
||||
children,
|
||||
}: {
|
||||
breadcrumbItems: PageShellBreadcrumbItem[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Placeholder } from "./Placeholder";
|
||||
import { formatPrice } from "@/data/sample";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
type ProductCardProps = {
|
||||
name: string;
|
||||
@@ -16,10 +12,6 @@ type ProductCardProps = {
|
||||
href?: string;
|
||||
fluid?: boolean;
|
||||
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;
|
||||
slug?: string;
|
||||
};
|
||||
@@ -34,27 +26,7 @@ export function ProductCard({
|
||||
variant = "default",
|
||||
href,
|
||||
fluid = false,
|
||||
contactPhone,
|
||||
variantId,
|
||||
slug,
|
||||
}: 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 = (
|
||||
<>
|
||||
<div className="relative p-[10px]">
|
||||
@@ -71,19 +43,21 @@ export function ProductCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 pb-0">
|
||||
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase">
|
||||
{brand}
|
||||
</p>
|
||||
<h3 className="mb-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium">
|
||||
<div className="px-4 pb-4">
|
||||
{brand && (
|
||||
<p className="font-display mb-1 line-clamp-2 h-8 text-[11px] leading-4 tracking-wide text-muted uppercase">
|
||||
{brand}
|
||||
</p>
|
||||
)}
|
||||
<h3 className="mb-2 line-clamp-2 h-11 text-sm leading-6 font-medium">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-12 flex-col items-end justify-end space-y-1 text-left">
|
||||
{oldPrice != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted line-through">{formatPrice(oldPrice)}</span>
|
||||
<span className="text-sm text-muted line-through">{formatPrice(oldPrice)}</span>
|
||||
{discount != null && (
|
||||
<span className="rounded-md bg-green-50 px-1.5 py-0.5 text-[11px] font-bold text-green-700">
|
||||
<span className="rounded-md bg-red px-1.5 py-0.5 text-[11px] font-bold text-white">
|
||||
{discount}٪
|
||||
</span>
|
||||
)}
|
||||
@@ -115,40 +89,6 @@ export function ProductCard({
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||
|
||||
type SectionTitleProps = {
|
||||
title: string;
|
||||
enTitle?: string;
|
||||
// Shown on its own line under the title (not inline like enTitle), at 50%
|
||||
// opacity — used for the homepage's dynamic store-specials carousels.
|
||||
enSubtitle?: string;
|
||||
subtitle?: string;
|
||||
action?: string;
|
||||
actionHref?: string;
|
||||
};
|
||||
|
||||
export function SectionTitle({
|
||||
title,
|
||||
enTitle,
|
||||
enSubtitle,
|
||||
subtitle,
|
||||
action,
|
||||
actionHref,
|
||||
}: SectionTitleProps) {
|
||||
export function SectionTitle({ title, enTitle, subtitle, action, actionHref }: SectionTitleProps) {
|
||||
return (
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 className="text-xl font-bold text-red-dark md:text-2xl">{title}</h2>
|
||||
@@ -32,15 +24,6 @@ export function SectionTitle({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{enSubtitle && (
|
||||
<p
|
||||
className="font-display mt-1 text-xs font-semibold tracking-wide text-foreground uppercase opacity-50 md:text-sm"
|
||||
lang="en"
|
||||
dir="ltr"
|
||||
>
|
||||
{enSubtitle}
|
||||
</p>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
||||
)}
|
||||
@@ -48,9 +31,10 @@ export function SectionTitle({
|
||||
{action && actionHref && (
|
||||
<a
|
||||
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}
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
type IconProps = { className?: string };
|
||||
|
||||
// Shared light/outline icon set — thin stroke (1.75), rounded caps/joins,
|
||||
// 24x24 viewBox, matching the chevrons already used in Carousel/ProductGallery.
|
||||
// Prefer these over emoji anywhere in the UI.
|
||||
|
||||
export function MenuIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 7h16M4 12h16M4 17h16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.75" />
|
||||
<path
|
||||
d="M4.75 19.25c0-3.45 3.24-6.25 7.25-6.25s7.25 2.8 7.25 6.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CartIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M3.5 4.5h1.6l1.1 2M6.2 6.5l1.65 7.3a1.5 1.5 0 0 0 1.46 1.17h6.9a1.5 1.5 0 0 0 1.46-1.15l1.23-5.32H6.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="10" cy="19.5" r="1.15" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="16.5" cy="19.5" r="1.15" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M6 6l12 12M18 6 6 18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M14.5 6.5 9 12l5.5 5.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+93
-8
@@ -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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -64,6 +89,8 @@ export type BusinessInfo = {
|
||||
aparat?: string;
|
||||
};
|
||||
addresses: string[];
|
||||
metaTags?: BusinessMetaTag[];
|
||||
trustBadges?: BusinessTrustBadge[];
|
||||
};
|
||||
|
||||
export async function getBusinessInfo(): Promise<BusinessInfo | null> {
|
||||
@@ -145,6 +172,7 @@ export type Product = {
|
||||
// brand-groups, store-items — lets "add to cart" skip a variant picker).
|
||||
variantId?: string;
|
||||
stockQuantity?: number;
|
||||
categoryId?: string;
|
||||
};
|
||||
|
||||
function normalizeProduct(raw: Record<string, unknown>): Product {
|
||||
@@ -195,6 +223,12 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
||||
variantId: firstVariant?.id != null ? String(firstVariant.id) : undefined,
|
||||
stockQuantity:
|
||||
firstVariant?.stockQuantity != null ? Number(firstVariant.stockQuantity) : undefined,
|
||||
categoryId:
|
||||
raw.categoryId != null
|
||||
? String(raw.categoryId)
|
||||
: (raw.category as Record<string, unknown> | undefined)?.id != null
|
||||
? String((raw.category as Record<string, unknown>).id)
|
||||
: undefined,
|
||||
price: numericPrice,
|
||||
oldPrice: numericOldPrice,
|
||||
discount,
|
||||
@@ -211,11 +245,12 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
||||
}
|
||||
|
||||
export function productHref(product: Product): string {
|
||||
if (product.slug) return `/products/${product.slug}`;
|
||||
if (product.variantId) return `/store-items/${product.variantId}`;
|
||||
if (product.slug) return `/store-items/${product.slug}`;
|
||||
// No slug (store-specials/brand-groups/store-items don't return one) — route
|
||||
// through the id-based resolver, which redirects to the canonical slug page.
|
||||
if (product.id) return `/products/id/${product.id}`;
|
||||
return `/products?q=${encodeURIComponent(product.name)}`;
|
||||
if (product.id) return `/store-items/${product.id}`;
|
||||
return `/store-items?q=${encodeURIComponent(product.name)}`;
|
||||
}
|
||||
|
||||
export async function getProducts(params?: {
|
||||
@@ -344,13 +379,47 @@ export async function getProductStoreVariants(productId: string): Promise<Produc
|
||||
export type TechnicalInfoField = { label: string; value: string };
|
||||
|
||||
export async function getProductTechnicalInfo(slug: string): Promise<TechnicalInfoField[]> {
|
||||
const res = await apiGet<{ form: unknown; values: Record<string, unknown>[] }>(
|
||||
const res = await apiGet<{
|
||||
form: { fields?: Record<string, unknown>[] } | null;
|
||||
values: Record<string, unknown>[];
|
||||
}>(
|
||||
`/tenants/${DOMAIN}/products/${slug}/technical-info`,
|
||||
);
|
||||
return (res?.values ?? []).map((v) => ({
|
||||
label: String(v.label ?? v.name ?? v.key ?? ""),
|
||||
value: String(v.value ?? ""),
|
||||
}));
|
||||
const submittedValues = res?.values ?? [];
|
||||
const valuesByFieldId = new Map(
|
||||
submittedValues
|
||||
.filter((value) => value.fieldId != null)
|
||||
.map((value) => [String(value.fieldId), value]),
|
||||
);
|
||||
const valuesByFieldKey = new Map(
|
||||
submittedValues
|
||||
.filter((value) => value.fieldKey != null)
|
||||
.map((value) => [String(value.fieldKey), value]),
|
||||
);
|
||||
|
||||
return [...(res?.form?.fields ?? [])]
|
||||
.sort((a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0))
|
||||
.map((field) => {
|
||||
const fieldId = String(field.id ?? "");
|
||||
const fieldKey = String(field.key ?? "");
|
||||
const submitted = valuesByFieldId.get(fieldId) ?? valuesByFieldKey.get(fieldKey);
|
||||
if (!submitted) return null;
|
||||
|
||||
const options = (field.options as Record<string, unknown>[] | undefined) ?? [];
|
||||
const optionLabel = (optionId: unknown) => {
|
||||
const option = options.find((item) => String(item.id ?? "") === String(optionId ?? ""));
|
||||
return option ? String(option.label ?? option.value ?? option.name ?? "") : "";
|
||||
};
|
||||
const selectedOptionIds = Array.isArray(submitted.optionIds) ? submitted.optionIds : [];
|
||||
const value =
|
||||
String(submitted.textValue ?? submitted.value ?? "").trim() ||
|
||||
optionLabel(submitted.optionId) ||
|
||||
selectedOptionIds.map(optionLabel).filter(Boolean).join("، ");
|
||||
const label = String(field.label ?? field.labelFa ?? field.name ?? field.key ?? "").trim();
|
||||
|
||||
return label && value ? { label, value } : null;
|
||||
})
|
||||
.filter((field): field is TechnicalInfoField => field !== null);
|
||||
}
|
||||
|
||||
export type StoreItem = {
|
||||
@@ -440,6 +509,7 @@ export type BlogSummary = {
|
||||
image?: string | null;
|
||||
publishedAt?: string;
|
||||
categoryName?: string;
|
||||
categoryId?: string;
|
||||
};
|
||||
|
||||
function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
|
||||
@@ -451,6 +521,7 @@ function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
|
||||
image: (raw.titleImageUrl as string | undefined) ?? null,
|
||||
publishedAt: raw.publishedAt as string | undefined,
|
||||
categoryName: raw.categoryName as string | undefined,
|
||||
categoryId: raw.categoryId != null ? String(raw.categoryId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -495,6 +566,10 @@ export type ProductGroup = {
|
||||
items: Product[];
|
||||
};
|
||||
|
||||
export function isSpecialSaleGroup(group: Pick<ProductGroup, "title" | "key">): boolean {
|
||||
return /فروش\s*ویژه|special[\s_-]*sale/i.test(`${group.title} ${group.key}`);
|
||||
}
|
||||
|
||||
function normalizeProductGroups(items: Record<string, unknown>[] | undefined): ProductGroup[] {
|
||||
return (items ?? [])
|
||||
.map((group) => ({
|
||||
@@ -583,6 +658,7 @@ export const STATIC_IMAGE_KEYS = {
|
||||
twoBanner: "two-banner",
|
||||
threeBannerRow: "three-banner-row",
|
||||
featuredCategories: "featured-categories",
|
||||
aboutUsImage: "about-us-image",
|
||||
} as const;
|
||||
|
||||
export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
@@ -622,6 +698,15 @@ export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
itemCount: null,
|
||||
recommendedWidth: 300,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.aboutUsImage,
|
||||
label: "Homepage about-us image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "14:9",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 560,
|
||||
},
|
||||
];
|
||||
|
||||
export async function getStaticImageSlots(): Promise<StaticImageSlot[]> {
|
||||
|
||||
Reference in New Issue
Block a user