diff --git a/CONTEXT.md b/CONTEXT.md index b6dfcac..992bae3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 `

`, 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 | diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 884a6e9..0fe1eb4 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -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( + /()/gi, + '
$1
', + ); +} + 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 ( -
- + +
+
+
+ {post.categoryName && {post.categoryName}} + {post.publishedAt && ( + <> + • + {formatDate(post.publishedAt)} + + )} + {post.author && ( + <> + • + {post.author} + + )} +
-
- {post.categoryName && {post.categoryName}} - {post.publishedAt && ( - <> - • - {formatDate(post.publishedAt)} - - )} - {post.author && ( - <> - • - {post.author} - - )} +

+ {post.title} +

+ +
+ +
+ + → بازگشت به وبلاگ + +
+
+ +
- -

- {post.title} -

- -
+ ); } diff --git a/src/app/globals.css b/src/app/globals.css index 43fe0ef..e81eec8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 2b7ebab..161b458 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -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 ( -
- - -
-
- -
- - - {product.brand && ( -

- {product.brand} -

- )} -

{product.name}

- - } - 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] ?? ""} - /> -
- - - - -
- ); + permanentRedirect(`/store-items/${slug}`); } diff --git a/src/app/products/id/[id]/page.tsx b/src/app/products/id/[id]/page.tsx index cbc0eca..d9491ce 100644 --- a/src/app/products/id/[id]/page.tsx +++ b/src/app/products/id/[id]/page.tsx @@ -11,5 +11,5 @@ export default async function ProductByIdPage({ if (!product?.slug) notFound(); - redirect(`/products/${product.slug}`); + redirect(`/store-items/${product.slug}`); } diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index 8910c16..df4434e 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -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>; }) { 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 ( -
- -

{group?.title ?? "محصولات"}

- {items.length === 0 ? ( - - ) : ( -
- {items.map((product) => ( - - ))} -
- )} -
- ); + 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 ( -
- - -

- {params.q ? `نتایج جستجو برای «${params.q}»` : "محصولات"} -

- - {items.length === 0 ? ( - - ) : ( - <> -
- {items.map((product) => ( - - ))} -
- - {totalPages > 1 && ( -
- {page > 1 ? ( - - قبلی - - ) : ( - - قبلی - - )} - - صفحه {page} از {totalPages} - - {page < totalPages ? ( - - بعدی - - ) : ( - - بعدی - - )} -
- )} - - )} -
- ); + const suffix = query.toString(); + permanentRedirect(`/store-items${suffix ? `?${suffix}` : ""}`); } diff --git a/src/app/store-items/[id]/page.tsx b/src/app/store-items/[id]/page.tsx index 9f6c587..08c7f97 100644 --- a/src/app/store-items/[id]/page.tsx +++ b/src/app/store-items/[id]/page.tsx @@ -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 ( -
- + ]} + >
@@ -101,6 +101,6 @@ export default async function StoreItemDetailPage({ excludeProductId={product.id} contactPhone={contactPhone} /> -
+ ); } diff --git a/src/app/store-items/page.tsx b/src/app/store-items/page.tsx index 3475e6f..2efcb1a 100644 --- a/src/app/store-items/page.tsx +++ b/src/app/store-items/page.tsx @@ -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(); + 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({ ]} /> -

- {params.q ? `نتایج جستجو برای «${params.q}»` : "فروشگاه"} -

