feat: unify catalog and improve detail pages
This commit is contained in:
+59
-7
@@ -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
|
||||
@@ -227,6 +232,44 @@ 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
|
||||
@@ -282,14 +325,24 @@ styled like the site's other primary buttons (filled `bg-red`, white text,
|
||||
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
|
||||
|
||||
@@ -346,9 +399,8 @@ un-draw is the distinctive part, not just "a border that appears."
|
||||
| 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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ 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" },
|
||||
@@ -148,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}
|
||||
@@ -158,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}
|
||||
@@ -307,7 +307,7 @@ export function Header({
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/products"
|
||||
href="/store-items"
|
||||
className="flex items-center gap-1 text-xs font-medium text-red hover:text-red-dark"
|
||||
>
|
||||
مشاهده همه
|
||||
@@ -322,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}
|
||||
@@ -332,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}
|
||||
@@ -351,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
|
||||
|
||||
@@ -6,7 +6,12 @@ import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||
import { TwoBanners } from "@/components/home/TwoBanners";
|
||||
import { ThreeBanners } from "@/components/home/ThreeBanners";
|
||||
import { SpecialSaleCountdown } from "@/components/home/SpecialSaleCountdown";
|
||||
import { productHref, type ProductGroup, type StaticImageWithUrl } from "@/lib/meshkee";
|
||||
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
|
||||
@@ -21,10 +26,6 @@ function englishLabelFromKey(key: string): string | undefined {
|
||||
return /[a-zA-Z]/.test(label) ? label.toUpperCase() : undefined;
|
||||
}
|
||||
|
||||
function isSpecialSaleGroup(group: ProductGroup): boolean {
|
||||
return /فروش\s*ویژه|special[\s_-]*sale/i.test(`${group.title} ${group.key}`);
|
||||
}
|
||||
|
||||
function SpecialSaleSection({ group }: { group: ProductGroup }) {
|
||||
return (
|
||||
<section className="bg-red py-6 md:py-8">
|
||||
@@ -48,7 +49,7 @@ function SpecialSaleSection({ group }: { group: ProductGroup }) {
|
||||
<p className="mt-1.5 mb-4 text-xs text-white/80 md:text-sm">تا پایان فروش ویژه</p>
|
||||
<SpecialSaleCountdown />
|
||||
<a
|
||||
href={`/products?specialId=${group.id}`}
|
||||
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"
|
||||
>
|
||||
مشاهده همه
|
||||
@@ -127,7 +128,7 @@ export function StoreSpecialCarousels({
|
||||
title={group.title}
|
||||
enTitle={englishLabelFromKey(group.key)}
|
||||
action="مشاهده همه"
|
||||
actionHref={`/products?specialId=${group.id}`}
|
||||
actionHref={`/store-items?specialId=${group.id}`}
|
||||
/>
|
||||
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
||||
{group.items.map((product) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+56
-8
@@ -145,6 +145,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 +196,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 +218,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 +352,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 +482,7 @@ export type BlogSummary = {
|
||||
image?: string | null;
|
||||
publishedAt?: string;
|
||||
categoryName?: string;
|
||||
categoryId?: string;
|
||||
};
|
||||
|
||||
function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
|
||||
@@ -451,6 +494,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 +539,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) => ({
|
||||
|
||||
Reference in New Issue
Block a user