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:
co-authored by
Cursor
parent
1a2b62463e
commit
c0a95a826a
+6
-1
@@ -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;
|
||||
|
||||
Generated
-114
@@ -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": [
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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}` };
|
||||
}
|
||||
Reference in New Issue
Block a user