diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 84ff3b9..884a6e9 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; import { getBlog } from "@/lib/meshkee"; function formatDate(iso?: string) { @@ -18,6 +19,14 @@ export default async function BlogDetailPage({ return (
+ +
{post.categoryName && {post.categoryName}} {post.publishedAt && ( diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 1efa511..3cb86bd 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,3 +1,4 @@ +import { Breadcrumb } from "@/components/ui/Breadcrumb"; import { Placeholder } from "@/components/ui/Placeholder"; import { EmptyState } from "@/components/ui/EmptyState"; import { getBlogs } from "@/lib/meshkee"; @@ -21,6 +22,8 @@ export default async function BlogListPage({ return (
+ +

وبلاگ

{items.length === 0 ? ( diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 1ffee33..1753ad3 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -1,10 +1,12 @@ 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 { formatPrice } from "@/data/sample"; import { getBusinessInfo, getProduct, + getProductStoreVariants, getProductTechnicalInfo, getProductVariations, } from "@/lib/meshkee"; @@ -21,108 +23,48 @@ export default async function ProductDetailPage({ getProductVariations(slug), getBusinessInfo(), ]); - const contactPhone = businessInfo?.phoneNumbers[0]; if (!product) notFound(); + const storeVariants = await getProductStoreVariants(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}

- {product.categoryName && ( - - {product.categoryName} - - )} - -
- {product.minPrice != null && ( - - {product.minPrice === product.maxPrice - ? formatPrice(product.minPrice) - : `${formatPrice(product.minPrice)} - ${formatPrice(product.maxPrice ?? product.minPrice)}`} - تومان - - )} - - موجودی: - - {product.inStock ? "موجود" : "ناموجود"} - - -
- - {variations.length > 0 && ( -
- {variations.map((variation) => ( -
-

{variation.name}

-
- {variation.options.map((option) => ( - - {option.colorHex && ( - - )} - {option.label} - - ))} -
-
- ))} -
- )} - - {product.minPrice != null ? ( - - ) : contactPhone ? ( - - 📞 - تماس با فروشگاه - - ) : ( - - )} -
+ + {product.brand && ( +

+ {product.brand} +

+ )} +

{product.name}

+ + } + variations={variations} + storeVariants={storeVariants} + fallbackPrice={{ minPrice: product.minPrice, maxPrice: product.maxPrice }} + inStock={product.inStock} + contactPhone={contactPhone} + storeName={businessInfo?.nameFa} + />
diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index 6a7edca..d9a6249 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -1,9 +1,27 @@ +import { Breadcrumb } from "@/components/ui/Breadcrumb"; import { ProductCard } from "@/components/ui/ProductCard"; import { EmptyState } from "@/components/ui/EmptyState"; -import { getBusinessInfo, getProducts, productHref } from "@/lib/meshkee"; +import { + type CategoryTreeGroup, + getBusinessInfo, + getCategoryTree, + getProducts, + productHref, +} from "@/lib/meshkee"; const PAGE_SIZE = 24; +function findCategoryLabel(tree: CategoryTreeGroup[], id: string): string | undefined { + for (const group of tree) { + for (const item of group.items) { + if (item.id === id) return item.nameFa; + const child = item.children.find((c) => c.id === id); + if (child) return child.nameFa; + } + } + return undefined; +} + export default async function ProductsPage({ searchParams, }: { @@ -11,7 +29,7 @@ export default async function ProductsPage({ }) { const params = await searchParams; const page = Math.max(1, Number(params.page ?? 1) || 1); - const [{ items, total }, businessInfo] = await Promise.all([ + const [{ items, total }, businessInfo, categoryTree] = await Promise.all([ getProducts({ categoryId: params.categoryId, name: params.q, @@ -19,9 +37,12 @@ export default async function ProductsPage({ 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(); @@ -33,6 +54,15 @@ export default async function ProductsPage({ return (
+ +

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

diff --git a/src/components/product/ProductPurchasePanel.tsx b/src/components/product/ProductPurchasePanel.tsx new file mode 100644 index 0000000..e994d26 --- /dev/null +++ b/src/components/product/ProductPurchasePanel.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { type ReactNode, useMemo, useState } from "react"; +import { formatPrice } from "@/data/sample"; +import type { ProductStoreVariant, ProductVariation } from "@/lib/meshkee"; + +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function ProductPurchasePanel({ + header, + variations, + storeVariants, + fallbackPrice, + inStock, + contactPhone, + storeName, +}: { + header: ReactNode; + variations: ProductVariation[]; + storeVariants: ProductStoreVariant[]; + fallbackPrice: { minPrice: number | null; maxPrice: number | null }; + inStock: boolean; + contactPhone?: string; + storeName?: string; +}) { + const [selected, setSelected] = useState>(() => { + const initial: Record = {}; + for (const variation of variations) { + if (variation.options[0]) initial[variation.id] = variation.options[0].id; + } + return initial; + }); + const [quantity, setQuantity] = useState(1); + + const matchedVariant = useMemo(() => { + if (variations.length === 0) return null; + return ( + storeVariants.find((variant) => + variations.every((variation) => { + const selection = variant.selections.find((s) => s.variationId === variation.id); + return selection?.optionId === selected[variation.id]; + }), + ) ?? null + ); + }, [variations, storeVariants, selected]); + + const hasVariations = variations.length > 0; + const price = hasVariations + ? (matchedVariant?.discountedPrice ?? matchedVariant?.price ?? null) + : fallbackPrice.minPrice; + const priceRangeLabel = + !hasVariations && + fallbackPrice.minPrice != null && + fallbackPrice.maxPrice != null && + fallbackPrice.minPrice !== fallbackPrice.maxPrice + ? `${formatPrice(fallbackPrice.minPrice)} - ${formatPrice(fallbackPrice.maxPrice)}` + : null; + + const stockQuantity = hasVariations ? (matchedVariant?.stockQuantity ?? 0) : null; + const available = hasVariations ? Boolean(matchedVariant) && stockQuantity! > 0 : inStock; + const maxQty = stockQuantity && stockQuantity > 0 ? stockQuantity : 99; + + return ( + <> +
+ {header} + + {hasVariations && ( +
+ {variations.map((variation) => ( +
+

+ {variation.type === "color" ? "انتخاب رنگ" : variation.name} +

+
+ {variation.options.map((option) => { + const isSelected = selected[variation.id] === option.id; + const swatch = option.colorHex ?? (variation.type === "color" ? option.value : null); + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ +
+
+ {storeName &&

{storeName}

} + +
+ موجودی: + + {available ? "موجود" : "ناموجود"} + +
+ + {price != null ? ( + <> +

+ {priceRangeLabel ?? formatPrice(price)} + تومان +

+ +
+ تعداد: +
+ + {quantity} + +
+
+ + + + ) : contactPhone ? ( + + 📞 + تماس با فروشگاه + + ) : ( + + )} +
+
+ + ); +} diff --git a/src/components/ui/Breadcrumb.tsx b/src/components/ui/Breadcrumb.tsx new file mode 100644 index 0000000..74770fe --- /dev/null +++ b/src/components/ui/Breadcrumb.tsx @@ -0,0 +1,20 @@ +type BreadcrumbItem = { label: string; href?: string }; + +export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) { + return ( + + ); +} diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 747726f..efd671d 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -252,6 +252,34 @@ export async function getProductVariations(slug: string): Promise { + const res = await apiGet<{ items: Record[] }>(`/tenants/${DOMAIN}/store-items`, { + productId, + pageSize: 100, + }); + return (res?.items ?? []).map((raw) => ({ + id: String(raw.id ?? ""), + price: raw.price != null ? Number(raw.price) : null, + discountedPrice: raw.discountedPrice != null ? Number(raw.discountedPrice) : null, + stockQuantity: Number(raw.stockQuantity ?? 0), + selections: ((raw.selections as Record[] | undefined) ?? []).map((s) => ({ + variationId: String(s.variationId ?? ""), + optionId: String(s.optionId ?? ""), + })), + })); +} + export type TechnicalInfoField = { label: string; value: string }; export async function getProductTechnicalInfo(slug: string): Promise {