+ {params.q && ( +

+ نتایج جستجو برای «{params.q}» +

+ )} - {items.length === 0 ? ( - - ) : ( - <> -
- {items.map((item) => ( +
+ + +
+ {specialMode && ( +

+ {specialGroup?.title ?? "فروش ویژه"} +

+ )} + + {(specialMode ? specialItems : storeResult.items).length === 0 ? ( + + ) : specialMode ? ( +
+ {specialItems.map((product) => ( + + ))} +
+ ) : ( + <> +
+ {storeResult.items.map((item) => ( ))}
- {totalPages > 1 && ( + {!specialMode && totalPages > 1 && (
{page > 1 ? ( )} - - )} + + )} +
+
); } diff --git a/src/components/blog/SimilarArticles.tsx b/src/components/blog/SimilarArticles.tsx new file mode 100644 index 0000000..073876f --- /dev/null +++ b/src/components/blog/SimilarArticles.tsx @@ -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 ( + + ); +} diff --git a/src/components/home/Header.tsx b/src/components/home/Header.tsx index 6594d3d..9e650c0 100644 --- a/src/components/home/Header.tsx +++ b/src/components/home/Header.tsx @@ -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) => (
  • {node.nameFa} @@ -158,7 +158,7 @@ function MobileDrawer({ {node.children.map((child) => (
  • {child.nameFa} @@ -307,7 +307,7 @@ export function Header({

  • مشاهده همه @@ -322,7 +322,7 @@ export function Header({ {level2.map((node) => (
    {node.nameFa} @@ -332,7 +332,7 @@ export function Header({ {node.children.map((child) => (
  • {child.nameFa} @@ -351,7 +351,7 @@ export function Header({ {categories.slice(0, 4).map((cat) => ( {cat.name} diff --git a/src/components/home/SearchBar.tsx b/src/components/home/SearchBar.tsx index 34c7b1f..c7229b8 100644 --- a/src/components/home/SearchBar.tsx +++ b/src/components/home/SearchBar.tsx @@ -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([]); 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() { 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) => (
  • @@ -48,7 +49,7 @@ function SpecialSaleSection({ group }: { group: ProductGroup }) {

    تا پایان فروش ویژه

    مشاهده همه @@ -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}`} /> {group.items.map((product) => ( diff --git a/src/components/product/ProductFilters.tsx b/src/components/product/ProductFilters.tsx new file mode 100644 index 0000000..ef4eda9 --- /dev/null +++ b/src/components/product/ProductFilters.tsx @@ -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 ( + + + + ); +} + +function FilterFields({ + categories, + values, +}: { + categories: ProductFilterCategory[]; + values: ProductFilterValues; +}) { + const categoryListRef = useRef(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( + `[data-category-id="${CSS.escape(activeParent.id)}"]`, + ); + if (accordion) accordion.open = true; + }, [categories, values.categoryId]); + + return ( +
    + {values.q && } + {values.specialId && } + {values.special && } + +
    + + دسته‌بندی + + + +
    + + {categories.map((category) => ( + (category.children ?? []).length > 0 ? ( +
    + + + + + + +
    + {(category.children ?? []).map((child) => ( + + ))} +
    +
    + ) : ( + + ) + ))} +
    +
    + +
    + + محدوده قیمت + + + +
    + + +
    +

    مبالغ به تومان هستند.

    +
    + +
    + +
    +
    + ); +} + +function FilterActions({ resetHref }: { resetHref: string }) { + return ( +
    + ); +} + +export function ProductFilters({ + categories, + values, +}: { + categories: ProductFilterCategory[]; + values: ProductFilterValues; +}) { + const [open, setOpen] = useState(false); + const desktopPriceTimer = useRef | 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) => { + 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 ( + <> + + + + + {open && ( +
    { + if (event.target === event.currentTarget) setOpen(false); + }} + > +
    +
    +

    + فیلتر محصولات +

    + +
    + +
    setOpen(false)} + className="flex min-h-0 flex-1 flex-col" + > +
    + +
    +
    + +
    +
    +
    +
    + )} + + ); +} diff --git a/src/components/product/ProductGallery.tsx b/src/components/product/ProductGallery.tsx index 79a46cf..7dbdb8a 100644 --- a/src/components/product/ProductGallery.tsx +++ b/src/components/product/ProductGallery.tsx @@ -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(
    )} @@ -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" > - + )} diff --git a/src/components/product/ProductTabs.tsx b/src/components/product/ProductTabs.tsx index 92abf2f..f928459 100644 --- a/src/components/product/ProductTabs.tsx +++ b/src/components/product/ProductTabs.tsx @@ -119,8 +119,8 @@ export function ProductTabs({ const activeLabel = TABS.find((t) => t.id === active)?.label ?? ""; return ( -
    -
    +
    +
    {TABS.map((tab) => (
    ); } diff --git a/src/components/ui/PageShell.tsx b/src/components/ui/PageShell.tsx new file mode 100644 index 0000000..54fa2bd --- /dev/null +++ b/src/components/ui/PageShell.tsx @@ -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 ( +
    + + {children} +
    + ); +} diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 82f6321..44d9b41 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -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): Product { @@ -195,6 +196,12 @@ function normalizeProduct(raw: Record): 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 | undefined)?.id != null + ? String((raw.category as Record).id) + : undefined, price: numericPrice, oldPrice: numericOldPrice, discount, @@ -211,11 +218,12 @@ function normalizeProduct(raw: Record): 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 { - const res = await apiGet<{ form: unknown; values: Record[] }>( + const res = await apiGet<{ + form: { fields?: Record[] } | null; + values: Record[]; + }>( `/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[] | 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): BlogSummary { @@ -451,6 +494,7 @@ function normalizeBlog(raw: Record): 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): boolean { + return /فروش\s*ویژه|special[\s_-]*sale/i.test(`${group.title} ${group.key}`); +} + function normalizeProductGroups(items: Record[] | undefined): ProductGroup[] { return (items ?? []) .map((group) => ({