From c0a95a826a769fae57f4996484859d727896c1b0 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 18 Aug 2026 11:22:31 +0330 Subject: [PATCH] Connect the storefront to the Meshkee Website API. Products, blog, search, mega menu, and home carousels now read live tenant data, and login goes to the customer portal. Co-authored-by: Cursor --- next.config.ts | 7 +- package-lock.json | 114 ----- public/images/meshkee-logo.png | Bin 0 -> 10541 bytes src/app/api/search/route.ts | 11 + src/app/blog/[slug]/page.tsx | 83 ++++ src/app/blog/page.tsx | 62 +++ src/app/globals.css | 134 ++++- src/app/meshkee/static-image-slots/route.ts | 14 + src/app/page.tsx | 89 ++-- src/app/products/[slug]/page.tsx | 138 ++++++ src/app/products/page.tsx | 122 +++++ src/app/special-sale/page.tsx | 34 ++ src/components/About.tsx | 29 +- src/components/Banners.tsx | 66 ++- src/components/BlogCarousel.tsx | 34 +- src/components/FeaturedCategories.tsx | 62 ++- src/components/Footer.tsx | 44 +- src/components/Header.tsx | 174 ++++--- src/components/Hero.tsx | 163 ++++-- src/components/Icons.tsx | 8 + src/components/Pagination.tsx | 41 ++ src/components/ProductCard.tsx | 75 +-- src/components/ProductCarousel.tsx | 45 +- src/components/ProductGallery.tsx | 34 ++ src/components/SearchBox.tsx | 170 +++++++ src/components/SectionHeading.tsx | 13 +- src/components/SiteChrome.tsx | 18 + src/components/SlotImage.tsx | 52 ++ src/data/content.ts | 46 +- src/lib/api.ts | 288 +++++++++++ src/lib/catalog.ts | 517 ++++++++++++++++++++ src/lib/format.ts | 10 +- src/lib/site.ts | 11 + src/lib/static-images.ts | 151 ++++++ 34 files changed, 2458 insertions(+), 401 deletions(-) create mode 100755 public/images/meshkee-logo.png create mode 100644 src/app/api/search/route.ts create mode 100644 src/app/blog/[slug]/page.tsx create mode 100644 src/app/blog/page.tsx create mode 100644 src/app/meshkee/static-image-slots/route.ts create mode 100644 src/app/products/[slug]/page.tsx create mode 100644 src/app/products/page.tsx create mode 100644 src/app/special-sale/page.tsx create mode 100644 src/components/Pagination.tsx create mode 100644 src/components/ProductGallery.tsx create mode 100644 src/components/SearchBox.tsx create mode 100644 src/components/SiteChrome.tsx create mode 100644 src/components/SlotImage.tsx create mode 100644 src/lib/api.ts create mode 100644 src/lib/catalog.ts create mode 100644 src/lib/site.ts create mode 100644 src/lib/static-images.ts diff --git a/next.config.ts b/next.config.ts index e9ffa30..0b0a041 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,12 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { protocol: "https", hostname: "c804387.parspack.net" }, + { protocol: "https", hostname: "api.meshkee.com" }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 010cc72..b80427d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -631,9 +631,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -650,9 +647,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -669,9 +663,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -688,9 +679,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -707,9 +695,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -726,9 +711,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -745,9 +727,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -764,9 +743,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -783,9 +759,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -808,9 +781,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -833,9 +803,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -858,9 +825,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -883,9 +847,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -908,9 +869,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -933,9 +891,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -958,9 +913,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1228,9 +1180,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1247,9 +1196,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1266,9 +1212,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1285,9 +1228,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1526,9 +1466,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1546,9 +1483,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1566,9 +1500,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1586,9 +1517,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2139,9 +2067,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2156,9 +2081,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2173,9 +2095,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2190,9 +2109,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2207,9 +2123,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2224,9 +2137,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2241,9 +2151,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2258,9 +2165,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2275,9 +2179,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2292,9 +2193,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4979,9 +4877,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5003,9 +4898,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5027,9 +4919,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5051,9 +4940,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/public/images/meshkee-logo.png b/public/images/meshkee-logo.png new file mode 100755 index 0000000000000000000000000000000000000000..c1efc446580449b8783969c7a9743e2b3042aea0 GIT binary patch literal 10541 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 literal 0 HcmV?d00001 diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..cbaa811 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,11 @@ +import { searchInstant } from "@/lib/api"; + +export async function GET(request: Request) { + const query = new URL(request.url).searchParams.get("q")?.trim() ?? ""; + if (query.length <= 2) { + return Response.json({ products: [], blogs: [] }); + } + + const result = await searchInstant(query); + return Response.json(result); +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..56e0965 --- /dev/null +++ b/src/app/blog/[slug]/page.tsx @@ -0,0 +1,83 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Placeholder } from "@/components/Placeholder"; +import { SiteChrome } from "@/components/SiteChrome"; +import { getBlogBySlug, listBlogs } from "@/lib/api"; +import { formatDate } from "@/lib/format"; +import { SITE_NAME } from "@/lib/site"; + +type Props = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const blog = await getBlogBySlug(decodeURIComponent(slug)); + if (!blog) return { title: `مقاله | ${SITE_NAME}` }; + return { + title: `${blog.title} | ${SITE_NAME}`, + description: blog.excerpt, + }; +} + +export default async function BlogDetailsPage({ params }: Props) { + const { slug } = await params; + const blog = await getBlogBySlug(decodeURIComponent(slug)); + if (!blog) notFound(); + + const related = (await listBlogs({ pageSize: 6 })).items + .filter((item) => item.slug !== blog.slug) + .slice(0, 3); + + return ( + +
+ + +

Blog

+

{blog.title}

+

+ {[blog.author, formatDate(blog.publishedAt), blog.categoryName].filter(Boolean).join(" • ")} +

+ + +
+ +
+ + {blog.html ? ( +
+ ) : ( +

{blog.excerpt}

+ )} + + {related.length > 0 ? ( +
+

مقالات مرتبط

+
+ {related.map((item) => ( + + +

{item.title}

+ + ))} +
+
+ ) : null} +
+
+ ); +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..6579856 --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Pagination } from "@/components/Pagination"; +import { Placeholder } from "@/components/Placeholder"; +import { SiteChrome } from "@/components/SiteChrome"; +import { listBlogs } from "@/lib/api"; +import { formatDate } from "@/lib/format"; + +export const metadata: Metadata = { + title: "مجله نینی | نینی نادری", + description: "مقاله‌ها و تازه‌های مجله نینی نادری", +}; + +export default async function BlogPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const params = await searchParams; + const page = Math.max(1, Number(params.page) || 1); + const blogs = await listBlogs({ page, pageSize: 12 }); + + return ( + +
+

Blog

+

مجله نینی

+ + + {blogs.items.length === 0 ? ( +

مقاله‌ای یافت نشد.

+ ) : ( +
+ {blogs.items.map((post) => ( + + +
+ {post.publishedAt ? ( +

{formatDate(post.publishedAt)}

+ ) : null} +

{post.title}

+

{post.excerpt}

+
+ + ))} +
+ )} + + (nextPage > 1 ? `/blog?page=${nextPage}` : "/blog")} + /> +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 48c6197..0d8cd7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -99,16 +99,28 @@ body { .container-nn { margin-inline: auto; width: 100%; - max-width: 1280px; + max-width: 1680px; padding-inline: 1rem; } @media (min-width: 768px) { .container-nn { - padding-inline: 1.25rem; + padding-inline: 1.5rem; } } +@media (min-width: 1280px) { + .container-nn { + padding-inline: 2rem; + } +} + +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1.25rem; +} + .placeholder-grid { background-image: linear-gradient(135deg, rgb(255 255 255 / 0.22) 0%, transparent 48%); background-repeat: no-repeat; @@ -123,6 +135,42 @@ body { display: none; } +.rich-content { + font-size: 0.95rem; + line-height: 1.95; + color: var(--muted); +} + +.rich-content p, +.rich-content ul, +.rich-content ol, +.rich-content blockquote { + margin-bottom: 1rem; +} + +.rich-content h2, +.rich-content h3, +.rich-content h4 { + color: var(--ink); + font-weight: 700; + margin: 1.5rem 0 0.75rem; +} + +.rich-content ul, +.rich-content ol { + padding-right: 1.25rem; +} + +.rich-content a { + color: var(--purple-700); +} + +.rich-content img { + max-width: 100%; + height: auto; + border-radius: 1rem; +} + .slider-fade { animation: slider-in 520ms ease; } @@ -137,3 +185,85 @@ body { transform: translateX(0); } } + +.meshkee-credit { + position: relative; +} + +.meshkee-credit__frame { + position: absolute; + inset: 0; + pointer-events: none; +} + +.meshkee-credit__line { + position: absolute; + background: #c8c8c8; + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1); +} + +.meshkee-credit__line--l { + top: 0; + left: 0; + width: 1px; + height: 100%; + transform: scaleY(0); + transform-origin: top; + transition-delay: 0s; +} + +.meshkee-credit__line--b { + bottom: 0; + left: 0; + width: 100%; + height: 1px; + transform: scaleX(0); + transform-origin: left; + transition-delay: 0.14s; +} + +.meshkee-credit__line--r { + top: 0; + right: 0; + width: 1px; + height: 100%; + transform: scaleY(0); + transform-origin: bottom; + transition-delay: 0.28s; +} + +.meshkee-credit__line--t { + top: 0; + left: 0; + width: 100%; + height: 1px; + transform: scaleX(0); + transform-origin: right; + transition-delay: 0.42s; +} + +.meshkee-credit:hover .meshkee-credit__line--l, +.meshkee-credit:hover .meshkee-credit__line--r { + transform: scaleY(1); +} + +.meshkee-credit:hover .meshkee-credit__line--t, +.meshkee-credit:hover .meshkee-credit__line--b { + transform: scaleX(1); +} + +.meshkee-credit:not(:hover) .meshkee-credit__line--l { + transition-delay: 0.42s; +} + +.meshkee-credit:not(:hover) .meshkee-credit__line--b { + transition-delay: 0.28s; +} + +.meshkee-credit:not(:hover) .meshkee-credit__line--r { + transition-delay: 0.14s; +} + +.meshkee-credit:not(:hover) .meshkee-credit__line--t { + transition-delay: 0s; +} diff --git a/src/app/meshkee/static-image-slots/route.ts b/src/app/meshkee/static-image-slots/route.ts new file mode 100644 index 0000000..9222f54 --- /dev/null +++ b/src/app/meshkee/static-image-slots/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { STATIC_IMAGE_SLOT_CATALOG } from "@/lib/static-images"; + +export function GET() { + return NextResponse.json( + { slots: STATIC_IMAGE_SLOT_CATALOG }, + { + headers: { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=60", + }, + }, + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 834b474..bde0bf2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,49 +5,58 @@ import { Contact } from "@/components/Contact"; import { Faq } from "@/components/Faq"; import { FeaturedCategories } from "@/components/FeaturedCategories"; import { Features } from "@/components/Features"; -import { Footer } from "@/components/Footer"; -import { Header } from "@/components/Header"; import { Hero } from "@/components/Hero"; import { ProductCarousel } from "@/components/ProductCarousel"; -import { TopHeader } from "@/components/TopHeader"; -import { saleProducts, topProducts, yeganehProducts } from "@/data/content"; +import { SiteChrome } from "@/components/SiteChrome"; +import { getSpecialProducts, listBlogs } from "@/lib/api"; +import { getHomeStaticImageSlots, HOME_SLOT_KEYS, slotByKey } from "@/lib/static-images"; + +export default async function Home() { + const [slots, sale, top, own, blogs] = await Promise.all([ + getHomeStaticImageSlots(), + getSpecialProducts("special-sale"), + getSpecialProducts("top-selling", "پرفروش"), + getSpecialProducts("nini-naderi-products", "نینی"), + listBlogs({ pageSize: 8 }), + ]); -export default function Home() { return ( -
- -
-
- - - - - - - - - - - - -
-
-
+ + + + + + + + + + + + + + ); } diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx new file mode 100644 index 0000000..9703f05 --- /dev/null +++ b/src/app/products/[slug]/page.tsx @@ -0,0 +1,138 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ProductCarousel } from "@/components/ProductCarousel"; +import { ProductGallery } from "@/components/ProductGallery"; +import { SiteChrome } from "@/components/SiteChrome"; +import { getCatalogItem, listCatalog } from "@/lib/api"; +import { formatPrice } from "@/lib/format"; +import { CUSTOMER_URL, SITE_NAME } from "@/lib/site"; + +type Props = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const product = await getCatalogItem(decodeURIComponent(slug)); + if (!product) return { title: `محصول | ${SITE_NAME}` }; + return { + title: `${product.title} | ${SITE_NAME}`, + description: product.summary || product.title, + }; +} + +export default async function ProductDetailsPage({ params }: Props) { + const { slug } = await params; + const product = await getCatalogItem(decodeURIComponent(slug)); + if (!product) notFound(); + + const related = product.categoryId + ? (await listCatalog({ categoryId: product.categoryId, pageSize: 12 })).items.filter( + (item) => item.id !== product.id && item.slug !== product.slug && item.productId !== product.productId, + ) + : []; + + return ( + +
+ + +
+
+ +
+ +
+

{product.brand}

+

{product.title}

+ {product.categoryName ?

{product.categoryName}

: null} + +
+ {product.oldPrice ? ( +

{formatPrice(product.oldPrice)}

+ ) : null} +

{formatPrice(product.price)}

+ {product.inStock === false ? ( +

در حال حاضر موجود نیست

+ ) : null} +
+ + {product.colorNames.length ? ( +
+

رنگ

+
+ {product.colorNames.map((name, index) => ( + + + {name} + + ))} +
+
+ ) : null} + + {product.variants && product.variants.length > 1 ? ( +
+

تنوع‌ها

+
    + {product.variants.map((variant) => ( +
  • + {variant.label} + + {variant.inStock ? formatPrice(variant.price) : "ناموجود"} + +
  • + ))} +
+
+ ) : null} + + {product.price != null && product.price > 0 ? ( + + خرید از حساب کاربری + + ) : null} + {product.summary ?

{product.summary}

: null} +
+
+ + {product.descriptionHtml ? ( +
+

توضیحات محصول

+
+
+ ) : null} + + +
+
+ ); +} diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx new file mode 100644 index 0000000..fb019a3 --- /dev/null +++ b/src/app/products/page.tsx @@ -0,0 +1,122 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Pagination } from "@/components/Pagination"; +import { ProductCard } from "@/components/ProductCard"; +import { SiteChrome } from "@/components/SiteChrome"; +import { getCategories, getCategoryById, listCatalog } from "@/lib/api"; +import { buildMegaMenu, categoryLabel } from "@/lib/catalog"; + +export const metadata: Metadata = { + title: "محصولات | نینی نادری", + description: "خرید لباس نوزاد و کودک از فروشگاه نینی نادری", +}; + +type Search = { + categoryId?: string; + q?: string; + page?: string; +}; + +export default async function ProductsPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const params = await searchParams; + const page = Math.max(1, Number(params.page) || 1); + const categoryId = params.categoryId; + const q = params.q?.trim(); + const [catalog, categories, activeCategory] = await Promise.all([ + listCatalog({ page, pageSize: 20, categoryId, name: q }), + getCategories("product"), + getCategoryById(categoryId), + ]); + const columns = buildMegaMenu(categories); + + const title = q + ? `نتایج جستجو برای «${q}»` + : activeCategory + ? categoryLabel(activeCategory) + : "محصولات نینی نادری"; + const activeColumn = categoryId + ? columns.find((column) => column.id === categoryId || column.children.some((child) => child.id === categoryId)) + : undefined; + + function hrefFor(nextPage: number) { + const search = new URLSearchParams(); + if (categoryId) search.set("categoryId", categoryId); + if (q) search.set("q", q); + if (nextPage > 1) search.set("page", String(nextPage)); + const value = search.toString(); + return value ? `/products?${value}` : "/products"; + } + + return ( + +
+

Products

+

{title}

+ + + {columns.length ? ( +
+ + همه + + {columns.map((column) => ( + + {column.name} + + ))} +
+ ) : null} + + {activeColumn?.children.length ? ( +
+ {activeColumn.children.map((child) => ( + + {child.name} + + ))} +
+ ) : null} + + {catalog.items.length === 0 ? ( +

+ محصولی در این بخش یافت نشد. +

+ ) : ( +
+ {catalog.items.map((product) => ( + + ))} +
+ )} + + +
+
+ ); +} diff --git a/src/app/special-sale/page.tsx b/src/app/special-sale/page.tsx new file mode 100644 index 0000000..85c1efc --- /dev/null +++ b/src/app/special-sale/page.tsx @@ -0,0 +1,34 @@ +import { ProductCard } from "@/components/ProductCard"; +import { SiteChrome } from "@/components/SiteChrome"; +import { getSpecialProducts } from "@/lib/api"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "فروش ویژه | نینی نادری", + description: "محصولات فروش ویژه نینی نادری", +}; + +export default async function SpecialSalePage() { + const sale = await getSpecialProducts("special-sale"); + + return ( + +
+

Special Sale

+

{sale.title || "فروش ویژه نینی‌ها"}

+ + {sale.products.length ? ( +
+ {sale.products.map((product) => ( + + ))} +
+ ) : ( +

+ محصول فروش ویژه‌ای پیدا نشد. +

+ )} +
+
+ ); +} diff --git a/src/components/About.tsx b/src/components/About.tsx index 308056a..46c95e8 100644 --- a/src/components/About.tsx +++ b/src/components/About.tsx @@ -1,7 +1,8 @@ import { about } from "@/data/content"; -import { Placeholder } from "./Placeholder"; +import { slotImages, type StaticImageSlot } from "@/lib/static-images"; +import { SlotImage } from "./SlotImage"; -export function About() { +export function About({ slot }: { slot?: StaticImageSlot }) { return (
@@ -18,19 +19,33 @@ export function About() {
{about.stats.map((stat) => (
-

{stat.value}

-

{stat.label}

+

+ + {stat.value} + + {stat.suffix ? ( + {stat.suffix} + ) : null} +

+

{stat.label}

))}
-
diff --git a/src/components/Banners.tsx b/src/components/Banners.tsx index 4177f3b..f8b1145 100644 --- a/src/components/Banners.tsx +++ b/src/components/Banners.tsx @@ -1,41 +1,57 @@ import { dualBanners, tripleBanners } from "@/data/content"; -import { Placeholder } from "./Placeholder"; +import { padSlotImages, type StaticImageSlot } from "@/lib/static-images"; +import { SlotImage } from "./SlotImage"; + +export function DualBanners({ slot }: { slot?: StaticImageSlot }) { + const images = padSlotImages(slot, 2); -export function DualBanners() { return (
- {dualBanners.map((banner) => ( - - - - ))} + {images.map((image, index) => { + const fallback = dualBanners[index] ?? dualBanners[0]; + return ( +
+ +
+ ); + })}
); } -export function TripleBanners() { +export function TripleBanners({ slot }: { slot?: StaticImageSlot }) { + const images = padSlotImages(slot, 3); + return (
- {tripleBanners.map((banner) => ( - - - - ))} + {images.map((image, index) => { + const fallback = tripleBanners[index] ?? tripleBanners[0]; + return ( +
+ +
+ ); + })}
); diff --git a/src/components/BlogCarousel.tsx b/src/components/BlogCarousel.tsx index 3caafc8..fc987f3 100644 --- a/src/components/BlogCarousel.tsx +++ b/src/components/BlogCarousel.tsx @@ -1,12 +1,20 @@ -import { posts } from "@/data/content"; +import Link from "next/link"; import { Carousel } from "./Carousel"; import { Placeholder } from "./Placeholder"; import { SectionHeading } from "./SectionHeading"; +import type { BlogPost } from "@/lib/catalog"; +import { formatDate } from "@/lib/format"; + +export function BlogCarousel({ posts }: { posts: BlogPost[] }) { + if (!posts.length) return null; -export function BlogCarousel() { return (
- + + + مشاهده همه + + {posts.map((post) => (
- + + +
-

{post.date}

-

- {post.title} -

+ {post.publishedAt ? ( +

{formatDate(post.publishedAt)}

+ ) : null} + +

+ {post.title} +

+

{post.excerpt}

- + ادامه مطلب - +
))} diff --git a/src/components/FeaturedCategories.tsx b/src/components/FeaturedCategories.tsx index 5ba854e..66dd6cc 100644 --- a/src/components/FeaturedCategories.tsx +++ b/src/components/FeaturedCategories.tsx @@ -1,28 +1,70 @@ import { categories } from "@/data/content"; import { toFaDigits } from "@/lib/format"; -import { Placeholder } from "./Placeholder"; +import { + imageAlt, + slotHref, + slotImages, + type StaticImageSlot, +} from "@/lib/static-images"; import { SectionHeading } from "./SectionHeading"; +import { SlotImage } from "./SlotImage"; + +type Props = { + slot?: StaticImageSlot; +}; + +export function FeaturedCategories({ slot }: Props) { + const images = slotImages(slot); + const items = + images.length > 0 + ? images.map((image, index) => { + const fallback = categories[index]; + return { + id: image.id, + image, + fallbackSrc: fallback?.image ?? categories[0].image, + title: imageAlt(image, fallback?.title ?? ""), + href: slotHref(image) || "#", + count: fallback?.count, + subtext: image.subtext, + }; + }) + : categories.map((category) => ({ + id: category.id, + image: undefined, + fallbackSrc: category.image, + title: category.title, + href: "#", + count: category.count, + subtext: undefined as string | null | undefined, + })); -export function FeaturedCategories() { return (
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 90937ea..52c3332 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -48,26 +48,52 @@ export function Footer() { -
-
-

© ۱۴۰۵ نینی نادری — nininaderi.com — همه حقوق محفوظ است.

-

Made for little wardrobes

+
+
+

© {new Date().getFullYear()} نینی نادری

+ + + + + + + + Meshkee + + Designed And Developed By + + Meshkee E-Commerce Team + + +
); } -function FooterList({ title, items }: { title: string; items: string[] }) { +function FooterList({ title, items }: { title: string; items: { href: string; label: string }[] }) { return (

{title}

    {items.map((item) => ( -
  • - - {item} - +
  • + + {item.label} +
  • ))}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 41b6a9a..4a74cb8 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,11 +2,17 @@ import Link from "next/link"; import { useState } from "react"; -import { categories, navLinks } from "@/data/content"; -import { toFaDigits } from "@/lib/format"; -import { IconBag, IconChevronDown, IconClose, IconHeart, IconMenu, IconSearch, IconUser } from "./Icons"; +import { SearchBox } from "@/components/SearchBox"; +import { navLinks } from "@/data/content"; +import type { MegaColumn } from "@/lib/catalog"; +import { CUSTOMER_URL } from "@/lib/site"; +import { IconBag, IconChevronDown, IconClose, IconHeart, IconMenu, IconUser } from "./Icons"; -export function Header() { +type Props = { + categories?: MegaColumn[]; +}; + +export function Header({ categories = [] }: Props) { const [open, setOpen] = useState(false); const [mega, setMega] = useState(false); const [mobileCats, setMobileCats] = useState(false); @@ -40,49 +46,50 @@ export function Header() { -
- - - - -
+
-
-
- - - - -
-
+
+ +
@@ -125,29 +132,43 @@ export function Header() { {mobileCats ? ( -
- {categories.map((category) => ( - - {category.title} - +
+ {categories.map((column) => ( +
+ + {column.name} + + {column.children.length ? ( +
+ {column.children.map((child) => ( + + {child.name} + + ))} +
+ ) : null} +
))}
) : null} {navLinks.map((link) => ( - {link.label} - + ))} + + ورود / ثبت نام +
) : null} @@ -155,32 +176,51 @@ export function Header() { ); } -function MegaMenu({ onPick }: { onPick: () => void }) { +function MegaMenu({ categories, onPick }: { categories: MegaColumn[]; onPick: () => void }) { return ( -
+

Categories

خرید بر اساس دسته

- + مشاهده همه - -
-
- {categories.map((category) => ( - -

{category.title}

-

{toFaDigits(category.count)} مدل

-
- ))} +
+ {categories.length ? ( +
+ {categories.map((column) => ( +
+ + {column.name} + + {column.children.length ? ( +
    + {column.children.map((child) => ( +
  • + + {child.name} + +
  • + ))} +
+ ) : null} +
+ ))} +
+ ) : ( +

دسته‌بندی‌ها به‌زودی اینجا می‌آیند.

+ )}
); diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 98e5e3d..332c55b 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -2,67 +2,134 @@ import { useEffect, useState } from "react"; import { sideBanners, slides } from "@/data/content"; -import { Placeholder } from "./Placeholder"; +import { + aspectRatioStyle, + imageAlt, + padSlotImages, + slotHref, + slotImages, + type StaticImageSlot, +} from "@/lib/static-images"; +import { SlotImage } from "./SlotImage"; + +type Props = { + slider?: StaticImageSlot; + beside?: StaticImageSlot; +}; + +export function Hero({ slider, beside }: Props) { + const apiSlides = slotImages(slider); + const items = + apiSlides.length > 0 + ? apiSlides.map((image) => ({ + id: image.id, + image, + fallbackSrc: slides[0].image, + alt: imageAlt(image, ""), + href: slotHref(image), + })) + : slides.map((slide) => ({ + id: slide.id, + image: undefined, + fallbackSrc: slide.image, + alt: slide.title, + href: undefined, + })); -export function Hero() { const [index, setIndex] = useState(0); - const slide = slides[index]; + const current = items[index] ?? items[0]; useEffect(() => { + setIndex(0); + }, [items.length]); + + useEffect(() => { + if (items.length < 2) return; const timer = window.setInterval(() => { - setIndex((current) => (current + 1) % slides.length); + setIndex((value) => (value + 1) % items.length); }, 5200); return () => window.clearInterval(timer); - }, []); + }, [items.length]); + + const next = () => setIndex((value) => (value + 1) % items.length); + const media = current ? ( + + ) : null; return (
-
- -
- {slides.map((item, itemIndex) => ( - - ))} -
+
+ {current?.href ? ( + + {media} + + ) : ( + + )} + {items.length > 1 ? ( +
+ {items.map((item, itemIndex) => ( + + ))} +
+ ) : null}
- {sideBanners.map((banner) => ( - - - - ))} + {padSlotImages(beside, 2) + .slice(0, 2) + .map((image, itemIndex) => { + const fallback = sideBanners[itemIndex] ?? sideBanners[0]; + return ( +
+ +
+ ); + })}
); diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 9230b1f..1bad6ef 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -150,6 +150,14 @@ export function IconMenu({ className = "size-6" }: IconProps) { ); } +export function IconPlus({ className = "size-5" }: IconProps) { + return ( + + + + ); +} + export function IconClose({ className = "size-6" }: IconProps) { return ( diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..53eaf7d --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,41 @@ +import Link from "next/link"; + +type Props = { + page: number; + pageSize: number; + total: number; + hrefFor: (page: number) => string; +}; + +export function Pagination({ page, pageSize, total, hrefFor }: Props) { + const pages = Math.max(1, Math.ceil(total / pageSize)); + if (pages <= 1) return null; + + const items = Array.from({ length: pages }, (_, index) => index + 1); + + return ( + + ); +} diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index b03e986..e1624f1 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -1,31 +1,41 @@ -import type { Product } from "@/data/content"; +import Link from "next/link"; +import { CUSTOMER_URL } from "@/lib/site"; +import type { Product } from "@/lib/catalog"; import { formatPrice } from "@/lib/format"; +import { IconPlus } from "./Icons"; import { Placeholder } from "./Placeholder"; type Props = { product: Product; + fill?: boolean; }; -export function ProductCard({ product }: Props) { +export function ProductCard({ product, fill = false }: Props) { + const canAdd = product.price != null && product.price > 0; + return (
- + + +

{product.brand}

{product.badge ? ( ) : null}
-

- {product.title} -

-
- {product.colors.map((color) => ( - - ))} -
+ +

+ {product.title} +

+ + {product.colors.length ? ( +
+ {product.colors.slice(0, 5).map((color) => ( + + ))} +
+ ) : null}
{product.oldPrice ? ( @@ -55,12 +69,15 @@ export function ProductCard({ product }: Props) { ) : null}

{formatPrice(product.price)}

- + {canAdd ? ( + + + + ) : null}
diff --git a/src/components/ProductCarousel.tsx b/src/components/ProductCarousel.tsx index 8577524..4b1a00a 100644 --- a/src/components/ProductCarousel.tsx +++ b/src/components/ProductCarousel.tsx @@ -1,12 +1,13 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; -import type { Product } from "@/data/content"; -import { toFaDigits } from "@/lib/format"; +import type { Product } from "@/lib/catalog"; +import { slotImages, type StaticImageSlot } from "@/lib/static-images"; import { Carousel } from "./Carousel"; -import { Placeholder } from "./Placeholder"; import { ProductCard } from "./ProductCard"; import { SectionHeading } from "./SectionHeading"; +import { SlotImage } from "./SlotImage"; type Props = { id?: string; @@ -15,6 +16,8 @@ type Props = { products: Product[]; countdown?: boolean; leadBanner?: boolean; + bannerSlot?: StaticImageSlot; + href?: string; }; export function ProductCarousel({ @@ -24,30 +27,42 @@ export function ProductCarousel({ products, countdown, leadBanner, + bannerSlot, + href, }: Props) { + const saleBanner = slotImages(bannerSlot)[0]; + if (!products.length && !leadBanner) return null; + return (
{countdown ? : null} + {href ? ( + + مشاهده همه + + ) : null} {leadBanner ? ( - - - +
) : null} {products.map((product) => ( @@ -94,11 +109,15 @@ function SaleTimer() { {cells.map((cell, index) => (
{index > 0 ? ( - : + : ) : null}
-

- {toFaDigits(String(cell.value).padStart(2, "0"))} +

+ {String(cell.value).padStart(2, "0")}

{cell.label}

diff --git a/src/components/ProductGallery.tsx b/src/components/ProductGallery.tsx new file mode 100644 index 0000000..80e3af1 --- /dev/null +++ b/src/components/ProductGallery.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useState } from "react"; +import { Placeholder } from "@/components/Placeholder"; + +export function ProductGallery({ images, title }: { images: string[]; title: string }) { + const gallery = images.filter(Boolean); + const [active, setActive] = useState(0); + const current = gallery[active] || gallery[0]; + + if (!current) return null; + + return ( +
+ + {gallery.length > 1 ? ( +
+ {gallery.slice(0, 8).map((image, index) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx new file mode 100644 index 0000000..9d70d57 --- /dev/null +++ b/src/components/SearchBox.tsx @@ -0,0 +1,170 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useId, useRef, useState } from "react"; +import { IconSearch } from "@/components/Icons"; +import { Placeholder } from "@/components/Placeholder"; +import type { BlogPost, Product } from "@/lib/catalog"; +import { formatPrice } from "@/lib/format"; + +type Props = { + className?: string; + inputClassName?: string; + placeholder?: string; + onNavigate?: () => void; +}; + +export function SearchBox({ + className = "", + inputClassName = "", + placeholder = "جستجوی لباس نوزاد، دخترانه، یگانه...", + onNavigate, +}: Props) { + const listId = useId(); + const root = useRef(null); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [products, setProducts] = useState([]); + const [blogs, setBlogs] = useState([]); + + useEffect(() => { + const term = query.trim(); + if (term.length <= 2) return; + + const controller = new AbortController(); + const timer = window.setTimeout(async () => { + setLoading(true); + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(term)}`, { + signal: controller.signal, + }); + const data = (await response.json()) as { products?: Product[]; blogs?: BlogPost[] }; + setProducts(data.products ?? []); + setBlogs(data.blogs ?? []); + setOpen(true); + } catch (error) { + if ((error as { name?: string }).name !== "AbortError") { + setProducts([]); + setBlogs([]); + setOpen(true); + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, 220); + + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [query]); + + useEffect(() => { + function onPointer(event: MouseEvent) { + if (!root.current?.contains(event.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onPointer); + return () => document.removeEventListener("mousedown", onPointer); + }, []); + + const hasQuery = query.trim().length > 2; + const empty = hasQuery && !loading && products.length === 0 && blogs.length === 0; + + return ( +
+
+ setQuery(event.target.value)} + onFocus={() => { + if (hasQuery) setOpen(true); + }} + onKeyDown={(event) => { + if (event.key === "Escape") setOpen(false); + }} + placeholder={placeholder} + autoComplete="off" + role="combobox" + aria-expanded={open} + aria-controls={listId} + className={inputClassName} + /> + + + +
+ + {open && hasQuery ? ( +
+ {loading && !products.length && !blogs.length ? ( +

در حال جستجو...

+ ) : null} + {empty ?

موردی پیدا نشد

: null} + + {products.length > 0 ? ( +
+

محصولات

+ {products.map((item) => ( + { + setOpen(false); + onNavigate?.(); + }} + className="flex items-center gap-3 rounded-2xl px-3 py-2 hover:bg-purple-50" + > + + + + + {item.title} + {formatPrice(item.price)} + + + ))} +
+ ) : null} + + {blogs.length > 0 ? ( +
+

مجله

+ {blogs.map((item) => ( + { + setOpen(false); + onNavigate?.(); + }} + className="flex items-center gap-3 rounded-2xl px-3 py-2 hover:bg-purple-50" + > + + + + {item.title} + + ))} +
+ ) : null} + + { + setOpen(false); + onNavigate?.(); + }} + className="mt-1 block rounded-2xl px-3 py-2 text-center text-sm font-bold text-purple-700 hover:bg-purple-50" + > + مشاهده همه نتایج + +
+ ) : null} +
+ ); +} diff --git a/src/components/SectionHeading.tsx b/src/components/SectionHeading.tsx index b160775..a89ca54 100644 --- a/src/components/SectionHeading.tsx +++ b/src/components/SectionHeading.tsx @@ -3,11 +3,10 @@ import type { ReactNode } from "react"; type Props = { english: string; title: string; - href?: string; children?: ReactNode; }; -export function SectionHeading({ english, title, href = "#", children }: Props) { +export function SectionHeading({ english, title, children }: Props) { return (
@@ -15,15 +14,7 @@ export function SectionHeading({ english, title, href = "#", children }: Props)

{title}

-
- {children} - - مشاهده همه - -
+ {children ?
{children}
: null}
); } diff --git a/src/components/SiteChrome.tsx b/src/components/SiteChrome.tsx new file mode 100644 index 0000000..5984507 --- /dev/null +++ b/src/components/SiteChrome.tsx @@ -0,0 +1,18 @@ +import { Footer } from "@/components/Footer"; +import { Header } from "@/components/Header"; +import { TopHeader } from "@/components/TopHeader"; +import { getMegaMenuColumns } from "@/lib/api"; +import type { ReactNode } from "react"; + +export async function SiteChrome({ children }: { children: ReactNode }) { + const categories = await getMegaMenuColumns(); + + return ( +
+ +
+
{children}
+
+
+ ); +} diff --git a/src/components/SlotImage.tsx b/src/components/SlotImage.tsx new file mode 100644 index 0000000..6966c11 --- /dev/null +++ b/src/components/SlotImage.tsx @@ -0,0 +1,52 @@ +import type { StaticImage } from "@/lib/static-images"; +import { aspectRatioStyle, imageAlt, slotHref } from "@/lib/static-images"; + +type Props = { + image?: StaticImage; + fallbackSrc: string; + fallbackAlt?: string; + aspectRatio?: string | null; + width: number; + height: number; + className?: string; + rounded?: string; + fill?: boolean; + unlinked?: boolean; + fallbackHref?: string; +}; + +export function SlotImage({ + image, + fallbackSrc, + fallbackAlt = "", + aspectRatio, + width, + height, + className = "", + rounded = "rounded-3xl", + fill = false, + unlinked = false, + fallbackHref, +}: Props) { + const src = image?.url || fallbackSrc; + const alt = imageAlt(image, fallbackAlt); + const href = unlinked ? undefined : slotHref(image) || fallbackHref; + const img = ( + {alt} + ); + + if (!href) return img; + + return ( + + {img} + + ); +} diff --git a/src/data/content.ts b/src/data/content.ts index 4f80312..2aaa22f 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -1,6 +1,10 @@ +import { CUSTOMER_URL } from "@/lib/site"; + export type Tone = "purple" | "cyan" | "rose"; -export type Product = { +export type { Product } from "@/lib/catalog"; + +export type DemoProduct = { id: string; image: string; title: string; @@ -13,10 +17,12 @@ export type Product = { }; export const navLinks = [ - { href: "#sale", label: "فروش ویژه" }, - { href: "#about", label: "درباره ما" }, - { href: "#faq", label: "سوالات متداول" }, - { href: "#contact", label: "تماس با ما" }, + { href: "/products", label: "محصولات" }, + { href: "/special-sale", label: "فروش ویژه" }, + { href: "/blog", label: "مجله" }, + { href: "/#about", label: "درباره ما" }, + { href: "/#faq", label: "سوالات متداول" }, + { href: "/#contact", label: "تماس با ما" }, ]; export const slides = [ @@ -109,7 +115,7 @@ export const features = [ }, ]; -export const saleProducts: Product[] = [ +export const saleProducts: DemoProduct[] = [ { id: "p1", image: "/images/sale-1.png", @@ -221,7 +227,7 @@ export const dualBanners = [ }, ]; -export const topProducts: Product[] = [ +export const topProducts: DemoProduct[] = [ { id: "t1", image: "/images/top-1.png", @@ -331,7 +337,7 @@ export const tripleBanners = [ }, ]; -export const yeganehProducts: Product[] = [ +export const yeganehProducts: DemoProduct[] = [ { id: "y1", image: "/images/yeganeh-1.png", @@ -403,9 +409,9 @@ export const about = { "هر سفارش با ضمانت اصالت ارسال می‌شود، امکان مرجوعی دارید و برای خریدهای بالای ۵ میلیون تومان پیک رایگان است. اگر سایز شک دارید، پشتیبانی نینی نادری کمکتان می‌کند.", ], stats: [ - { value: "۱۲ سال", label: "همراه خانواده‌ها" }, - { value: "+۴۰۰", label: "مدل فعال" }, - { value: "۲۴ ساعته", label: "پاسخگویی سفارش" }, + { value: "12", suffix: "سال", label: "همراه خانواده‌ها" }, + { value: "400+", suffix: "", label: "مدل فعال" }, + { value: "24", suffix: "ساعته", label: "پاسخگویی سفارش" }, ], image: "/images/about.png", imageSize: "560 × 400", @@ -472,9 +478,21 @@ export const faqs = [ export const footer = { blurb: "فروشگاه پوشاک نوزاد و کودک نینی نادری. لباس نرم، رنگ زنده، ارسال سریع.", - shop: ["نوزاد", "دخترانه", "پسرانه", "برند یگانه", "فروش ویژه"], - help: ["پیگیری سفارش", "راهنمای سایز", "مرجوعی کالا", "سوالات پرتکرار", "تماس با ما"], - about: ["داستان نینی نادری", "شعب حضوری", "فرصت همکاری", "قوانین و حریم خصوصی"], + shop: [ + { href: "/products", label: "محصولات" }, + { href: "/special-sale", label: "فروش ویژه" }, + { href: "/blog", label: "مجله" }, + { href: "/#about", label: "درباره ما" }, + ], + help: [ + { href: CUSTOMER_URL, label: "پیگیری سفارش" }, + { href: "/#faq", label: "سوالات پرتکرار" }, + { href: "/#contact", label: "تماس با ما" }, + ], + about: [ + { href: "/#about", label: "داستان نینی نادری" }, + { href: "/#contact", label: "تماس با ما" }, + ], contact: { phone: "۰۲۱-۹۱۰۰۱۲۳۴", mobile: "۰۹۱۲۳۴۵۶۷۸۹", diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..36ccd35 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,288 @@ +import { + buildMegaMenu, + categoryIdsWithDescendants, + mapBlog, + mapProduct, + mapSpecial, + mapSpecialItem, + mapStoreItem, + mergeProduct, + type BlogPost, + type Category, + type MegaColumn, + type Paginated, + type Product, + type StoreSpecial, +} from "@/lib/catalog"; +import { API_BASE, SITE_DOMAIN } from "@/lib/site"; + +type Json = Record; + +function tenantUrl(path: string) { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${API_BASE}/tenants/${SITE_DOMAIN}${normalized}`; +} + +async function apiGet(path: string, cache: RequestCache = "force-cache"): Promise { + try { + const response = await fetch(tenantUrl(path), { + cache, + next: cache === "no-store" ? undefined : { revalidate: 60 }, + }); + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + return null; + } +} + +function query(params: Record) { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === "") continue; + search.set(key, String(value)); + } + const encoded = search.toString(); + return encoded ? `?${encoded}` : ""; +} + +function paginated( + data: { items?: T[]; total?: number; page?: number; pageSize?: number } | null, + pageSize: number, +): Paginated { + return { + items: data?.items ?? [], + total: data?.total ?? 0, + page: data?.page ?? 1, + pageSize: data?.pageSize ?? pageSize, + }; +} + +export async function getCategories(entityType: "product" | "blog" = "product"): Promise { + const data = await apiGet<{ items?: Category[] }>(`/categories${query({ entityType })}`); + return Array.isArray(data?.items) ? data.items : []; +} + +export async function getMegaMenuColumns(): Promise { + return buildMegaMenu(await getCategories("product")); +} + +export async function getCategoryById(id?: string) { + if (!id) return undefined; + const categories = await getCategories("product"); + return categories.find((category) => category.id === id); +} + +type StoreListParams = { + page?: number; + pageSize?: number; + categoryId?: string; + name?: string; +}; + +export async function listStoreItems(params: StoreListParams = {}): Promise> { + const pageSize = params.pageSize ?? 20; + const data = await apiGet<{ items?: Json[]; total?: number; page?: number; pageSize?: number }>( + `/store-items${query({ + page: params.page ?? 1, + pageSize, + categoryId: params.categoryId, + name: params.name, + })}`, + params.name ? "no-store" : "force-cache", + ); + const page = paginated(data, pageSize); + const catalog = await getProductIndex(); + return { + ...page, + items: page.items.map((item) => { + const mapped = mapStoreItem(item); + return mergeProduct(mapped, catalog.byId.get(mapped.productId || mapped.id)); + }), + }; +} + +export async function getStoreItem(variantId: string): Promise { + const data = await apiGet<{ variant?: Json; storeItem?: Json }>(`/store-items/${encodeURIComponent(variantId)}`); + const raw = (data?.variant || data?.storeItem || data) as Json | undefined; + if (!raw || typeof raw !== "object") return null; + if (!(raw.id || raw.productId || raw.productNameFa || raw.productTitle)) return null; + const mapped = mapStoreItem(raw); + const catalog = await getProductIndex(); + return mergeProduct(mapped, catalog.byId.get(mapped.productId || mapped.id)); +} + +export async function getStoreItemByProduct(productId: string): Promise { + const data = await apiGet<{ storeItem?: Json }>(`/store-items/by-product/${encodeURIComponent(productId)}`); + if (!data?.storeItem) return null; + const mapped = mapStoreItem(data.storeItem); + const catalog = await getProductIndex(); + return mergeProduct(mapped, catalog.byId.get(productId) ?? catalog.byId.get(mapped.productId || mapped.id)); +} + +type ProductListParams = { + page?: number; + pageSize?: number; + categoryId?: string; + name?: string; +}; + +export async function listProducts(params: ProductListParams = {}): Promise> { + const pageSize = params.pageSize ?? 20; + const data = await apiGet<{ items?: Json[]; total?: number; page?: number; pageSize?: number }>( + `/products${query({ + page: params.page ?? 1, + pageSize, + categoryId: params.categoryId, + name: params.name, + inStore: true, + })}`, + params.name ? "no-store" : "force-cache", + ); + const page = paginated(data, pageSize); + return { ...page, items: page.items.map(mapProduct) }; +} + +export async function getProductBySlug(slug: string): Promise { + const data = await apiGet<{ product?: Json }>(`/products/${encodeURIComponent(slug)}`); + if (!data?.product) return null; + return mapProduct(data.product); +} + +async function getProductIndex() { + const data = await listProducts({ page: 1, pageSize: 100 }); + const byId = new Map(); + const bySlug = new Map(); + for (const item of data.items) { + byId.set(item.id, item); + if (item.productId) byId.set(item.productId, item); + bySlug.set(item.slug, item); + bySlug.set(decodeURIComponent(item.slug), item); + } + return { items: data.items, byId, bySlug }; +} + +export async function listCatalog(params: StoreListParams = {}): Promise> { + if (params.categoryId) { + const categories = await getCategories("product"); + const ids = categoryIdsWithDescendants(params.categoryId, categories); + if (ids.length > 1) { + const pages = await Promise.all( + ids.map((categoryId) => listStoreItems({ ...params, page: 1, pageSize: 50, categoryId })), + ); + const seen = new Set(); + const items = pages + .flatMap((page) => page.items) + .filter((item) => { + const key = item.productId || item.slug || item.id; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const pageSize = params.pageSize ?? 20; + const page = params.page ?? 1; + const start = (page - 1) * pageSize; + return { + items: items.slice(start, start + pageSize), + total: items.length, + page, + pageSize, + }; + } + } + + const store = await listStoreItems(params); + if (store.total > 0 || store.items.length > 0) return store; + return listProducts(params); +} + +export async function getCatalogItem(slug: string): Promise { + const decoded = decodeURIComponent(slug); + const catalog = await getProductIndex(); + const product = + catalog.bySlug.get(decoded) || + catalog.bySlug.get(slug) || + catalog.byId.get(decoded) || + (await getProductBySlug(decoded)); + + const store = + (await getStoreItem(decoded)) || + (product?.id ? await getStoreItemByProduct(product.id) : null) || + (await getStoreItemByProduct(decoded)); + + if (store && product) return mergeProduct(store, product); + if (store) return store; + if (product) { + const storeForProduct = product.id ? await getStoreItemByProduct(product.id) : null; + return storeForProduct ? mergeProduct(storeForProduct, product) : product; + } + return null; +} + +type BlogListParams = { + page?: number; + pageSize?: number; + title?: string; +}; + +export async function listBlogs(params: BlogListParams = {}): Promise> { + const pageSize = params.pageSize ?? 12; + const data = await apiGet<{ items?: Json[]; total?: number; page?: number; pageSize?: number }>( + `/blogs${query({ page: params.page ?? 1, pageSize, title: params.title })}`, + params.title ? "no-store" : "force-cache", + ); + const page = paginated(data, pageSize); + return { ...page, items: page.items.map(mapBlog) }; +} + +export async function getBlogBySlug(slug: string): Promise { + const data = await apiGet<{ blog?: Json }>(`/blogs/${encodeURIComponent(slug)}`); + if (!data?.blog) return null; + return mapBlog(data.blog); +} + +export async function getStoreSpecials(): Promise { + const data = await apiGet<{ items?: Json[] }>("/store-specials"); + return (data?.items ?? []).map(mapSpecial).filter((item) => item.isActive); +} + +function specialKeyMatches(actual: string, wanted: string) { + const normalize = (value: string) => value.toLowerCase().replace(/_/g, "-").replace(/s$/, ""); + return actual.toLowerCase() === wanted.toLowerCase() || normalize(actual) === normalize(wanted); +} + +export async function getSpecialProducts(key: string, badge?: string): Promise<{ title: string; products: Product[] }> { + const specials = await getStoreSpecials(); + const special = specials.find((item) => specialKeyMatches(item.key, key)); + if (!special) return { title: "", products: [] }; + + const catalog = await getProductIndex(); + const products = special.items + .filter((item) => item.isActive) + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((item) => mapSpecialItem(item, catalog.byId.get(item.productId), badge)); + + return { title: special.title, products }; +} + +export async function searchInstant(term: string) { + const q = term.trim(); + if (q.length <= 2) { + return { products: [] as Product[], blogs: [] as BlogPost[] }; + } + + const [store, blogs] = await Promise.all([ + listStoreItems({ name: q, pageSize: 8 }), + listBlogs({ title: q, pageSize: 4 }), + ]); + + const seen = new Set(); + const products: Product[] = []; + for (const item of store.items) { + if (seen.has(item.href)) continue; + seen.add(item.href); + products.push(item); + } + + return { products: products.slice(0, 8), blogs: blogs.items }; +} diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts new file mode 100644 index 0000000..7ca1a04 --- /dev/null +++ b/src/lib/catalog.ts @@ -0,0 +1,517 @@ +export const PLACEHOLDER_IMAGE = "/images/sale-1.png"; +export const PLACEHOLDER_BLOG = "/images/blog-1.png"; + +export type Tone = "purple" | "cyan" | "rose"; + +export type Category = { + id: string; + parentId?: string | null; + name: string; + nameFa?: string | null; + slug: string; + sortOrder?: number; + isActive?: boolean; +}; + +export type MegaColumn = { + id: string; + name: string; + href: string; + children: { id: string; name: string; href: string }[]; +}; + +export type Product = { + id: string; + productId?: string; + slug: string; + href: string; + image: string; + images: string[]; + title: string; + brand: string; + price: number | null; + oldPrice?: number | null; + colors: string[]; + colorNames: string[]; + tone: Tone; + badge?: string; + inStock?: boolean; + categoryId?: string; + categoryName?: string; + summary?: string; + descriptionHtml?: string; + variants?: ProductVariant[]; +}; + +export type ProductVariant = { + id: string; + label: string; + price: number | null; + oldPrice?: number | null; + stock: number; + inStock: boolean; + color?: string; + size?: string; +}; + +export type BlogPost = { + id: string; + slug: string; + href: string; + title: string; + excerpt: string; + image: string; + html: string; + categoryName?: string; + publishedAt?: string; + author?: string; +}; + +export type Paginated = { + items: T[]; + total: number; + page: number; + pageSize: number; +}; + +export type StoreSpecial = { + id: string; + key: string; + title: string; + isActive: boolean; + sortOrder: number; + items: SpecialItem[]; +}; + +export type SpecialItem = { + id: string; + productId: string; + productTitle: string; + productNameFa: string | null; + productImage: string | null; + isActive: boolean; + sortOrder: number; + variants: ProductVariant[]; +}; + +type Json = Record; + +function asRecord(value: unknown): Json | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Json) : null; +} + +function asString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} + +function asNumber(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() && !Number.isNaN(Number(value))) return Number(value); + return null; +} + +function pickString(record: Json | null, keys: string[]) { + if (!record) return ""; + for (const key of keys) { + const value = asString(record[key]); + if (value) return value; + } + return ""; +} + +function imageFromUnknown(value: unknown): string[] { + if (!value) return []; + if (typeof value === "string") return value ? [value] : []; + if (Array.isArray(value)) { + return value.flatMap((item) => { + if (typeof item === "string") return item ? [item] : []; + const record = asRecord(item); + const url = pickString(record, ["url", "src", "image", "thumbnailUrl", "imageUrl"]); + return url ? [url] : []; + }); + } + const record = asRecord(value); + const url = pickString(record, ["url", "src", "image", "thumbnailUrl", "imageUrl"]); + return url ? [url] : []; +} + +export function uniqueImages(...groups: unknown[]) { + const seen = new Set(); + const images: string[] = []; + for (const group of groups) { + for (const url of imageFromUnknown(group)) { + if (seen.has(url)) continue; + seen.add(url); + images.push(url); + } + } + return images; +} + +export function categoryLabel(category: Category) { + return category.nameFa || category.name; +} + +export function categoryDepth(category: Category, all: Category[], seen = new Set()): number { + if (!category.parentId) return 1; + if (seen.has(category.id)) return 1; + const parent = all.find((item) => item.id === category.parentId); + if (!parent) return 1; + seen.add(category.id); + return 1 + categoryDepth(parent, all, seen); +} + +export function categoryIdsWithDescendants(id: string, all: Category[]): string[] { + const ids = [id]; + for (const category of all) { + if (category.parentId === id) ids.push(...categoryIdsWithDescendants(category.id, all)); + } + return ids; +} + +export function buildMegaMenu(categories: Category[]): MegaColumn[] { + const active = categories.filter((category) => category.isActive !== false); + const byParent = new Map(); + for (const category of active) { + const key = category.parentId || ""; + const list = byParent.get(key) ?? []; + list.push(category); + byParent.set(key, list); + } + + const sortCats = (list: Category[]) => + [...list].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || categoryLabel(a).localeCompare(categoryLabel(b), "fa"), + ); + + const roots = sortCats(byParent.get("") ?? active.filter((category) => !category.parentId)); + const levelTwo = sortCats(roots.flatMap((root) => byParent.get(root.id) ?? [])); + const columns = levelTwo.length ? levelTwo : roots; + + return columns.map((column) => ({ + id: column.id, + name: categoryLabel(column), + href: `/products?categoryId=${column.id}`, + children: sortCats(byParent.get(column.id) ?? []).map((child) => ({ + id: child.id, + name: categoryLabel(child), + href: `/products?categoryId=${child.id}`, + })), + })); +} + +export function plainText(value: string, max = 160) { + const text = value + .replace(/<[^>]+>/g, " ") + .replace(/[*_#`]+/g, "") + .replace(/\s+/g, " ") + .trim(); + if (text.length <= max) return text; + return `${text.slice(0, max).trim()}…`; +} + +export function sanitizeHtml(html: string) { + return html + .replace(/[\s\S]*?<\/script>/gi, "") + .replace(/[\s\S]*?<\/style>/gi, "") + .replace(/\son\w+="[^"]*"/gi, "") + .replace(/\son\w+='[^']*'/gi, ""); +} + +const COLOR_HEX: Record = { + "زرد": "#FBBF24", + "سبز": "#34D399", + "صورتی": "#F9A8D4", + "بنفش": "#C4B5FD", + "یاسی": "#C4B5FD", + "آبی": "#67E8F9", + "فیروزه": "#22D3EE", + "طوسی": "#94A3B8", + "خاکستری": "#94A3B8", + "سفید": "#F8FAFC", + "مشکی": "#111827", + "سیاه": "#111827", + "قرمز": "#F43F5E", + "سرخابی": "#FB7185", + "نارنجی": "#FB923C", + "کرم": "#FDE68A", + "قهوه‌ای": "#92400E", + "قهوه ای": "#92400E", +}; + +export function colorHex(name: string) { + return COLOR_HEX[name.trim()] || (name.startsWith("#") ? name : "#C4B5FD"); +} + +function selectionValue(selections: unknown, names: string[]) { + if (!Array.isArray(selections)) return ""; + for (const item of selections) { + const record = asRecord(item); + const variation = pickString(record, ["variationName", "name"]); + if (names.some((name) => variation.includes(name))) { + return pickString(record, ["value", "optionValue"]); + } + } + return ""; +} + +function colorNamesFrom(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const names = new Set(); + for (const item of value) { + const record = asRecord(item); + const variation = pickString(record, ["variationName", "name"]); + if (variation.includes("رنگ")) { + const color = pickString(record, ["value", "optionValue"]); + if (color) names.add(color); + } + } + return [...names]; +} + +export function mapVariant(raw: unknown): ProductVariant { + const record = asRecord(raw) ?? {}; + const price = asNumber(record.discountedPrice) ?? asNumber(record.price); + const oldPrice = asNumber(record.discountedPrice) != null ? asNumber(record.price) : null; + const stock = asNumber(record.stockQuantity) ?? 0; + const unlimited = Boolean(record.unlimited); + return { + id: pickString(record, ["id", "variantId"]), + label: pickString(record, ["label", "nameFa", "name"]) || "تنوع", + price, + oldPrice: oldPrice ?? undefined, + stock: stock ?? 0, + inStock: typeof record.inStock === "boolean" ? record.inStock : unlimited || (stock ?? 0) > 0, + color: selectionValue(record.selections, ["رنگ"]) || undefined, + size: selectionValue(record.selections, ["سایز", "اندازه"]) || undefined, + }; +} + +function pickPricedVariant(variants: ProductVariant[]) { + const inStock = variants.filter((variant) => variant.inStock && variant.price != null); + const priced = variants.filter((variant) => variant.price != null); + const pool = inStock.length ? inStock : priced.length ? priced : variants; + return pool.reduce((best, next) => { + if (!best) return next; + const bestPrice = best.price ?? Number.POSITIVE_INFINITY; + const nextPrice = next.price ?? Number.POSITIVE_INFINITY; + return nextPrice < bestPrice ? next : best; + }, undefined); +} + +function discountBadge(price: number | null, oldPrice?: number | null) { + if (price == null || oldPrice == null || oldPrice <= price) return undefined; + return `${Math.round(((oldPrice - price) / oldPrice) * 100)}٪`; +} + +function toneFromId(id: string): Tone { + const tones: Tone[] = ["purple", "cyan", "rose"]; + let hash = 0; + for (const char of id) hash = (hash + char.charCodeAt(0)) % tones.length; + return tones[hash] ?? "purple"; +} + +export function mapStoreItem(raw: unknown, product?: Product): Product { + const record = asRecord(raw) ?? {}; + const nestedProduct = asRecord(record.product); + const variantList = Array.isArray(record.variants) ? record.variants.map(mapVariant) : []; + const selfVariant = record.price != null || record.discountedPrice != null || record.label ? mapVariant(record) : null; + const variants = variantList.length ? variantList : selfVariant ? [selfVariant] : []; + const chosen = pickPricedVariant(variants); + + const firstRawVariant = Array.isArray(record.variants) ? asRecord(record.variants[0]) : null; + const productId = + pickString(record, ["productId"]) || + pickString(nestedProduct, ["id"]) || + pickString(firstRawVariant, ["productId"]) || + product?.productId || + product?.id || + ""; + const id = pickString(record, ["id", "variantId"]) || productId; + const slug = product?.slug || pickString(nestedProduct, ["slug"]) || productId || id || "item"; + const title = + pickString(record, ["productNameFa", "productTitle", "nameFa", "titleFa", "title", "name"]) || + pickString(firstRawVariant, ["productNameFa", "productTitle", "nameFa", "title"]) || + pickString(nestedProduct, ["nameFa", "titleFa", "title", "name"]) || + product?.title || + "محصول"; + const images = uniqueImages( + record.productImage, + record.imageUrl, + record.thumbnailUrl, + record.image, + record.images, + firstRawVariant?.productImage, + firstRawVariant?.image, + nestedProduct?.thumbnailUrl, + nestedProduct?.image, + nestedProduct?.images, + product?.images, + product?.image, + ); + const colorNames = [ + ...new Set([ + ...colorNamesFrom(record.selections), + ...variants.map((variant) => variant.color).filter((value): value is string => Boolean(value)), + ...(product?.colorNames ?? []), + ]), + ]; + const price = chosen?.price ?? asNumber(record.discountedPrice) ?? asNumber(record.price) ?? product?.price ?? null; + const oldPrice = chosen?.oldPrice ?? (asNumber(record.discountedPrice) != null ? asNumber(record.price) : null) ?? product?.oldPrice; + const qty = asNumber(record.productTotalStock) ?? asNumber(record.stockQuantity); + const inStock = variants.some((variant) => variant.inStock) || (qty != null && qty > 0); + + return { + id: id || slug, + productId: productId || undefined, + slug, + href: `/products/${encodeURIComponent(slug)}`, + image: images[0] || product?.image || PLACEHOLDER_IMAGE, + images: images.length ? images : product?.images?.length ? product.images : [PLACEHOLDER_IMAGE], + title, + brand: product?.brand || pickString(asRecord(record.brand), ["nameFa", "name"]) || pickString(nestedProduct, ["brandName"]) || "نینی نادری", + price, + oldPrice: oldPrice ?? undefined, + colors: colorNames.map(colorHex), + colorNames, + tone: toneFromId(id || slug), + badge: discountBadge(price, oldPrice), + inStock, + categoryId: product?.categoryId || pickString(record, ["categoryId"]) || pickString(nestedProduct, ["categoryId"]) || undefined, + categoryName: + product?.categoryName || + pickString(record, ["categoryNameFa", "categoryName"]) || + pickString(nestedProduct, ["categoryNameFa", "categoryName"]) || + undefined, + summary: product?.summary || pickString(nestedProduct, ["summary"]) || pickString(record, ["summary"]), + descriptionHtml: product?.descriptionHtml || sanitizeHtml(pickString(nestedProduct, ["descriptionHtml"]) || pickString(record, ["descriptionHtml"])), + variants, + }; +} + +export function mapProduct(raw: unknown): Product { + const record = asRecord(raw) ?? {}; + const store = asRecord(record.store); + const brand = asRecord(record.brand); + const slug = pickString(record, ["slug", "id"]); + const images = uniqueImages(record.thumbnailUrl, record.image, record.thumbnail, record.images); + const price = asNumber(store?.minPrice) ?? asNumber(record.price); + + return { + id: pickString(record, ["id"]) || slug, + productId: pickString(record, ["id"]) || undefined, + slug, + href: `/products/${encodeURIComponent(slug)}`, + image: images[0] || PLACEHOLDER_IMAGE, + images: images.length ? images : [PLACEHOLDER_IMAGE], + title: pickString(record, ["nameFa", "titleFa", "title", "name"]) || "محصول", + brand: pickString(brand, ["nameFa", "name"]) || "نینی نادری", + price, + colors: [], + colorNames: [], + tone: toneFromId(slug), + inStock: typeof store?.inStock === "boolean" ? store.inStock : undefined, + categoryId: pickString(record, ["categoryId"]) || undefined, + categoryName: pickString(record, ["categoryNameFa", "categoryName"]) || undefined, + summary: pickString(record, ["summary"]), + descriptionHtml: sanitizeHtml(pickString(record, ["descriptionHtml"])), + }; +} + +export function mapSpecialItem(item: SpecialItem, product?: Product, badge?: string): Product { + const variants = item.variants ?? []; + const chosen = pickPricedVariant(variants); + const colorNames = [...new Set(variants.map((variant) => variant.color).filter((value): value is string => Boolean(value)))]; + const slug = product?.slug || item.productId; + const price = chosen?.price ?? product?.price ?? null; + const oldPrice = chosen?.oldPrice ?? product?.oldPrice; + + return { + id: item.productId, + productId: item.productId, + slug, + href: `/products/${encodeURIComponent(slug)}`, + image: item.productImage || product?.image || PLACEHOLDER_IMAGE, + images: uniqueImages(item.productImage, product?.images, product?.image), + title: item.productNameFa || item.productTitle || product?.title || "محصول", + brand: product?.brand || "نینی نادری", + price, + oldPrice: oldPrice ?? undefined, + colors: colorNames.map(colorHex), + colorNames, + tone: toneFromId(item.productId), + badge: badge || discountBadge(price, oldPrice), + inStock: variants.some((variant) => variant.inStock), + categoryId: product?.categoryId, + categoryName: product?.categoryName, + summary: product?.summary, + descriptionHtml: product?.descriptionHtml, + variants, + }; +} + +export function mapSpecial(raw: unknown): StoreSpecial { + const record = asRecord(raw) ?? {}; + const items = Array.isArray(record.items) ? record.items : []; + return { + id: pickString(record, ["id"]), + key: pickString(record, ["key"]), + title: pickString(record, ["title", "titleFa", "name"]), + isActive: record.isActive !== false, + sortOrder: asNumber(record.sortOrder) ?? 0, + items: items.map((item) => { + const row = asRecord(item) ?? {}; + return { + id: pickString(row, ["id"]), + productId: pickString(row, ["productId"]), + productTitle: pickString(row, ["productTitle", "title"]), + productNameFa: pickString(row, ["productNameFa"]) || null, + productImage: pickString(row, ["productImage", "image"]) || null, + isActive: row.isActive !== false, + sortOrder: asNumber(row.sortOrder) ?? 0, + variants: Array.isArray(row.variants) ? row.variants.map(mapVariant) : [], + }; + }), + }; +} + +export function mapBlog(raw: unknown): BlogPost { + const record = asRecord(raw) ?? {}; + const author = asRecord(record.author); + const slug = pickString(record, ["slug", "id"]); + const authorName = [asString(author?.firstName), asString(author?.lastName)].filter(Boolean).join(" "); + const html = sanitizeHtml(pickString(record, ["mainTextHtml", "contentHtml", "bodyHtml"])); + const excerptSource = pickString(record, ["abstract", "summary", "excerpt"]) || html; + + return { + id: pickString(record, ["id"]) || slug, + slug, + href: `/blog/${encodeURIComponent(slug)}`, + title: pickString(record, ["title", "titleFa", "name"]) || "مقاله", + excerpt: plainText(excerptSource, 180), + image: pickString(record, ["titleImageUrl", "image", "thumbnailUrl"]) || PLACEHOLDER_BLOG, + html, + categoryName: pickString(record, ["categoryNameFa", "categoryName"]) || undefined, + publishedAt: pickString(record, ["publishedAt", "createdAt"]) || undefined, + author: authorName || undefined, + }; +} + +export function mergeProduct(store: Product, product?: Product | null): Product { + if (!product) return store; + return { + ...store, + slug: product.slug || store.slug, + href: product.slug ? `/products/${encodeURIComponent(product.slug)}` : store.href, + title: store.title && store.title !== "محصول" ? store.title : product.title || store.title, + brand: product.brand || store.brand, + image: store.image !== PLACEHOLDER_IMAGE ? store.image : product.image, + images: uniqueImages(store.images, product.images), + categoryId: store.categoryId || product.categoryId, + categoryName: store.categoryName || product.categoryName, + summary: store.summary || product.summary, + descriptionHtml: store.descriptionHtml || product.descriptionHtml, + price: store.price ?? product.price, + oldPrice: store.oldPrice ?? product.oldPrice, + }; +} diff --git a/src/lib/format.ts b/src/lib/format.ts index 1df65bf..6e98238 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -4,6 +4,14 @@ export function toFaDigits(value: number | string) { return String(value).replace(/\d/g, (digit) => FA_DIGITS[Number(digit)]); } -export function formatPrice(value: number) { +export function formatPrice(value: number | null | undefined) { + if (value == null || Number.isNaN(value)) return "برای اطلاع از قیمت تماس بگیرید"; return `${toFaDigits(value.toLocaleString("en-US"))} تومان`; } + +export function formatDate(value?: string) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium" }).format(date); +} diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..78cc95c --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,11 @@ +export const SITE_DOMAIN = + process.env.NEXT_PUBLIC_MESHKEE_DOMAIN?.trim() || "nininaderi.com"; + +export const API_BASE = + process.env.NEXT_PUBLIC_MESHKEE_API_BASE?.trim() || + "https://api.meshkee.com/api/v1"; + +export const CUSTOMER_URL = `https://customer.${SITE_DOMAIN}`; + +export const SITE_NAME = "نینی نادری"; +export const SITE_NAME_EN = "Nini Naderi"; diff --git a/src/lib/static-images.ts b/src/lib/static-images.ts new file mode 100644 index 0000000..64ba562 --- /dev/null +++ b/src/lib/static-images.ts @@ -0,0 +1,151 @@ +import type { CSSProperties } from "react"; +import { API_BASE, SITE_DOMAIN } from "@/lib/site"; + +export { API_BASE, SITE_DOMAIN }; + +export const HOME_SLOT_KEYS = { + slider: "slider", + besideSlider: "two-banner-beside-slider", + categories: "categories", + specialSale: "special-sale-banner", + twoBannerRow: "two-banner-row", + threeBannerRow: "three-banner-row", + aboutUs: "about-us", +} as const; + +export const STATIC_IMAGE_SLOT_CATALOG = [ + { + key: HOME_SLOT_KEYS.slider, + label: "Homepage slider", + kind: "list" as const, + pageKey: "home", + aspectRatio: "16:7", + itemCount: null, + recommendedWidth: 960, + }, + { + key: HOME_SLOT_KEYS.besideSlider, + label: "Banners beside slider", + kind: "list" as const, + pageKey: "home", + aspectRatio: "9:5", + itemCount: 2, + recommendedWidth: 360, + }, + { + key: HOME_SLOT_KEYS.categories, + label: "Featured categories", + kind: "list" as const, + pageKey: "home", + aspectRatio: "1:1", + itemCount: null, + recommendedWidth: 160, + }, + { + key: HOME_SLOT_KEYS.specialSale, + label: "Special sale banner", + kind: "single" as const, + pageKey: "home", + aspectRatio: "3:5", + itemCount: 1, + recommendedWidth: 240, + }, + { + key: HOME_SLOT_KEYS.twoBannerRow, + label: "Two banner row", + kind: "list" as const, + pageKey: "home", + aspectRatio: "16:7", + itemCount: 2, + recommendedWidth: 640, + }, + { + key: HOME_SLOT_KEYS.threeBannerRow, + label: "Three banner row", + kind: "list" as const, + pageKey: "home", + aspectRatio: "7:4", + itemCount: 3, + recommendedWidth: 420, + }, + { + key: HOME_SLOT_KEYS.aboutUs, + label: "About us image", + kind: "single" as const, + pageKey: "home", + aspectRatio: "7:5", + itemCount: 1, + recommendedWidth: 560, + }, +]; + +export type StaticImage = { + id: string; + url: string; + title?: string | null; + titleFa?: string | null; + titleEn?: string | null; + subtext?: string | null; + linkUrl?: string | null; + width?: number | null; + height?: number | null; + sortOrder?: number; +}; + +export type StaticImageSlot = { + id: string; + key: string; + kind: "single" | "list"; + label: string; + aspectRatio?: string | null; + recommendedWidth?: number | null; + itemCount?: number | null; + pageKey?: string; + images: StaticImage[]; +}; + +export async function getHomeStaticImageSlots(): Promise { + try { + const response = await fetch( + `${API_BASE}/tenants/${SITE_DOMAIN}/website/static-images?pageKey=home`, + { next: { revalidate: 60 } }, + ); + if (!response.ok) return []; + const data = (await response.json()) as { items?: StaticImageSlot[] }; + return Array.isArray(data.items) ? data.items : []; + } catch { + return []; + } +} + +export function slotByKey(slots: StaticImageSlot[], key: string) { + return slots.find((slot) => slot.key === key); +} + +export function slotImages(slot?: StaticImageSlot) { + const images = slot?.images?.filter((image) => image.url) ?? []; + if (slot?.kind === "single") return images.slice(0, 1); + return images; +} + +export function padSlotImages(slot: StaticImageSlot | undefined, minCount: number) { + const images = slotImages(slot); + const count = Math.max(minCount, images.length); + return Array.from({ length: count }, (_, index) => images[index]); +} + +export function imageAlt(image: StaticImage | undefined, fallback: string) { + return image?.titleFa || image?.titleEn || image?.title || fallback; +} + +export function slotHref(image?: StaticImage) { + if (!image?.url) return undefined; + return image.linkUrl || undefined; +} + +export function aspectRatioStyle(ratio?: string | null): CSSProperties | undefined { + if (!ratio) return undefined; + const [width, height] = ratio.split(":"); + if (!width || !height) return undefined; + return { aspectRatio: `${width} / ${height}` }; +}