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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-18 11:22:31 +03:30
co-authored by Cursor
parent 1a2b62463e
commit c0a95a826a
34 changed files with 2458 additions and 401 deletions
+6 -1
View File
@@ -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;
-114
View File
@@ -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": [
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+11
View File
@@ -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);
}
+83
View File
@@ -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<Metadata> {
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 (
<SiteChrome>
<article className="container-nn max-w-4xl py-10">
<nav className="mb-6 text-xs text-muted">
<Link href="/" className="hover:text-purple-700">
صفحه اصلی
</Link>
<span className="mx-2">‹</span>
<Link href="/blog" className="hover:text-purple-700">
مجله
</Link>
<span className="mx-2">‹</span>
<span>{blog.title}</span>
</nav>
<p className="font-display text-sm tracking-wide text-cyan-600">Blog</p>
<h1 className="mt-1 text-2xl font-black leading-10 text-ink sm:text-3xl">{blog.title}</h1>
<p className="mt-3 text-sm text-muted">
{[blog.author, formatDate(blog.publishedAt), blog.categoryName].filter(Boolean).join(" • ")}
</p>
<span className="mt-4 block h-1.5 w-16 rounded-full bg-linear-to-l from-purple-600 via-cyan-500 to-rose-500" />
<div className="mt-6 overflow-hidden rounded-3xl">
<Placeholder src={blog.image} alt={blog.title} width={1200} height={640} rounded="rounded-3xl" />
</div>
{blog.html ? (
<div className="rich-content mt-8" dangerouslySetInnerHTML={{ __html: blog.html }} />
) : (
<p className="mt-8 text-sm leading-8 text-muted">{blog.excerpt}</p>
)}
{related.length > 0 ? (
<section className="mt-12">
<h2 className="mb-4 text-lg font-bold text-ink">مقالات مرتبط</h2>
<div className="grid gap-4 sm:grid-cols-3">
{related.map((item) => (
<Link
key={item.slug}
href={item.href}
className="glass-box overflow-hidden rounded-3xl shadow-soft"
>
<Placeholder src={item.image} alt={item.title} width={400} height={220} rounded="rounded-none" />
<p className="p-3 text-sm font-bold leading-6">{item.title}</p>
</Link>
))}
</div>
</section>
) : null}
</article>
</SiteChrome>
);
}
+62
View File
@@ -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 (
<SiteChrome>
<section className="container-nn py-10">
<p className="font-display text-sm tracking-wide text-cyan-600">Blog</p>
<h1 className="mt-1 text-3xl font-black text-ink">مجله نینی</h1>
<span className="mt-3 block h-1.5 w-16 rounded-full bg-linear-to-l from-purple-600 via-cyan-500 to-rose-500" />
{blogs.items.length === 0 ? (
<p className="mt-10 rounded-3xl bg-white/50 px-6 py-16 text-center text-muted">مقاله‌ای یافت نشد.</p>
) : (
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{blogs.items.map((post) => (
<Link
key={post.slug}
href={post.href}
className="glass-box overflow-hidden rounded-3xl shadow-soft transition hover:-translate-y-1 hover:shadow-card"
>
<Placeholder src={post.image} alt={post.title} width={640} height={360} rounded="rounded-none" />
<div className="p-4">
{post.publishedAt ? (
<p className="text-xs text-cyan-600">{formatDate(post.publishedAt)}</p>
) : null}
<h2 className="mt-2 line-clamp-2 min-h-12 text-base font-bold leading-6">{post.title}</h2>
<p className="mt-2 line-clamp-2 text-sm leading-7 text-muted">{post.excerpt}</p>
</div>
</Link>
))}
</div>
)}
<Pagination
page={blogs.page}
pageSize={blogs.pageSize}
total={blogs.total}
hrefFor={(nextPage) => (nextPage > 1 ? `/blog?page=${nextPage}` : "/blog")}
/>
</section>
</SiteChrome>
);
}
+132 -2
View File
@@ -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;
}
@@ -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",
},
},
);
}
+49 -40
View File
@@ -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 (
<div className="flex min-h-full flex-col">
<TopHeader />
<Header />
<main className="flex-1">
<Hero />
<FeaturedCategories />
<Features />
<ProductCarousel
id="sale"
english="Special Sale"
title="فروش ویژه نینی‌ها"
products={saleProducts}
countdown
leadBanner
/>
<DualBanners />
<ProductCarousel
english="Top Selling"
title="پرفروش‌ترین‌ها"
products={topProducts}
/>
<TripleBanners />
<ProductCarousel
id="yeganeh"
english="Yeganeh"
title="محصولات برند یگانه"
products={yeganehProducts}
/>
<About />
<BlogCarousel />
<Faq />
<Contact />
</main>
<Footer />
</div>
<SiteChrome>
<Hero
slider={slotByKey(slots, HOME_SLOT_KEYS.slider)}
beside={slotByKey(slots, HOME_SLOT_KEYS.besideSlider)}
/>
<FeaturedCategories slot={slotByKey(slots, HOME_SLOT_KEYS.categories)} />
<Features />
<ProductCarousel
id="sale"
english="Special Sale"
title={sale.title || "فروش ویژه نینی‌ها"}
products={sale.products}
countdown
leadBanner
bannerSlot={slotByKey(slots, HOME_SLOT_KEYS.specialSale)}
href="/special-sale"
/>
<DualBanners slot={slotByKey(slots, HOME_SLOT_KEYS.twoBannerRow)} />
<ProductCarousel
english="Top Selling"
title={top.title || "پرفروش‌ترین‌ها"}
products={top.products}
href="/products"
/>
<TripleBanners slot={slotByKey(slots, HOME_SLOT_KEYS.threeBannerRow)} />
<ProductCarousel
id="nini-naderi"
english="Nini Naderi"
title={own.title || "محصولات نینی نادری"}
products={own.products}
href="/products"
/>
<About slot={slotByKey(slots, HOME_SLOT_KEYS.aboutUs)} />
<BlogCarousel posts={blogs.items} />
<Faq />
<Contact />
</SiteChrome>
);
}
+138
View File
@@ -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<Metadata> {
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 (
<SiteChrome>
<section className="container-nn py-10">
<nav className="mb-6 text-xs text-muted">
<Link href="/" className="hover:text-purple-700">
صفحه اصلی
</Link>
<span className="mx-2">‹</span>
<Link href="/products" className="hover:text-purple-700">
محصولات
</Link>
{product.categoryId && product.categoryName ? (
<>
<span className="mx-2">‹</span>
<Link href={`/products?categoryId=${product.categoryId}`} className="hover:text-purple-700">
{product.categoryName}
</Link>
</>
) : null}
<span className="mx-2">‹</span>
<span>{product.title}</span>
</nav>
<div className="grid gap-10 lg:grid-cols-[1.05fr_1fr]">
<div className="glass-box rounded-3xl p-5">
<ProductGallery images={product.images} title={product.title} />
</div>
<div>
<p className="font-display text-sm text-cyan-600">{product.brand}</p>
<h1 className="mt-1 text-2xl font-black leading-10 text-ink sm:text-3xl">{product.title}</h1>
{product.categoryName ? <p className="mt-2 text-sm text-muted">{product.categoryName}</p> : null}
<div className="mt-6">
{product.oldPrice ? (
<p className="text-sm text-muted line-through">{formatPrice(product.oldPrice)}</p>
) : null}
<p className="text-2xl font-black text-purple-700">{formatPrice(product.price)}</p>
{product.inStock === false ? (
<p className="mt-2 text-sm text-rose-500">در حال حاضر موجود نیست</p>
) : null}
</div>
{product.colorNames.length ? (
<div className="mt-5">
<p className="text-sm font-bold">رنگ</p>
<div className="mt-2 flex flex-wrap gap-2">
{product.colorNames.map((name, index) => (
<span key={name} className="inline-flex items-center gap-2 rounded-full bg-white/70 px-3 py-1 text-xs">
<span
className="size-3 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
style={{ backgroundColor: product.colors[index] }}
/>
{name}
</span>
))}
</div>
</div>
) : null}
{product.variants && product.variants.length > 1 ? (
<div className="mt-5">
<p className="text-sm font-bold">تنوع‌ها</p>
<ul className="mt-2 space-y-1.5 text-sm text-muted">
{product.variants.map((variant) => (
<li key={variant.id} className="flex items-center justify-between rounded-2xl bg-white/60 px-3 py-2">
<span>{variant.label}</span>
<span className={variant.inStock ? "text-purple-700" : "text-rose-400"}>
{variant.inStock ? formatPrice(variant.price) : "ناموجود"}
</span>
</li>
))}
</ul>
</div>
) : null}
{product.price != null && product.price > 0 ? (
<a
href={CUSTOMER_URL}
className="mt-8 inline-flex rounded-full bg-purple-700 px-8 py-3 text-sm font-bold text-white hover:bg-purple-800"
>
خرید از حساب کاربری
</a>
) : null}
{product.summary ? <p className="mt-6 text-sm leading-8 text-muted">{product.summary}</p> : null}
</div>
</div>
{product.descriptionHtml ? (
<div className="glass-box mt-10 rounded-3xl p-6">
<h2 className="mb-4 text-lg font-bold text-ink">توضیحات محصول</h2>
<div className="rich-content" dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
</div>
) : null}
<ProductCarousel
english="Related"
title="محصولات مرتبط"
products={related}
href={product.categoryId ? `/products?categoryId=${product.categoryId}` : "/products"}
/>
</section>
</SiteChrome>
);
}
+122
View File
@@ -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<Search>;
}) {
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 (
<SiteChrome>
<section className="container-nn py-10">
<p className="font-display text-sm tracking-wide text-cyan-600">Products</p>
<h1 className="mt-1 text-3xl font-black text-ink">{title}</h1>
<span className="mt-3 block h-1.5 w-16 rounded-full bg-linear-to-l from-purple-600 via-cyan-500 to-rose-500" />
{columns.length ? (
<div className="mt-8 flex flex-wrap gap-2">
<Link
href="/products"
className={`rounded-full px-4 py-1.5 text-sm ${
!categoryId ? "bg-purple-700 text-white" : "bg-white/70 text-muted hover:text-purple-700"
}`}
>
همه
</Link>
{columns.map((column) => (
<Link
key={column.id}
href={column.href}
className={`rounded-full px-4 py-1.5 text-sm ${
categoryId === column.id ? "bg-purple-700 text-white" : "bg-white/70 text-muted hover:text-purple-700"
}`}
>
{column.name}
</Link>
))}
</div>
) : null}
{activeColumn?.children.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{activeColumn.children.map((child) => (
<Link
key={child.id}
href={child.href}
className={`rounded-full px-3 py-1 text-xs ${
categoryId === child.id ? "bg-cyan-600 text-white" : "bg-purple-50 text-muted hover:text-purple-700"
}`}
>
{child.name}
</Link>
))}
</div>
) : null}
{catalog.items.length === 0 ? (
<p className="mt-10 rounded-3xl bg-white/50 px-6 py-16 text-center text-muted">
محصولی در این بخش یافت نشد.
</p>
) : (
<div className="product-grid mt-8">
{catalog.items.map((product) => (
<ProductCard key={product.id} product={product} fill />
))}
</div>
)}
<Pagination
page={catalog.page}
pageSize={catalog.pageSize}
total={catalog.total}
hrefFor={hrefFor}
/>
</section>
</SiteChrome>
);
}
+34
View File
@@ -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 (
<SiteChrome>
<section className="container-nn py-10">
<p className="font-display text-sm tracking-wide text-cyan-600">Special Sale</p>
<h1 className="mt-1 text-3xl font-black text-ink">{sale.title || "فروش ویژه نینی‌ها"}</h1>
<span className="mt-3 block h-1.5 w-16 rounded-full bg-linear-to-l from-purple-600 via-cyan-500 to-rose-500" />
{sale.products.length ? (
<div className="product-grid mt-8">
{sale.products.map((product) => (
<ProductCard key={product.id} product={product} fill />
))}
</div>
) : (
<p className="mt-10 rounded-3xl bg-white/50 px-6 py-16 text-center text-muted">
محصول فروش ویژه‌ای پیدا نشد.
</p>
)}
</section>
</SiteChrome>
);
}
+22 -7
View File
@@ -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 (
<section id="about" className="container-nn py-10">
<div className="glass-box overflow-hidden rounded-[32px] shadow-soft">
@@ -18,19 +19,33 @@ export function About() {
<div className="mt-8 grid grid-cols-3 gap-3">
{about.stats.map((stat) => (
<div key={stat.label} className="rounded-2xl bg-purple-50 px-3 py-4 text-center">
<p className="font-display text-lg text-purple-700">{stat.value}</p>
<p className="mt-1 text-xs text-muted">{stat.label}</p>
<p className="flex items-baseline justify-center gap-1 text-purple-700">
<span
dir="ltr"
lang="en"
className="font-display text-[1.65rem] font-semibold leading-none tracking-tight"
>
{stat.value}
</span>
{stat.suffix ? (
<span className="text-[11px] font-medium text-purple-600">{stat.suffix}</span>
) : null}
</p>
<p className="mt-1.5 text-xs text-muted">{stat.label}</p>
</div>
))}
</div>
</div>
<div className="bg-purple-50 p-4 sm:p-6">
<Placeholder
src={about.image}
alt={about.title}
<SlotImage
image={slotImages(slot)[0]}
fallbackSrc={about.image}
fallbackAlt={about.title}
aspectRatio={slot?.aspectRatio}
width={560}
height={400}
rounded="rounded-[28px]"
fill
className="h-full min-h-[280px] object-cover"
/>
</div>
+41 -25
View File
@@ -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 (
<section className="container-nn py-4">
<div className="grid gap-4 md:grid-cols-2">
{dualBanners.map((banner) => (
<a key={banner.id} href="#" className="overflow-hidden rounded-3xl">
<Placeholder
src={banner.image}
alt={banner.title}
width={640}
height={280}
rounded="rounded-3xl"
/>
</a>
))}
{images.map((image, index) => {
const fallback = dualBanners[index] ?? dualBanners[0];
return (
<div key={image?.id ?? fallback.id} className="overflow-hidden rounded-3xl">
<SlotImage
image={image}
fallbackSrc={fallback.image}
fallbackAlt={fallback.title}
aspectRatio={slot?.aspectRatio}
width={640}
height={280}
rounded="rounded-3xl"
fallbackHref="/special-sale"
/>
</div>
);
})}
</div>
</section>
);
}
export function TripleBanners() {
export function TripleBanners({ slot }: { slot?: StaticImageSlot }) {
const images = padSlotImages(slot, 3);
return (
<section className="container-nn py-4">
<div className="grid gap-4 md:grid-cols-3">
{tripleBanners.map((banner) => (
<a key={banner.id} href="#" className="overflow-hidden rounded-3xl">
<Placeholder
src={banner.image}
alt={banner.title}
width={420}
height={240}
rounded="rounded-3xl"
/>
</a>
))}
{images.map((image, index) => {
const fallback = tripleBanners[index] ?? tripleBanners[0];
return (
<div key={image?.id ?? fallback.id} className="overflow-hidden rounded-3xl">
<SlotImage
image={image}
fallbackSrc={fallback.image}
fallbackAlt={fallback.title}
aspectRatio={slot?.aspectRatio}
width={420}
height={240}
rounded="rounded-3xl"
/>
</div>
);
})}
</div>
</section>
);
+24 -10
View File
@@ -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 (
<section id="blog" className="container-nn py-8">
<SectionHeading english="Tiny Stories" title="تازه‌های مجله نینی" />
<SectionHeading english="Tiny Stories" title="تازه‌های مجله نینی">
<Link href="/blog" className="text-sm font-bold text-purple-700 hover:text-purple-800">
مشاهده همه
</Link>
</SectionHeading>
<Carousel>
{posts.map((post) => (
<article
@@ -14,16 +22,22 @@ export function BlogCarousel() {
data-card
className="glass-box w-[280px] shrink-0 snap-start overflow-hidden rounded-3xl shadow-soft sm:w-[320px]"
>
<Placeholder src={post.image} alt={post.title} width={360} height={220} rounded="rounded-none" />
<Link href={post.href} className="block">
<Placeholder src={post.image} alt={post.title} width={360} height={220} rounded="rounded-none" />
</Link>
<div className="p-4">
<p className="text-xs text-cyan-600">{post.date}</p>
<h3 className="mt-2 line-clamp-2 min-h-12 text-base font-bold leading-6 text-ink">
{post.title}
</h3>
{post.publishedAt ? (
<p className="text-xs text-cyan-600">{formatDate(post.publishedAt)}</p>
) : null}
<Link href={post.href}>
<h3 className="mt-2 line-clamp-2 min-h-12 text-base font-bold leading-6 text-ink">
{post.title}
</h3>
</Link>
<p className="mt-2 line-clamp-2 text-sm leading-7 text-muted">{post.excerpt}</p>
<a href="#" className="mt-3 inline-block text-sm font-bold text-purple-700">
<Link href={post.href} className="mt-3 inline-block text-sm font-bold text-purple-700">
ادامه مطلب
</a>
</Link>
</div>
</article>
))}
+52 -10
View File
@@ -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 (
<section id="categories" className="container-nn py-8">
<SectionHeading english="Little Wardrobe" title="دسته‌بندی‌های محبوب" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8">
{categories.map((category) => (
{items.map((item) => (
<a
key={category.id}
href="#"
key={item.id}
href={item.href}
className="glass-box group rounded-3xl p-3 text-center shadow-soft transition hover:-translate-y-1"
>
<Placeholder
src={category.image}
alt={category.title}
<SlotImage
image={item.image}
fallbackSrc={item.fallbackSrc}
fallbackAlt={item.title}
aspectRatio={slot?.aspectRatio ?? "1:1"}
width={160}
height={160}
rounded="rounded-2xl"
unlinked
/>
<h3 className="mt-3 text-sm font-bold text-ink">{category.title}</h3>
<p className="text-xs text-muted">{toFaDigits(category.count)} مدل</p>
<h3 className="mt-3 text-sm font-bold text-ink">{item.title}</h3>
{item.subtext ? (
<p className="text-xs text-muted">{item.subtext}</p>
) : item.count != null ? (
<p className="text-xs text-muted">{toFaDigits(item.count)} مدل</p>
) : null}
</a>
))}
</div>
+35 -9
View File
@@ -48,26 +48,52 @@ export function Footer() {
</div>
</div>
</div>
<div className="border-t border-line">
<div className="container-nn flex flex-col items-center justify-between gap-2 py-4 text-xs text-muted sm:flex-row">
<p>© ۱۴۰۵ نینی نادری — nininaderi.com — همه حقوق محفوظ است.</p>
<p className="font-display text-purple-600">Made for little wardrobes</p>
<div className="border-t border-[#e5e5e5] bg-[#f7f7f7]">
<div className="container-nn flex flex-col items-center justify-between gap-3 py-4 text-xs text-[#999] sm:flex-row">
<p>© {new Date().getFullYear()} نینی نادری</p>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit group inline-flex items-center gap-2.5 px-2.5 py-1.5 text-[#999] transition-colors duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] hover:text-[#555]"
>
<span className="meshkee-credit__frame" aria-hidden>
<span className="meshkee-credit__line meshkee-credit__line--l" />
<span className="meshkee-credit__line meshkee-credit__line--t" />
<span className="meshkee-credit__line meshkee-credit__line--r" />
<span className="meshkee-credit__line meshkee-credit__line--b" />
</span>
<img
src="/images/meshkee-logo.png"
alt="Meshkee"
width={36}
height={32}
className="relative h-8 w-auto shrink-0 object-contain opacity-50 transition-opacity duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:opacity-100"
/>
<span className="relative flex flex-col leading-snug">
<span className="text-[11px]">Designed And Developed By</span>
<span className="text-[13px] font-semibold text-[#666] transition-colors duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:text-[#333]">
Meshkee E-Commerce Team
</span>
</span>
</a>
</div>
</div>
</footer>
);
}
function FooterList({ title, items }: { title: string; items: string[] }) {
function FooterList({ title, items }: { title: string; items: { href: string; label: string }[] }) {
return (
<div>
<h3 className="mb-3 text-sm font-bold">{title}</h3>
<ul className="space-y-2 text-sm text-muted">
{items.map((item) => (
<li key={item}>
<a href="#" className="hover:text-purple-700">
{item}
</a>
<li key={item.label}>
<Link href={item.href} className="hover:text-purple-700">
{item.label}
</Link>
</li>
))}
</ul>
+107 -67
View File
@@ -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() {
</span>
</Link>
<form className="relative mx-auto hidden min-w-0 flex-1 max-w-xl md:block">
<input
type="search"
placeholder="جستجوی لباس نوزاد، دخترانه، یگانه..."
className="h-11 w-full rounded-full border border-line bg-purple-50/60 pr-4 pl-11 text-sm outline-none transition focus:border-purple-500 focus:bg-white"
/>
<span className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted">
<IconSearch />
</span>
</form>
<SearchBox
className="mx-auto hidden min-w-0 flex-1 max-w-xl md:block"
inputClassName="h-11 w-full rounded-full border border-line bg-purple-50/60 pr-4 pl-11 text-sm outline-none transition focus:border-purple-500 focus:bg-white"
/>
<div className="ms-auto flex items-center gap-1 sm:gap-2">
<a href="#" className="hidden items-center gap-1.5 rounded-full px-3 py-2 text-sm text-ink hover:bg-purple-50 lg:inline-flex">
<a
href={CUSTOMER_URL}
className="grid size-10 place-items-center rounded-full text-ink hover:bg-purple-50 lg:hidden"
aria-label="ورود"
>
<IconUser />
</a>
<a
href={CUSTOMER_URL}
className="hidden items-center gap-1.5 rounded-full px-3 py-2 text-sm text-ink hover:bg-purple-50 lg:inline-flex"
>
<IconUser />
ورود
</a>
<a href="#" className="relative grid size-10 place-items-center rounded-full text-ink hover:bg-purple-50" aria-label="علاقه‌مندی">
<a
href={CUSTOMER_URL}
className="relative grid size-10 place-items-center rounded-full text-ink hover:bg-purple-50"
aria-label="علاقه‌مندی"
>
<IconHeart />
<span className="absolute top-1 left-1 grid size-4 place-items-center rounded-full bg-rose-500 text-[10px] text-white">
۲
</span>
</a>
<a href="#" className="relative grid size-10 place-items-center rounded-full text-ink hover:bg-purple-50" aria-label="سبد خرید">
<a
href={CUSTOMER_URL}
className="relative grid size-10 place-items-center rounded-full text-ink hover:bg-purple-50"
aria-label="سبد خرید"
>
<IconBag />
<span className="absolute top-1 left-1 grid size-4 place-items-center rounded-full bg-cyan-500 text-[10px] text-white">
۳
</span>
</a>
</div>
</div>
<form className="pb-3 md:hidden">
<div className="relative">
<input
type="search"
placeholder="جستجو در نینی نادری"
className="h-10 w-full rounded-full border border-line bg-purple-50/60 pr-4 pl-10 text-sm outline-none"
/>
<span className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted">
<IconSearch className="size-4" />
</span>
</div>
</form>
<div className="pb-3 md:hidden">
<SearchBox
placeholder="جستجو در نینی نادری"
inputClassName="h-10 w-full rounded-full border border-line bg-purple-50/60 pr-4 pl-10 text-sm outline-none"
onNavigate={closeAll}
/>
</div>
<nav className="relative hidden h-12 items-center gap-6 text-sm font-medium text-ink lg:flex">
<div
@@ -99,16 +106,16 @@ export function Header() {
دسته‌بندی‌ها
<IconChevronDown className={`size-4 transition ${mega ? "rotate-180" : ""}`} />
</button>
{mega ? <MegaMenu onPick={closeAll} /> : null}
{mega ? <MegaMenu categories={categories} onPick={closeAll} /> : null}
</div>
{navLinks.map((link) => (
<a
<Link
key={link.label}
href={link.href}
className="relative py-3 transition hover:text-purple-700 after:absolute after:right-0 after:bottom-1 after:h-0.5 after:w-0 after:bg-linear-to-l after:from-purple-600 after:to-cyan-500 after:transition-all hover:after:w-full"
>
{link.label}
</a>
</Link>
))}
</nav>
</div>
@@ -125,29 +132,43 @@ export function Header() {
<IconChevronDown className={`size-4 transition ${mobileCats ? "rotate-180" : ""}`} />
</button>
{mobileCats ? (
<div className="grid grid-cols-2 gap-2 border-b border-line/70 py-3">
{categories.map((category) => (
<a
key={category.id}
href="#categories"
onClick={closeAll}
className="rounded-2xl bg-purple-50/70 px-3 py-2 text-xs font-bold"
>
{category.title}
</a>
<div className="space-y-3 border-b border-line/70 py-3">
{categories.map((column) => (
<div key={column.id}>
<Link href={column.href} onClick={closeAll} className="block text-sm font-bold text-purple-700">
{column.name}
</Link>
{column.children.length ? (
<div className="mt-2 grid grid-cols-2 gap-2">
{column.children.map((child) => (
<Link
key={child.id}
href={child.href}
onClick={closeAll}
className="rounded-2xl bg-purple-50/70 px-3 py-2 text-xs font-bold"
>
{child.name}
</Link>
))}
</div>
) : null}
</div>
))}
</div>
) : null}
{navLinks.map((link) => (
<a
<Link
key={link.label}
href={link.href}
onClick={closeAll}
className="border-b border-line/70 py-3 text-ink"
>
{link.label}
</a>
</Link>
))}
<a href={CUSTOMER_URL} onClick={closeAll} className="py-3 text-ink">
ورود / ثبت نام
</a>
</nav>
</div>
) : null}
@@ -155,32 +176,51 @@ export function Header() {
);
}
function MegaMenu({ onPick }: { onPick: () => void }) {
function MegaMenu({ categories, onPick }: { categories: MegaColumn[]; onPick: () => void }) {
return (
<div className="absolute top-full right-0 z-50 w-[min(40rem,calc(100vw-2rem))] pt-2">
<div className="absolute top-full right-0 z-50 w-[min(52rem,calc(100vw-2rem))] pt-2">
<div className="rounded-3xl border border-white/80 bg-white/95 p-5 shadow-card backdrop-blur-[80px]">
<div className="mb-4 flex items-end justify-between">
<div>
<p className="font-display text-xs text-cyan-600">Categories</p>
<p className="text-base font-bold">خرید بر اساس دسته</p>
</div>
<a href="#categories" onClick={onPick} className="text-xs font-medium text-purple-700">
<Link href="/products" onClick={onPick} className="text-xs font-medium text-purple-700">
مشاهده همه
</a>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{categories.map((category) => (
<a
key={category.id}
href="#categories"
onClick={onPick}
className="rounded-2xl px-3 py-3 text-right transition hover:bg-white/45"
>
<p className="text-sm font-bold text-ink">{category.title}</p>
<p className="mt-1 text-[11px] text-muted">{toFaDigits(category.count)} مدل</p>
</a>
))}
</Link>
</div>
{categories.length ? (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{categories.map((column) => (
<div key={column.id}>
<Link
href={column.href}
onClick={onPick}
className="block text-sm font-bold text-purple-700 hover:text-purple-800"
>
{column.name}
</Link>
{column.children.length ? (
<ul className="mt-2 space-y-1.5">
{column.children.map((child) => (
<li key={child.id}>
<Link
href={child.href}
onClick={onPick}
className="block rounded-xl px-2 py-1 text-sm text-muted transition hover:bg-purple-50 hover:text-ink"
>
{child.name}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</div>
) : (
<p className="text-sm text-muted">دسته‌بندی‌ها به‌زودی اینجا می‌آیند.</p>
)}
</div>
</div>
);
+115 -48
View File
@@ -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 ? (
<SlotImage
image={current.image}
fallbackSrc={current.fallbackSrc}
fallbackAlt={current.alt}
width={960}
height={420}
rounded="rounded-3xl"
fill
unlinked
className="aspect-[960/420] h-full w-full lg:aspect-auto"
/>
) : null;
return (
<section className="container-nn py-5 md:py-7">
<div className="grid gap-4 lg:grid-cols-12 lg:grid-rows-2">
<div className="relative overflow-hidden rounded-3xl lg:col-span-8 lg:row-span-2">
<button
type="button"
key={slide.id}
className="slider-fade block h-full w-full"
onClick={() => setIndex((current) => (current + 1) % slides.length)}
aria-label="اسلاید بعد"
>
<Placeholder
src={slide.image}
alt={slide.title}
width={960}
height={420}
rounded="rounded-3xl"
fill
className="aspect-[960/420] h-full w-full lg:aspect-auto"
/>
</button>
<div className="pointer-events-none absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
{slides.map((item, itemIndex) => (
<span
key={item.id}
className={`h-2 rounded-full ${
itemIndex === index ? "w-7 bg-white" : "w-2 bg-white/55"
}`}
/>
))}
</div>
<div
className="relative overflow-hidden rounded-3xl lg:col-span-8 lg:row-span-2 lg:h-full lg:!aspect-auto"
style={aspectRatioStyle(slider?.aspectRatio)}
>
{current?.href ? (
<a
href={current.href}
className="slider-fade block h-full w-full"
aria-label={current.alt || "اسلاید"}
>
{media}
</a>
) : (
<button
type="button"
key={current?.id}
className="slider-fade block h-full w-full"
onClick={next}
aria-label="اسلاید بعد"
>
{media}
</button>
)}
{items.length > 1 ? (
<div className="pointer-events-none absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
{items.map((item, itemIndex) => (
<span
key={item.id}
className={`h-2 rounded-full ${
itemIndex === index ? "w-7 bg-white" : "w-2 bg-white/55"
}`}
/>
))}
</div>
) : null}
</div>
{sideBanners.map((banner) => (
<a
key={banner.id}
href="#"
className="overflow-hidden rounded-3xl lg:col-span-4"
>
<Placeholder
src={banner.image}
alt={banner.title}
width={360}
height={200}
rounded="rounded-3xl"
/>
</a>
))}
{padSlotImages(beside, 2)
.slice(0, 2)
.map((image, itemIndex) => {
const fallback = sideBanners[itemIndex] ?? sideBanners[0];
return (
<div
key={image?.id ?? fallback.id}
className="overflow-hidden rounded-3xl lg:col-span-4 lg:h-full lg:!aspect-auto"
style={aspectRatioStyle(beside?.aspectRatio)}
>
<SlotImage
image={image}
fallbackSrc={fallback.image}
fallbackAlt={fallback.title}
aspectRatio={beside?.aspectRatio}
width={360}
height={200}
rounded="rounded-3xl"
fill
className="h-full w-full"
/>
</div>
);
})}
</div>
</section>
);
+8
View File
@@ -150,6 +150,14 @@ export function IconMenu({ className = "size-6" }: IconProps) {
);
}
export function IconPlus({ className = "size-5" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function IconClose({ className = "size-6" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden>
+41
View File
@@ -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 (
<nav className="mt-10 flex flex-wrap items-center justify-center gap-2" aria-label="صفحه‌بندی">
{page > 1 ? (
<Link href={hrefFor(page - 1)} className="rounded-full border border-line px-3 py-1.5 text-sm hover:bg-purple-50">
قبلی
</Link>
) : null}
{items.map((item) => (
<Link
key={item}
href={hrefFor(item)}
className={`grid size-9 place-items-center rounded-full text-sm ${
item === page ? "bg-purple-700 text-white" : "border border-line hover:bg-purple-50"
}`}
>
{item}
</Link>
))}
{page < pages ? (
<Link href={hrefFor(page + 1)} className="rounded-full border border-line px-3 py-1.5 text-sm hover:bg-purple-50">
بعدی
</Link>
) : null}
</nav>
);
}
+46 -29
View File
@@ -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 (
<article
data-card
className="glass-box w-[220px] shrink-0 snap-start rounded-3xl p-2.5 shadow-soft transition hover:-translate-y-1 hover:shadow-card sm:w-[240px]"
className={`glass-box rounded-3xl p-2.5 shadow-soft transition hover:-translate-y-1 hover:shadow-card ${
fill ? "w-full" : "w-[280px] shrink-0 snap-start sm:w-[300px]"
}`}
>
<Placeholder
src={product.image}
alt={product.title}
width={280}
height={340}
rounded="rounded-2xl"
/>
<Link href={product.href} className="block">
<Placeholder
src={product.image}
alt={product.title}
width={360}
height={420}
rounded="rounded-2xl"
/>
</Link>
<div className="px-1.5 pt-3 pb-1">
<div className="flex items-center justify-between gap-2">
<p className="font-display text-[11px] text-cyan-600">{product.brand}</p>
{product.badge ? (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold text-white ${
product.badge === "یگانه"
product.badge === "یگانه" || product.badge === "نینی"
? "bg-purple-700"
: product.badge === "پرفروش"
? "bg-cyan-600"
@@ -36,18 +46,22 @@ export function ProductCard({ product }: Props) {
</span>
) : null}
</div>
<h3 className="mt-0.5 line-clamp-2 min-h-11 text-sm font-bold leading-6 text-ink">
{product.title}
</h3>
<div className="mt-2 flex items-center gap-1.5">
{product.colors.map((color) => (
<span
key={color}
className="size-3.5 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
style={{ backgroundColor: color }}
/>
))}
</div>
<Link href={product.href}>
<h3 className="mt-0.5 line-clamp-2 min-h-11 text-sm font-bold leading-6 text-ink">
{product.title}
</h3>
</Link>
{product.colors.length ? (
<div className="mt-2 flex items-center gap-1.5">
{product.colors.slice(0, 5).map((color) => (
<span
key={color}
className="size-3.5 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
style={{ backgroundColor: color }}
/>
))}
</div>
) : null}
<div className="mt-3 flex items-end justify-between gap-2">
<div>
{product.oldPrice ? (
@@ -55,12 +69,15 @@ export function ProductCard({ product }: Props) {
) : null}
<p className="text-sm font-black text-purple-700">{formatPrice(product.price)}</p>
</div>
<button
type="button"
className="rounded-full bg-purple-700 px-3 py-1.5 text-xs font-bold text-white hover:bg-purple-800"
>
افزودن
</button>
{canAdd ? (
<a
href={CUSTOMER_URL}
aria-label="افزودن به سبد خرید"
className="grid size-9 place-items-center rounded-full bg-purple-700 text-white hover:bg-purple-800"
>
<IconPlus className="size-4" />
</a>
) : null}
</div>
</div>
</article>
+32 -13
View File
@@ -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 (
<section id={id} className="container-nn py-8">
<SectionHeading english={english} title={title}>
{countdown ? <SaleTimer /> : null}
{href ? (
<Link href={href} className="text-sm font-bold text-purple-700 hover:text-purple-800">
مشاهده همه
</Link>
) : null}
</SectionHeading>
<Carousel>
{leadBanner ? (
<a
href="#"
<div
data-card
className="flex w-[220px] min-h-[280px] shrink-0 snap-start self-stretch overflow-hidden rounded-3xl sm:w-[240px]"
className="flex w-[280px] min-h-[320px] shrink-0 snap-start self-stretch overflow-hidden rounded-3xl sm:w-[300px]"
aria-label="بنر فروش ویژه"
>
<Placeholder
src="/images/sale-banner.png"
alt=""
<SlotImage
image={saleBanner}
fallbackSrc="/images/sale-banner.png"
fallbackAlt="فروش ویژه"
aspectRatio={bannerSlot?.aspectRatio}
width={240}
height={400}
rounded="rounded-3xl"
fill
className="h-full w-full"
fallbackHref="/special-sale"
/>
</a>
</div>
) : null}
{products.map((product) => (
<ProductCard key={product.id} product={product} />
@@ -94,11 +109,15 @@ function SaleTimer() {
{cells.map((cell, index) => (
<div key={cell.label} className="flex items-end gap-1.5">
{index > 0 ? (
<span className="font-count mb-5 text-xl leading-none text-rose-400">:</span>
<span className="font-display mb-5 text-xl leading-none text-rose-400">:</span>
) : null}
<div className="min-w-[3.25rem] rounded-2xl bg-linear-to-b from-rose-100 to-rose-50 px-2.5 py-2 text-center shadow-[inset_0_1px_0_rgb(255_255_255_/_0.8)]">
<p className="font-count text-[1.65rem] leading-none text-rose-600">
{toFaDigits(String(cell.value).padStart(2, "0"))}
<p
dir="ltr"
lang="en"
className="font-display text-[1.65rem] font-semibold leading-none tracking-tight text-rose-600"
>
{String(cell.value).padStart(2, "0")}
</p>
<p className="mt-1 text-[10px] font-medium text-rose-400">{cell.label}</p>
</div>
+34
View File
@@ -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 (
<div>
<Placeholder src={current} alt={title} width={720} height={720} rounded="rounded-3xl" />
{gallery.length > 1 ? (
<div className="mt-3 flex flex-wrap gap-2">
{gallery.slice(0, 8).map((image, index) => (
<button
key={`${image}-${index}`}
type="button"
onClick={() => setActive(index)}
className={`overflow-hidden rounded-2xl ${
index === active ? "ring-2 ring-purple-600" : "ring-1 ring-line"
}`}
>
<Placeholder src={image} alt="" width={72} height={72} rounded="rounded-none" className="size-[72px] object-cover" />
</button>
))}
</div>
) : null}
</div>
);
}
+170
View File
@@ -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<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const [blogs, setBlogs] = useState<BlogPost[]>([]);
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 (
<div ref={root} className={`relative min-w-0 ${className}`}>
<form action="/products" className="relative">
<input
name="q"
value={query}
onChange={(event) => 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}
/>
<span className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted">
<IconSearch />
</span>
</form>
{open && hasQuery ? (
<div
id={listId}
className="absolute inset-x-0 top-[calc(100%+8px)] z-50 max-h-96 overflow-auto rounded-3xl border border-white/80 bg-white/95 p-2 shadow-card backdrop-blur-[80px]"
>
{loading && !products.length && !blogs.length ? (
<p className="px-3 py-4 text-sm text-muted">در حال جستجو...</p>
) : null}
{empty ? <p className="px-3 py-4 text-sm text-muted">موردی پیدا نشد</p> : null}
{products.length > 0 ? (
<section>
<h3 className="px-3 py-2 text-xs font-semibold text-muted">محصولات</h3>
{products.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => {
setOpen(false);
onNavigate?.();
}}
className="flex items-center gap-3 rounded-2xl px-3 py-2 hover:bg-purple-50"
>
<span className="size-12 shrink-0 overflow-hidden rounded-xl">
<Placeholder src={item.image} alt="" width={48} height={48} rounded="rounded-none" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink">{item.title}</span>
<span className="mt-0.5 block text-xs text-purple-700">{formatPrice(item.price)}</span>
</span>
</Link>
))}
</section>
) : null}
{blogs.length > 0 ? (
<section>
<h3 className="px-3 py-2 text-xs font-semibold text-muted">مجله</h3>
{blogs.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => {
setOpen(false);
onNavigate?.();
}}
className="flex items-center gap-3 rounded-2xl px-3 py-2 hover:bg-purple-50"
>
<span className="size-12 shrink-0 overflow-hidden rounded-xl">
<Placeholder src={item.image} alt="" width={48} height={48} rounded="rounded-none" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-bold text-ink">{item.title}</span>
</Link>
))}
</section>
) : null}
<Link
href={`/products?q=${encodeURIComponent(query.trim())}`}
onClick={() => {
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"
>
مشاهده همه نتایج
</Link>
</div>
) : null}
</div>
);
}
+2 -11
View File
@@ -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 (
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
@@ -15,15 +14,7 @@ export function SectionHeading({ english, title, href = "#", children }: Props)
<h2 className="mt-1 text-2xl font-bold text-ink sm:text-3xl">{title}</h2>
<span className="mt-2 block h-1.5 w-16 rounded-full bg-linear-to-l from-purple-600 via-cyan-500 to-rose-500" />
</div>
<div className="flex items-center gap-4">
{children}
<a
href={href}
className="hidden text-sm font-medium text-purple-700 transition hover:text-rose-600 sm:inline-flex"
>
مشاهده همه
</a>
</div>
{children ? <div className="flex items-center gap-4">{children}</div> : null}
</div>
);
}
+18
View File
@@ -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 (
<div className="flex min-h-full flex-col">
<TopHeader />
<Header categories={categories} />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
+52
View File
@@ -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 = (
<img
src={src}
alt={alt}
width={width}
height={height}
className={`block w-full object-cover ${fill ? "h-full" : "h-auto"} ${rounded} ${className}`}
style={fill ? undefined : aspectRatioStyle(aspectRatio)}
/>
);
if (!href) return img;
return (
<a href={href} className={`block ${fill ? "h-full w-full" : ""}`}>
{img}
</a>
);
}
+32 -14
View File
@@ -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: "۰۹۱۲۳۴۵۶۷۸۹",
+288
View File
@@ -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<string, unknown>;
function tenantUrl(path: string) {
const normalized = path.startsWith("/") ? path : `/${path}`;
return `${API_BASE}/tenants/${SITE_DOMAIN}${normalized}`;
}
async function apiGet<T>(path: string, cache: RequestCache = "force-cache"): Promise<T | null> {
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<string, string | number | boolean | undefined>) {
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<T>(
data: { items?: T[]; total?: number; page?: number; pageSize?: number } | null,
pageSize: number,
): Paginated<T> {
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<Category[]> {
const data = await apiGet<{ items?: Category[] }>(`/categories${query({ entityType })}`);
return Array.isArray(data?.items) ? data.items : [];
}
export async function getMegaMenuColumns(): Promise<MegaColumn[]> {
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<Paginated<Product>> {
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<Product | null> {
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<Product | null> {
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<Paginated<Product>> {
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<Product | null> {
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<string, Product>();
const bySlug = new Map<string, Product>();
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<Paginated<Product>> {
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<string>();
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<Product | null> {
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<Paginated<BlogPost>> {
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<BlogPost | null> {
const data = await apiGet<{ blog?: Json }>(`/blogs/${encodeURIComponent(slug)}`);
if (!data?.blog) return null;
return mapBlog(data.blog);
}
export async function getStoreSpecials(): Promise<StoreSpecial[]> {
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<string>();
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 };
}
+517
View File
@@ -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<T> = {
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<string, unknown>;
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<string>();
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<string>()): 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<string, Category[]>();
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(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "")
.replace(/\son\w+="[^"]*"/gi, "")
.replace(/\son\w+='[^']*'/gi, "");
}
const COLOR_HEX: Record<string, string> = {
"زرد": "#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<string>();
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<ProductVariant | undefined>((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,
};
}
+9 -1
View File
@@ -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);
}
+11
View File
@@ -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";
+151
View File
@@ -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<StaticImageSlot[]> {
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}` };
}