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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: "https", hostname: "c804387.parspack.net" },
|
||||||
|
{ protocol: "https", hostname: "api.meshkee.com" },
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
-114
@@ -631,9 +631,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -650,9 +647,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -669,9 +663,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -688,9 +679,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -707,9 +695,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -726,9 +711,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -745,9 +727,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -764,9 +743,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -783,9 +759,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -808,9 +781,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -833,9 +803,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -858,9 +825,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -883,9 +847,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -908,9 +869,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -933,9 +891,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -958,9 +913,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1228,9 +1180,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1247,9 +1196,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1266,9 +1212,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1285,9 +1228,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1526,9 +1466,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1546,9 +1483,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1566,9 +1500,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1586,9 +1517,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2139,9 +2067,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2156,9 +2081,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2173,9 +2095,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2190,9 +2109,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2207,9 +2123,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2224,9 +2137,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2241,9 +2151,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2258,9 +2165,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2275,9 +2179,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2292,9 +2193,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4979,9 +4877,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5003,9 +4898,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5027,9 +4919,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5051,9 +4940,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"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 {
|
.container-nn {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1280px;
|
max-width: 1680px;
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.container-nn {
|
.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 {
|
.placeholder-grid {
|
||||||
background-image: linear-gradient(135deg, rgb(255 255 255 / 0.22) 0%, transparent 48%);
|
background-image: linear-gradient(135deg, rgb(255 255 255 / 0.22) 0%, transparent 48%);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
@@ -123,6 +135,42 @@ body {
|
|||||||
display: none;
|
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 {
|
.slider-fade {
|
||||||
animation: slider-in 520ms ease;
|
animation: slider-in 520ms ease;
|
||||||
}
|
}
|
||||||
@@ -137,3 +185,85 @@ body {
|
|||||||
transform: translateX(0);
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+35
-26
@@ -5,49 +5,58 @@ import { Contact } from "@/components/Contact";
|
|||||||
import { Faq } from "@/components/Faq";
|
import { Faq } from "@/components/Faq";
|
||||||
import { FeaturedCategories } from "@/components/FeaturedCategories";
|
import { FeaturedCategories } from "@/components/FeaturedCategories";
|
||||||
import { Features } from "@/components/Features";
|
import { Features } from "@/components/Features";
|
||||||
import { Footer } from "@/components/Footer";
|
|
||||||
import { Header } from "@/components/Header";
|
|
||||||
import { Hero } from "@/components/Hero";
|
import { Hero } from "@/components/Hero";
|
||||||
import { ProductCarousel } from "@/components/ProductCarousel";
|
import { ProductCarousel } from "@/components/ProductCarousel";
|
||||||
import { TopHeader } from "@/components/TopHeader";
|
import { SiteChrome } from "@/components/SiteChrome";
|
||||||
import { saleProducts, topProducts, yeganehProducts } from "@/data/content";
|
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 (
|
return (
|
||||||
<div className="flex min-h-full flex-col">
|
<SiteChrome>
|
||||||
<TopHeader />
|
<Hero
|
||||||
<Header />
|
slider={slotByKey(slots, HOME_SLOT_KEYS.slider)}
|
||||||
<main className="flex-1">
|
beside={slotByKey(slots, HOME_SLOT_KEYS.besideSlider)}
|
||||||
<Hero />
|
/>
|
||||||
<FeaturedCategories />
|
<FeaturedCategories slot={slotByKey(slots, HOME_SLOT_KEYS.categories)} />
|
||||||
<Features />
|
<Features />
|
||||||
<ProductCarousel
|
<ProductCarousel
|
||||||
id="sale"
|
id="sale"
|
||||||
english="Special Sale"
|
english="Special Sale"
|
||||||
title="فروش ویژه نینیها"
|
title={sale.title || "فروش ویژه نینیها"}
|
||||||
products={saleProducts}
|
products={sale.products}
|
||||||
countdown
|
countdown
|
||||||
leadBanner
|
leadBanner
|
||||||
|
bannerSlot={slotByKey(slots, HOME_SLOT_KEYS.specialSale)}
|
||||||
|
href="/special-sale"
|
||||||
/>
|
/>
|
||||||
<DualBanners />
|
<DualBanners slot={slotByKey(slots, HOME_SLOT_KEYS.twoBannerRow)} />
|
||||||
<ProductCarousel
|
<ProductCarousel
|
||||||
english="Top Selling"
|
english="Top Selling"
|
||||||
title="پرفروشترینها"
|
title={top.title || "پرفروشترینها"}
|
||||||
products={topProducts}
|
products={top.products}
|
||||||
|
href="/products"
|
||||||
/>
|
/>
|
||||||
<TripleBanners />
|
<TripleBanners slot={slotByKey(slots, HOME_SLOT_KEYS.threeBannerRow)} />
|
||||||
<ProductCarousel
|
<ProductCarousel
|
||||||
id="yeganeh"
|
id="nini-naderi"
|
||||||
english="Yeganeh"
|
english="Nini Naderi"
|
||||||
title="محصولات برند یگانه"
|
title={own.title || "محصولات نینی نادری"}
|
||||||
products={yeganehProducts}
|
products={own.products}
|
||||||
|
href="/products"
|
||||||
/>
|
/>
|
||||||
<About />
|
<About slot={slotByKey(slots, HOME_SLOT_KEYS.aboutUs)} />
|
||||||
<BlogCarousel />
|
<BlogCarousel posts={blogs.items} />
|
||||||
<Faq />
|
<Faq />
|
||||||
<Contact />
|
<Contact />
|
||||||
</main>
|
</SiteChrome>
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 (
|
return (
|
||||||
<section id="about" className="container-nn py-10">
|
<section id="about" className="container-nn py-10">
|
||||||
<div className="glass-box overflow-hidden rounded-[32px] shadow-soft">
|
<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">
|
<div className="mt-8 grid grid-cols-3 gap-3">
|
||||||
{about.stats.map((stat) => (
|
{about.stats.map((stat) => (
|
||||||
<div key={stat.label} className="rounded-2xl bg-purple-50 px-3 py-4 text-center">
|
<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="flex items-baseline justify-center gap-1 text-purple-700">
|
||||||
<p className="mt-1 text-xs text-muted">{stat.label}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-50 p-4 sm:p-6">
|
<div className="bg-purple-50 p-4 sm:p-6">
|
||||||
<Placeholder
|
<SlotImage
|
||||||
src={about.image}
|
image={slotImages(slot)[0]}
|
||||||
alt={about.title}
|
fallbackSrc={about.image}
|
||||||
|
fallbackAlt={about.title}
|
||||||
|
aspectRatio={slot?.aspectRatio}
|
||||||
width={560}
|
width={560}
|
||||||
height={400}
|
height={400}
|
||||||
rounded="rounded-[28px]"
|
rounded="rounded-[28px]"
|
||||||
|
fill
|
||||||
className="h-full min-h-[280px] object-cover"
|
className="h-full min-h-[280px] object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+33
-17
@@ -1,41 +1,57 @@
|
|||||||
import { dualBanners, tripleBanners } from "@/data/content";
|
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 (
|
return (
|
||||||
<section className="container-nn py-4">
|
<section className="container-nn py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{dualBanners.map((banner) => (
|
{images.map((image, index) => {
|
||||||
<a key={banner.id} href="#" className="overflow-hidden rounded-3xl">
|
const fallback = dualBanners[index] ?? dualBanners[0];
|
||||||
<Placeholder
|
return (
|
||||||
src={banner.image}
|
<div key={image?.id ?? fallback.id} className="overflow-hidden rounded-3xl">
|
||||||
alt={banner.title}
|
<SlotImage
|
||||||
|
image={image}
|
||||||
|
fallbackSrc={fallback.image}
|
||||||
|
fallbackAlt={fallback.title}
|
||||||
|
aspectRatio={slot?.aspectRatio}
|
||||||
width={640}
|
width={640}
|
||||||
height={280}
|
height={280}
|
||||||
rounded="rounded-3xl"
|
rounded="rounded-3xl"
|
||||||
|
fallbackHref="/special-sale"
|
||||||
/>
|
/>
|
||||||
</a>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TripleBanners() {
|
export function TripleBanners({ slot }: { slot?: StaticImageSlot }) {
|
||||||
|
const images = padSlotImages(slot, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="container-nn py-4">
|
<section className="container-nn py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
{tripleBanners.map((banner) => (
|
{images.map((image, index) => {
|
||||||
<a key={banner.id} href="#" className="overflow-hidden rounded-3xl">
|
const fallback = tripleBanners[index] ?? tripleBanners[0];
|
||||||
<Placeholder
|
return (
|
||||||
src={banner.image}
|
<div key={image?.id ?? fallback.id} className="overflow-hidden rounded-3xl">
|
||||||
alt={banner.title}
|
<SlotImage
|
||||||
|
image={image}
|
||||||
|
fallbackSrc={fallback.image}
|
||||||
|
fallbackAlt={fallback.title}
|
||||||
|
aspectRatio={slot?.aspectRatio}
|
||||||
width={420}
|
width={420}
|
||||||
height={240}
|
height={240}
|
||||||
rounded="rounded-3xl"
|
rounded="rounded-3xl"
|
||||||
/>
|
/>
|
||||||
</a>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { posts } from "@/data/content";
|
import Link from "next/link";
|
||||||
import { Carousel } from "./Carousel";
|
import { Carousel } from "./Carousel";
|
||||||
import { Placeholder } from "./Placeholder";
|
import { Placeholder } from "./Placeholder";
|
||||||
import { SectionHeading } from "./SectionHeading";
|
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 (
|
return (
|
||||||
<section id="blog" className="container-nn py-8">
|
<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>
|
<Carousel>
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<article
|
<article
|
||||||
@@ -14,16 +22,22 @@ export function BlogCarousel() {
|
|||||||
data-card
|
data-card
|
||||||
className="glass-box w-[280px] shrink-0 snap-start overflow-hidden rounded-3xl shadow-soft sm:w-[320px]"
|
className="glass-box w-[280px] shrink-0 snap-start overflow-hidden rounded-3xl shadow-soft sm:w-[320px]"
|
||||||
>
|
>
|
||||||
|
<Link href={post.href} className="block">
|
||||||
<Placeholder src={post.image} alt={post.title} width={360} height={220} rounded="rounded-none" />
|
<Placeholder src={post.image} alt={post.title} width={360} height={220} rounded="rounded-none" />
|
||||||
|
</Link>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<p className="text-xs text-cyan-600">{post.date}</p>
|
{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">
|
<h3 className="mt-2 line-clamp-2 min-h-12 text-base font-bold leading-6 text-ink">
|
||||||
{post.title}
|
{post.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
</Link>
|
||||||
<p className="mt-2 line-clamp-2 text-sm leading-7 text-muted">{post.excerpt}</p>
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,28 +1,70 @@
|
|||||||
import { categories } from "@/data/content";
|
import { categories } from "@/data/content";
|
||||||
import { toFaDigits } from "@/lib/format";
|
import { toFaDigits } from "@/lib/format";
|
||||||
import { Placeholder } from "./Placeholder";
|
import {
|
||||||
|
imageAlt,
|
||||||
|
slotHref,
|
||||||
|
slotImages,
|
||||||
|
type StaticImageSlot,
|
||||||
|
} from "@/lib/static-images";
|
||||||
import { SectionHeading } from "./SectionHeading";
|
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 (
|
return (
|
||||||
<section id="categories" className="container-nn py-8">
|
<section id="categories" className="container-nn py-8">
|
||||||
<SectionHeading english="Little Wardrobe" title="دستهبندیهای محبوب" />
|
<SectionHeading english="Little Wardrobe" title="دستهبندیهای محبوب" />
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8">
|
||||||
{categories.map((category) => (
|
{items.map((item) => (
|
||||||
<a
|
<a
|
||||||
key={category.id}
|
key={item.id}
|
||||||
href="#"
|
href={item.href}
|
||||||
className="glass-box group rounded-3xl p-3 text-center shadow-soft transition hover:-translate-y-1"
|
className="glass-box group rounded-3xl p-3 text-center shadow-soft transition hover:-translate-y-1"
|
||||||
>
|
>
|
||||||
<Placeholder
|
<SlotImage
|
||||||
src={category.image}
|
image={item.image}
|
||||||
alt={category.title}
|
fallbackSrc={item.fallbackSrc}
|
||||||
|
fallbackAlt={item.title}
|
||||||
|
aspectRatio={slot?.aspectRatio ?? "1:1"}
|
||||||
width={160}
|
width={160}
|
||||||
height={160}
|
height={160}
|
||||||
rounded="rounded-2xl"
|
rounded="rounded-2xl"
|
||||||
|
unlinked
|
||||||
/>
|
/>
|
||||||
<h3 className="mt-3 text-sm font-bold text-ink">{category.title}</h3>
|
<h3 className="mt-3 text-sm font-bold text-ink">{item.title}</h3>
|
||||||
<p className="text-xs text-muted">{toFaDigits(category.count)} مدل</p>
|
{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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,26 +48,52 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-line">
|
<div className="border-t border-[#e5e5e5] bg-[#f7f7f7]">
|
||||||
<div className="container-nn flex flex-col items-center justify-between gap-2 py-4 text-xs text-muted sm:flex-row">
|
<div className="container-nn flex flex-col items-center justify-between gap-3 py-4 text-xs text-[#999] sm:flex-row">
|
||||||
<p>© ۱۴۰۵ نینی نادری — nininaderi.com — همه حقوق محفوظ است.</p>
|
<p>© {new Date().getFullYear()} نینی نادری</p>
|
||||||
<p className="font-display text-purple-600">Made for little wardrobes</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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FooterList({ title, items }: { title: string; items: string[] }) {
|
function FooterList({ title, items }: { title: string; items: { href: string; label: string }[] }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-bold">{title}</h3>
|
<h3 className="mb-3 text-sm font-bold">{title}</h3>
|
||||||
<ul className="space-y-2 text-sm text-muted">
|
<ul className="space-y-2 text-sm text-muted">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={item}>
|
<li key={item.label}>
|
||||||
<a href="#" className="hover:text-purple-700">
|
<Link href={item.href} className="hover:text-purple-700">
|
||||||
{item}
|
{item.label}
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+96
-56
@@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { categories, navLinks } from "@/data/content";
|
import { SearchBox } from "@/components/SearchBox";
|
||||||
import { toFaDigits } from "@/lib/format";
|
import { navLinks } from "@/data/content";
|
||||||
import { IconBag, IconChevronDown, IconClose, IconHeart, IconMenu, IconSearch, IconUser } from "./Icons";
|
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 [open, setOpen] = useState(false);
|
||||||
const [mega, setMega] = useState(false);
|
const [mega, setMega] = useState(false);
|
||||||
const [mobileCats, setMobileCats] = useState(false);
|
const [mobileCats, setMobileCats] = useState(false);
|
||||||
@@ -40,49 +46,50 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<form className="relative mx-auto hidden min-w-0 flex-1 max-w-xl md:block">
|
<SearchBox
|
||||||
<input
|
className="mx-auto hidden min-w-0 flex-1 max-w-xl md:block"
|
||||||
type="search"
|
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"
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="ms-auto flex items-center gap-1 sm:gap-2">
|
<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 />
|
<IconUser />
|
||||||
ورود
|
ورود
|
||||||
</a>
|
</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 />
|
<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>
|
||||||
<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 />
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="pb-3 md:hidden">
|
<div className="pb-3 md:hidden">
|
||||||
<div className="relative">
|
<SearchBox
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
placeholder="جستجو در نینی نادری"
|
placeholder="جستجو در نینی نادری"
|
||||||
className="h-10 w-full rounded-full border border-line bg-purple-50/60 pr-4 pl-10 text-sm outline-none"
|
inputClassName="h-10 w-full rounded-full border border-line bg-purple-50/60 pr-4 pl-10 text-sm outline-none"
|
||||||
|
onNavigate={closeAll}
|
||||||
/>
|
/>
|
||||||
<span className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted">
|
|
||||||
<IconSearch className="size-4" />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<nav className="relative hidden h-12 items-center gap-6 text-sm font-medium text-ink lg:flex">
|
<nav className="relative hidden h-12 items-center gap-6 text-sm font-medium text-ink lg:flex">
|
||||||
<div
|
<div
|
||||||
@@ -99,16 +106,16 @@ export function Header() {
|
|||||||
دستهبندیها
|
دستهبندیها
|
||||||
<IconChevronDown className={`size-4 transition ${mega ? "rotate-180" : ""}`} />
|
<IconChevronDown className={`size-4 transition ${mega ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{mega ? <MegaMenu onPick={closeAll} /> : null}
|
{mega ? <MegaMenu categories={categories} onPick={closeAll} /> : null}
|
||||||
</div>
|
</div>
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<a
|
<Link
|
||||||
key={link.label}
|
key={link.label}
|
||||||
href={link.href}
|
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"
|
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}
|
{link.label}
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,29 +132,43 @@ export function Header() {
|
|||||||
<IconChevronDown className={`size-4 transition ${mobileCats ? "rotate-180" : ""}`} />
|
<IconChevronDown className={`size-4 transition ${mobileCats ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{mobileCats ? (
|
{mobileCats ? (
|
||||||
<div className="grid grid-cols-2 gap-2 border-b border-line/70 py-3">
|
<div className="space-y-3 border-b border-line/70 py-3">
|
||||||
{categories.map((category) => (
|
{categories.map((column) => (
|
||||||
<a
|
<div key={column.id}>
|
||||||
key={category.id}
|
<Link href={column.href} onClick={closeAll} className="block text-sm font-bold text-purple-700">
|
||||||
href="#categories"
|
{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}
|
onClick={closeAll}
|
||||||
className="rounded-2xl bg-purple-50/70 px-3 py-2 text-xs font-bold"
|
className="rounded-2xl bg-purple-50/70 px-3 py-2 text-xs font-bold"
|
||||||
>
|
>
|
||||||
{category.title}
|
{child.name}
|
||||||
</a>
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<a
|
<Link
|
||||||
key={link.label}
|
key={link.label}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
onClick={closeAll}
|
onClick={closeAll}
|
||||||
className="border-b border-line/70 py-3 text-ink"
|
className="border-b border-line/70 py-3 text-ink"
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
<a href={CUSTOMER_URL} onClick={closeAll} className="py-3 text-ink">
|
||||||
|
ورود / ثبت نام
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -155,32 +176,51 @@ export function Header() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MegaMenu({ onPick }: { onPick: () => void }) {
|
function MegaMenu({ categories, onPick }: { categories: MegaColumn[]; onPick: () => void }) {
|
||||||
return (
|
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="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 className="mb-4 flex items-end justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-display text-xs text-cyan-600">Categories</p>
|
<p className="font-display text-xs text-cyan-600">Categories</p>
|
||||||
<p className="text-base font-bold">خرید بر اساس دسته</p>
|
<p className="text-base font-bold">خرید بر اساس دسته</p>
|
||||||
</div>
|
</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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
{categories.length ? (
|
||||||
{categories.map((category) => (
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<a
|
{categories.map((column) => (
|
||||||
key={category.id}
|
<div key={column.id}>
|
||||||
href="#categories"
|
<Link
|
||||||
|
href={column.href}
|
||||||
onClick={onPick}
|
onClick={onPick}
|
||||||
className="rounded-2xl px-3 py-3 text-right transition hover:bg-white/45"
|
className="block text-sm font-bold text-purple-700 hover:text-purple-800"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-bold text-ink">{category.title}</p>
|
{column.name}
|
||||||
<p className="mt-1 text-[11px] text-muted">{toFaDigits(category.count)} مدل</p>
|
</Link>
|
||||||
</a>
|
{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>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">دستهبندیها بهزودی اینجا میآیند.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+97
-30
@@ -2,42 +2,99 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { sideBanners, slides } from "@/data/content";
|
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 [index, setIndex] = useState(0);
|
||||||
const slide = slides[index];
|
const current = items[index] ?? items[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setIndex(0);
|
||||||
|
}, [items.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length < 2) return;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
setIndex((current) => (current + 1) % slides.length);
|
setIndex((value) => (value + 1) % items.length);
|
||||||
}, 5200);
|
}, 5200);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, [items.length]);
|
||||||
|
|
||||||
return (
|
const next = () => setIndex((value) => (value + 1) % items.length);
|
||||||
<section className="container-nn py-5 md:py-7">
|
const media = current ? (
|
||||||
<div className="grid gap-4 lg:grid-cols-12 lg:grid-rows-2">
|
<SlotImage
|
||||||
<div className="relative overflow-hidden rounded-3xl lg:col-span-8 lg:row-span-2">
|
image={current.image}
|
||||||
<button
|
fallbackSrc={current.fallbackSrc}
|
||||||
type="button"
|
fallbackAlt={current.alt}
|
||||||
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}
|
width={960}
|
||||||
height={420}
|
height={420}
|
||||||
rounded="rounded-3xl"
|
rounded="rounded-3xl"
|
||||||
fill
|
fill
|
||||||
|
unlinked
|
||||||
className="aspect-[960/420] h-full w-full lg:aspect-auto"
|
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 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>
|
</button>
|
||||||
|
)}
|
||||||
|
{items.length > 1 ? (
|
||||||
<div className="pointer-events-none absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
|
<div className="pointer-events-none absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
|
||||||
{slides.map((item, itemIndex) => (
|
{items.map((item, itemIndex) => (
|
||||||
<span
|
<span
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`h-2 rounded-full ${
|
className={`h-2 rounded-full ${
|
||||||
@@ -46,23 +103,33 @@ export function Hero() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sideBanners.map((banner) => (
|
{padSlotImages(beside, 2)
|
||||||
<a
|
.slice(0, 2)
|
||||||
key={banner.id}
|
.map((image, itemIndex) => {
|
||||||
href="#"
|
const fallback = sideBanners[itemIndex] ?? sideBanners[0];
|
||||||
className="overflow-hidden rounded-3xl lg:col-span-4"
|
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)}
|
||||||
>
|
>
|
||||||
<Placeholder
|
<SlotImage
|
||||||
src={banner.image}
|
image={image}
|
||||||
alt={banner.title}
|
fallbackSrc={fallback.image}
|
||||||
|
fallbackAlt={fallback.title}
|
||||||
|
aspectRatio={beside?.aspectRatio}
|
||||||
width={360}
|
width={360}
|
||||||
height={200}
|
height={200}
|
||||||
rounded="rounded-3xl"
|
rounded="rounded-3xl"
|
||||||
|
fill
|
||||||
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
</a>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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) {
|
export function IconClose({ className = "size-6" }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden>
|
<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 { formatPrice } from "@/lib/format";
|
||||||
|
import { IconPlus } from "./Icons";
|
||||||
import { Placeholder } from "./Placeholder";
|
import { Placeholder } from "./Placeholder";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
product: Product;
|
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 (
|
return (
|
||||||
<article
|
<article
|
||||||
data-card
|
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]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
<Link href={product.href} className="block">
|
||||||
<Placeholder
|
<Placeholder
|
||||||
src={product.image}
|
src={product.image}
|
||||||
alt={product.title}
|
alt={product.title}
|
||||||
width={280}
|
width={360}
|
||||||
height={340}
|
height={420}
|
||||||
rounded="rounded-2xl"
|
rounded="rounded-2xl"
|
||||||
/>
|
/>
|
||||||
|
</Link>
|
||||||
<div className="px-1.5 pt-3 pb-1">
|
<div className="px-1.5 pt-3 pb-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="font-display text-[11px] text-cyan-600">{product.brand}</p>
|
<p className="font-display text-[11px] text-cyan-600">{product.brand}</p>
|
||||||
{product.badge ? (
|
{product.badge ? (
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold text-white ${
|
className={`rounded-full px-2 py-0.5 text-[10px] font-bold text-white ${
|
||||||
product.badge === "یگانه"
|
product.badge === "یگانه" || product.badge === "نینی"
|
||||||
? "bg-purple-700"
|
? "bg-purple-700"
|
||||||
: product.badge === "پرفروش"
|
: product.badge === "پرفروش"
|
||||||
? "bg-cyan-600"
|
? "bg-cyan-600"
|
||||||
@@ -36,11 +46,14 @@ export function ProductCard({ product }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<Link href={product.href}>
|
||||||
<h3 className="mt-0.5 line-clamp-2 min-h-11 text-sm font-bold leading-6 text-ink">
|
<h3 className="mt-0.5 line-clamp-2 min-h-11 text-sm font-bold leading-6 text-ink">
|
||||||
{product.title}
|
{product.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
</Link>
|
||||||
|
{product.colors.length ? (
|
||||||
<div className="mt-2 flex items-center gap-1.5">
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
{product.colors.map((color) => (
|
{product.colors.slice(0, 5).map((color) => (
|
||||||
<span
|
<span
|
||||||
key={color}
|
key={color}
|
||||||
className="size-3.5 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
|
className="size-3.5 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
|
||||||
@@ -48,6 +61,7 @@ export function ProductCard({ product }: Props) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="mt-3 flex items-end justify-between gap-2">
|
<div className="mt-3 flex items-end justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
{product.oldPrice ? (
|
{product.oldPrice ? (
|
||||||
@@ -55,12 +69,15 @@ export function ProductCard({ product }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
<p className="text-sm font-black text-purple-700">{formatPrice(product.price)}</p>
|
<p className="text-sm font-black text-purple-700">{formatPrice(product.price)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{canAdd ? (
|
||||||
type="button"
|
<a
|
||||||
className="rounded-full bg-purple-700 px-3 py-1.5 text-xs font-bold text-white hover:bg-purple-800"
|
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" />
|
||||||
</button>
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Product } from "@/data/content";
|
import type { Product } from "@/lib/catalog";
|
||||||
import { toFaDigits } from "@/lib/format";
|
import { slotImages, type StaticImageSlot } from "@/lib/static-images";
|
||||||
import { Carousel } from "./Carousel";
|
import { Carousel } from "./Carousel";
|
||||||
import { Placeholder } from "./Placeholder";
|
|
||||||
import { ProductCard } from "./ProductCard";
|
import { ProductCard } from "./ProductCard";
|
||||||
import { SectionHeading } from "./SectionHeading";
|
import { SectionHeading } from "./SectionHeading";
|
||||||
|
import { SlotImage } from "./SlotImage";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -15,6 +16,8 @@ type Props = {
|
|||||||
products: Product[];
|
products: Product[];
|
||||||
countdown?: boolean;
|
countdown?: boolean;
|
||||||
leadBanner?: boolean;
|
leadBanner?: boolean;
|
||||||
|
bannerSlot?: StaticImageSlot;
|
||||||
|
href?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProductCarousel({
|
export function ProductCarousel({
|
||||||
@@ -24,30 +27,42 @@ export function ProductCarousel({
|
|||||||
products,
|
products,
|
||||||
countdown,
|
countdown,
|
||||||
leadBanner,
|
leadBanner,
|
||||||
|
bannerSlot,
|
||||||
|
href,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const saleBanner = slotImages(bannerSlot)[0];
|
||||||
|
if (!products.length && !leadBanner) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id={id} className="container-nn py-8">
|
<section id={id} className="container-nn py-8">
|
||||||
<SectionHeading english={english} title={title}>
|
<SectionHeading english={english} title={title}>
|
||||||
{countdown ? <SaleTimer /> : null}
|
{countdown ? <SaleTimer /> : null}
|
||||||
|
{href ? (
|
||||||
|
<Link href={href} className="text-sm font-bold text-purple-700 hover:text-purple-800">
|
||||||
|
مشاهده همه
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
<Carousel>
|
<Carousel>
|
||||||
{leadBanner ? (
|
{leadBanner ? (
|
||||||
<a
|
<div
|
||||||
href="#"
|
|
||||||
data-card
|
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="بنر فروش ویژه"
|
aria-label="بنر فروش ویژه"
|
||||||
>
|
>
|
||||||
<Placeholder
|
<SlotImage
|
||||||
src="/images/sale-banner.png"
|
image={saleBanner}
|
||||||
alt=""
|
fallbackSrc="/images/sale-banner.png"
|
||||||
|
fallbackAlt="فروش ویژه"
|
||||||
|
aspectRatio={bannerSlot?.aspectRatio}
|
||||||
width={240}
|
width={240}
|
||||||
height={400}
|
height={400}
|
||||||
rounded="rounded-3xl"
|
rounded="rounded-3xl"
|
||||||
fill
|
fill
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
|
fallbackHref="/special-sale"
|
||||||
/>
|
/>
|
||||||
</a>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<ProductCard key={product.id} product={product} />
|
<ProductCard key={product.id} product={product} />
|
||||||
@@ -94,11 +109,15 @@ function SaleTimer() {
|
|||||||
{cells.map((cell, index) => (
|
{cells.map((cell, index) => (
|
||||||
<div key={cell.label} className="flex items-end gap-1.5">
|
<div key={cell.label} className="flex items-end gap-1.5">
|
||||||
{index > 0 ? (
|
{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}
|
) : 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)]">
|
<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">
|
<p
|
||||||
{toFaDigits(String(cell.value).padStart(2, "0"))}
|
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>
|
||||||
<p className="mt-1 text-[10px] font-medium text-rose-400">{cell.label}</p>
|
<p className="mt-1 text-[10px] font-medium text-rose-400">{cell.label}</p>
|
||||||
</div>
|
</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 = {
|
type Props = {
|
||||||
english: string;
|
english: string;
|
||||||
title: string;
|
title: string;
|
||||||
href?: string;
|
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SectionHeading({ english, title, href = "#", children }: Props) {
|
export function SectionHeading({ english, title, children }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<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>
|
<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" />
|
<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>
|
||||||
<div className="flex items-center gap-4">
|
{children ? <div className="flex items-center gap-4">{children}</div> : null}
|
||||||
{children}
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className="hidden text-sm font-medium text-purple-700 transition hover:text-rose-600 sm:inline-flex"
|
|
||||||
>
|
|
||||||
مشاهده همه
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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 Tone = "purple" | "cyan" | "rose";
|
||||||
|
|
||||||
export type Product = {
|
export type { Product } from "@/lib/catalog";
|
||||||
|
|
||||||
|
export type DemoProduct = {
|
||||||
id: string;
|
id: string;
|
||||||
image: string;
|
image: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,10 +17,12 @@ export type Product = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{ href: "#sale", label: "فروش ویژه" },
|
{ href: "/products", label: "محصولات" },
|
||||||
{ href: "#about", label: "درباره ما" },
|
{ href: "/special-sale", label: "فروش ویژه" },
|
||||||
{ href: "#faq", label: "سوالات متداول" },
|
{ href: "/blog", label: "مجله" },
|
||||||
{ href: "#contact", label: "تماس با ما" },
|
{ href: "/#about", label: "درباره ما" },
|
||||||
|
{ href: "/#faq", label: "سوالات متداول" },
|
||||||
|
{ href: "/#contact", label: "تماس با ما" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const slides = [
|
export const slides = [
|
||||||
@@ -109,7 +115,7 @@ export const features = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const saleProducts: Product[] = [
|
export const saleProducts: DemoProduct[] = [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
image: "/images/sale-1.png",
|
image: "/images/sale-1.png",
|
||||||
@@ -221,7 +227,7 @@ export const dualBanners = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const topProducts: Product[] = [
|
export const topProducts: DemoProduct[] = [
|
||||||
{
|
{
|
||||||
id: "t1",
|
id: "t1",
|
||||||
image: "/images/top-1.png",
|
image: "/images/top-1.png",
|
||||||
@@ -331,7 +337,7 @@ export const tripleBanners = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const yeganehProducts: Product[] = [
|
export const yeganehProducts: DemoProduct[] = [
|
||||||
{
|
{
|
||||||
id: "y1",
|
id: "y1",
|
||||||
image: "/images/yeganeh-1.png",
|
image: "/images/yeganeh-1.png",
|
||||||
@@ -403,9 +409,9 @@ export const about = {
|
|||||||
"هر سفارش با ضمانت اصالت ارسال میشود، امکان مرجوعی دارید و برای خریدهای بالای ۵ میلیون تومان پیک رایگان است. اگر سایز شک دارید، پشتیبانی نینی نادری کمکتان میکند.",
|
"هر سفارش با ضمانت اصالت ارسال میشود، امکان مرجوعی دارید و برای خریدهای بالای ۵ میلیون تومان پیک رایگان است. اگر سایز شک دارید، پشتیبانی نینی نادری کمکتان میکند.",
|
||||||
],
|
],
|
||||||
stats: [
|
stats: [
|
||||||
{ value: "۱۲ سال", label: "همراه خانوادهها" },
|
{ value: "12", suffix: "سال", label: "همراه خانوادهها" },
|
||||||
{ value: "+۴۰۰", label: "مدل فعال" },
|
{ value: "400+", suffix: "", label: "مدل فعال" },
|
||||||
{ value: "۲۴ ساعته", label: "پاسخگویی سفارش" },
|
{ value: "24", suffix: "ساعته", label: "پاسخگویی سفارش" },
|
||||||
],
|
],
|
||||||
image: "/images/about.png",
|
image: "/images/about.png",
|
||||||
imageSize: "560 × 400",
|
imageSize: "560 × 400",
|
||||||
@@ -472,9 +478,21 @@ export const faqs = [
|
|||||||
export const footer = {
|
export const footer = {
|
||||||
blurb:
|
blurb:
|
||||||
"فروشگاه پوشاک نوزاد و کودک نینی نادری. لباس نرم، رنگ زنده، ارسال سریع.",
|
"فروشگاه پوشاک نوزاد و کودک نینی نادری. لباس نرم، رنگ زنده، ارسال سریع.",
|
||||||
shop: ["نوزاد", "دخترانه", "پسرانه", "برند یگانه", "فروش ویژه"],
|
shop: [
|
||||||
help: ["پیگیری سفارش", "راهنمای سایز", "مرجوعی کالا", "سوالات پرتکرار", "تماس با ما"],
|
{ href: "/products", label: "محصولات" },
|
||||||
about: ["داستان نینی نادری", "شعب حضوری", "فرصت همکاری", "قوانین و حریم خصوصی"],
|
{ 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: {
|
contact: {
|
||||||
phone: "۰۲۱-۹۱۰۰۱۲۳۴",
|
phone: "۰۲۱-۹۱۰۰۱۲۳۴",
|
||||||
mobile: "۰۹۱۲۳۴۵۶۷۸۹",
|
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)]);
|
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"))} تومان`;
|
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