SEO-friendly id-based URLs, sitemap/robots, and homepage category banners
- Replace legacy DB-slug URLs with /products/{id}/{fa-slug},
/products/category/{id}/{fa-slug}, and /blog/{id}/{fa-slug}, backed by
the new by-id API endpoints and a Persian slugify helper
- Add /sitemap.xml (index) + /sitemap-main.xml + /sitemap-products.xml
and /robots.txt pointing to it
- Register the three-banner-row slot in the static-image-slots catalog
- Replace the homepage category carousel with a 3-column linkable
ThreeBanners section wired to that slot
- Remove the now-superseded root prompt files (moved into Ai data/)
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/ai-data
|
||||
/psd
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
Domain: simode.shop
|
||||
Documentation for backend is:https://api.meshkee.com/docs/website
|
||||
|
||||
|
||||
Login or user icon should go to customer.simode.shop
|
||||
|
||||
Create product page and details >> connect to store items
|
||||
Create blog page and details >> connect to blog
|
||||
|
||||
Product Menu should be a mega menu with level 2 and 3 of categories - skip level 1
|
||||
|
||||
Search input should be instant search - activate when type more than 2 char
|
||||
|
||||
First carousel >> special-discount
|
||||
Second carousel >> top-selling
|
||||
@@ -1,13 +0,0 @@
|
||||
Wire this page to simode.shop static-image slots.
|
||||
|
||||
Read http://meshkee.com/docs/website (static-images). Env is already set.
|
||||
|
||||
Fetch slots, match by key:
|
||||
- slider > duplicatable
|
||||
- two-banner-row > two slot
|
||||
- one-banner-row → one slot
|
||||
- aboutus-row → one slot
|
||||
|
||||
Use slot.images (all of them if kind=list, first if kind=single).
|
||||
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
|
||||
Use slot.aspectRatio from the API. Do not invent upload APIs.
|
||||
@@ -1,17 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBlog } from "@/lib/api";
|
||||
import { getBlogById } from "@/lib/api";
|
||||
|
||||
export default async function BlogDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
params: Promise<{ id: string; slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { id } = await params;
|
||||
|
||||
let blog;
|
||||
try {
|
||||
({ blog } = await getBlog(slug));
|
||||
({ blog } = await getBlogById(id));
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getBlogs, safe } from "@/lib/api";
|
||||
import { blogHref } from "@/lib/slug";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
@@ -46,7 +47,7 @@ export default async function BlogPage({
|
||||
return (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/blog/${post.slug}`}
|
||||
href={blogHref(post)}
|
||||
className="group flex flex-col h-full bg-white border border-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -43,6 +43,15 @@ export async function GET() {
|
||||
itemCount: 1,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
{
|
||||
key: "three-banner-row",
|
||||
label: "سه بنر دستهبندی",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "1:1",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 520,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
+3
-7
@@ -1,6 +1,6 @@
|
||||
import HeroSlider from "@/components/HeroSlider";
|
||||
import SpecialSaleCarousel from "@/components/SpecialSaleCarousel";
|
||||
import Categories from "@/components/Categories";
|
||||
import ThreeBanners from "@/components/ThreeBanners";
|
||||
import TwoBanners from "@/components/TwoBanners";
|
||||
import TopSellingCarousel from "@/components/TopSellingCarousel";
|
||||
import FullBanner from "@/components/FullBanner";
|
||||
@@ -8,14 +8,12 @@ import AboutUs from "@/components/AboutUs";
|
||||
import BlogCarousel from "@/components/BlogCarousel";
|
||||
import {
|
||||
getBlogs,
|
||||
getCategories,
|
||||
getProducts,
|
||||
getStaticImageSlots,
|
||||
getStoreSpecials,
|
||||
findSlot,
|
||||
safe,
|
||||
} from "@/lib/api";
|
||||
import { getMegaMenuColumns } from "@/lib/categoryTree";
|
||||
import type {
|
||||
ApiProduct,
|
||||
ProductDiscount,
|
||||
@@ -58,8 +56,7 @@ function resolveSpecialItems(
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const [categoriesFlat, allProducts, specials, blogs, slots] = await Promise.all([
|
||||
safe(getCategories(), []),
|
||||
const [allProducts, specials, blogs, slots] = await Promise.all([
|
||||
// Large enough to cover the current catalog in one call so
|
||||
// store-special items (which only carry a productId) can be resolved
|
||||
// to a full product record (slug, price, stock) without N lookups.
|
||||
@@ -69,7 +66,6 @@ export default async function Home() {
|
||||
safe(getStaticImageSlots("home"), []),
|
||||
]);
|
||||
|
||||
const categoryColumns = getMegaMenuColumns(categoriesFlat);
|
||||
const productById = new Map(allProducts.items.map((p) => [p.id, p]));
|
||||
|
||||
const specialDiscount = specials.find((s) => s.key === "special-discount" && s.isActive);
|
||||
@@ -82,7 +78,7 @@ export default async function Home() {
|
||||
<main>
|
||||
<HeroSlider slot={findSlot(slots, "slider")} />
|
||||
<SpecialSaleCarousel items={specialItems} />
|
||||
<Categories categoryColumns={categoryColumns} />
|
||||
<ThreeBanners slot={findSlot(slots, "three-banner-row")} />
|
||||
<TwoBanners slot={findSlot(slots, "two-banner-row")} />
|
||||
<TopSellingCarousel items={topSellingItems} />
|
||||
<FullBanner slot={findSlot(slots, "one-banner-row")} />
|
||||
|
||||
@@ -2,11 +2,12 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getComments,
|
||||
getProduct,
|
||||
getProductById,
|
||||
getProductTechnicalInfo,
|
||||
getStoreItemsByProduct,
|
||||
safe,
|
||||
} from "@/lib/api";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import ProductGallery from "@/components/ProductGallery";
|
||||
import ProductBuyPanel from "@/components/ProductBuyPanel";
|
||||
import ProductInfoTabs from "@/components/ProductInfoTabs";
|
||||
@@ -16,13 +17,13 @@ import SectionHeading from "@/components/ui/SectionHeading";
|
||||
export default async function ProductDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
params: Promise<{ id: string; slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { id } = await params;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await getProduct(slug);
|
||||
data = await getProductById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
@@ -31,7 +32,7 @@ export default async function ProductDetailPage({
|
||||
|
||||
const [{ items: storeItems }, technicalInfo, { items: comments }] = await Promise.all([
|
||||
safe(getStoreItemsByProduct(product.id), { items: [] }),
|
||||
safe(getProductTechnicalInfo(slug), { form: null, values: [] }),
|
||||
safe(getProductTechnicalInfo(id), { form: null, values: [] }),
|
||||
safe(getComments("product", product.id), { items: [] }),
|
||||
]);
|
||||
|
||||
@@ -48,10 +49,15 @@ export default async function ProductDetailPage({
|
||||
<Link href="/products" className="hover:text-brand transition-colors">
|
||||
محصولات
|
||||
</Link>
|
||||
{product.categoryNameFa && (
|
||||
{product.categoryId && product.categoryNameFa && (
|
||||
<>
|
||||
<span className="mx-1.5">/</span>
|
||||
<span>{product.categoryNameFa}</span>
|
||||
<Link
|
||||
href={categoryHref({ id: product.categoryId, nameFa: product.categoryNameFa })}
|
||||
className="hover:text-brand transition-colors"
|
||||
>
|
||||
{product.categoryNameFa}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
@@ -0,0 +1,93 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getCategories, getCategoryById, getProducts, safe } from "@/lib/api";
|
||||
import { getMegaMenuColumns } from "@/lib/categoryTree";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import type { ApiCategory } from "@/lib/types";
|
||||
import ProductCard from "@/components/ui/ProductCard";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default async function CategoryProductsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string; slug: string }>;
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page) || 1);
|
||||
|
||||
let category: ApiCategory;
|
||||
try {
|
||||
category = await getCategoryById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [categoriesFlat, result] = await Promise.all([
|
||||
safe(getCategories(), []),
|
||||
safe(getProducts({ page, pageSize: PAGE_SIZE, categoryId: id }), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
]);
|
||||
|
||||
const filterItems = getMegaMenuColumns(categoriesFlat).flatMap((col) => col.children);
|
||||
const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
|
||||
|
||||
function buildHref(targetPage: number) {
|
||||
return `${categoryHref(category)}${targetPage > 1 ? `?page=${targetPage}` : ""}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="boxed py-8 md:py-12">
|
||||
<div className="mb-8">
|
||||
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
|
||||
Products
|
||||
</span>
|
||||
<h1 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">{category.nameFa}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
<Link
|
||||
href="/products"
|
||||
className="rounded-full px-4 h-9 flex items-center text-xs md:text-sm font-bold border border-line text-ink hover:border-brand hover:text-brand transition-colors"
|
||||
>
|
||||
همه
|
||||
</Link>
|
||||
{filterItems.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={categoryHref(cat)}
|
||||
className={`rounded-full px-4 h-9 flex items-center text-xs md:text-sm font-bold transition-colors ${
|
||||
cat.id === id
|
||||
? "bg-brand text-white"
|
||||
: "border border-line text-ink hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{cat.nameFa}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{result.items.length === 0 ? (
|
||||
<p className="py-16 text-center text-body">محصولی یافت نشد.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-5">
|
||||
{result.items.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} buildHref={buildHref} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+17
-32
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { getCategories, getProducts, safe } from "@/lib/api";
|
||||
import { getMegaMenuColumns } from "@/lib/categoryTree";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import ProductCard from "@/components/ui/ProductCard";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
|
||||
@@ -13,32 +14,26 @@ export const metadata = {
|
||||
export default async function ProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ category?: string; page?: string }>;
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number(params.page) || 1);
|
||||
|
||||
const categoriesFlat = await safe(getCategories(), []);
|
||||
const categoryColumns = getMegaMenuColumns(categoriesFlat);
|
||||
const filterItems = categoryColumns.flatMap((col) => col.children);
|
||||
|
||||
const activeCategory = params.category
|
||||
? categoriesFlat.find((c) => c.slug === params.category)
|
||||
: undefined;
|
||||
|
||||
const result = await safe(
|
||||
getProducts({ page, pageSize: PAGE_SIZE, categoryId: activeCategory?.id }),
|
||||
{ items: [], total: 0, page, pageSize: PAGE_SIZE }
|
||||
);
|
||||
const [categoriesFlat, result] = await Promise.all([
|
||||
safe(getCategories(), []),
|
||||
safe(getProducts({ page, pageSize: PAGE_SIZE }), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
]);
|
||||
|
||||
const filterItems = getMegaMenuColumns(categoriesFlat).flatMap((col) => col.children);
|
||||
const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
|
||||
|
||||
function buildHref(targetPage: number) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.category) qs.set("category", params.category);
|
||||
if (targetPage > 1) qs.set("page", String(targetPage));
|
||||
const query = qs.toString();
|
||||
return `/products${query ? `?${query}` : ""}`;
|
||||
return `/products${targetPage > 1 ? `?page=${targetPage}` : ""}`;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -47,31 +42,21 @@ export default async function ProductsPage({
|
||||
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
|
||||
Products
|
||||
</span>
|
||||
<h1 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">
|
||||
{activeCategory ? activeCategory.nameFa : "همه محصولات"}
|
||||
</h1>
|
||||
<h1 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">همه محصولات</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
<Link
|
||||
href="/products"
|
||||
className={`rounded-full px-4 h-9 flex items-center text-xs md:text-sm font-bold transition-colors ${
|
||||
!activeCategory
|
||||
? "bg-brand text-white"
|
||||
: "border border-line text-ink hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
className="rounded-full px-4 h-9 flex items-center text-xs md:text-sm font-bold bg-brand text-white"
|
||||
>
|
||||
همه
|
||||
</Link>
|
||||
{filterItems.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/products?category=${cat.slug}`}
|
||||
className={`rounded-full px-4 h-9 flex items-center text-xs md:text-sm font-bold transition-colors ${
|
||||
activeCategory?.id === cat.id
|
||||
? "bg-brand text-white"
|
||||
: "border border-line text-ink hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
href={categoryHref(cat)}
|
||||
className="rounded-full px-4 h-9 flex items-center text-xs md:text-sm font-bold border border-line text-ink hover:border-brand hover:text-brand transition-colors"
|
||||
>
|
||||
{cat.nameFa}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/config";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SITE_URL } from "@/lib/config";
|
||||
import { getBlogs, getCategories, safe } from "@/lib/api";
|
||||
import { getMegaMenuColumns } from "@/lib/categoryTree";
|
||||
import { blogHref, categoryHref } from "@/lib/slug";
|
||||
|
||||
function urlTag(loc: string) {
|
||||
return ` <url><loc>${encodeURI(loc)}</loc></url>`;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [categoriesFlat, blogs] = await Promise.all([
|
||||
safe(getCategories(), []),
|
||||
safe(getBlogs({ pageSize: 200 }), { items: [], total: 0, page: 1, pageSize: 200 }),
|
||||
]);
|
||||
|
||||
// Both the level-2 group pages and their level-3 children get their own
|
||||
// category URL, same as the header mega-menu.
|
||||
const categoryNodes = getMegaMenuColumns(categoriesFlat).flatMap((col) => [col, ...col.children]);
|
||||
|
||||
const staticPaths = ["/", "/products", "/blog"];
|
||||
|
||||
const urls = [
|
||||
...staticPaths.map((path) => `${SITE_URL}${path}`),
|
||||
...categoryNodes.map((cat) => `${SITE_URL}${categoryHref(cat)}`),
|
||||
...blogs.items.map((post) => `${SITE_URL}${blogHref(post)}`),
|
||||
];
|
||||
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map(urlTag).join("\n")}
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SITE_URL } from "@/lib/config";
|
||||
import { getProducts, safe } from "@/lib/api";
|
||||
import { productHref } from "@/lib/slug";
|
||||
|
||||
export async function GET() {
|
||||
const result = await safe(getProducts({ pageSize: 1000 }), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
||||
const urls = result.items.map((p) => `${SITE_URL}${productHref(p)}`);
|
||||
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map((loc) => ` <url><loc>${encodeURI(loc)}</loc></url>`).join("\n")}
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SITE_URL } from "@/lib/config";
|
||||
|
||||
export async function GET() {
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>${SITE_URL}/sitemap-main.xml</loc></sitemap>
|
||||
<sitemap><loc>${SITE_URL}/sitemap-products.xml</loc></sitemap>
|
||||
</sitemapindex>
|
||||
`;
|
||||
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import Link from "next/link";
|
||||
import "swiper/css";
|
||||
import type { ApiBlog } from "@/lib/types";
|
||||
import { blogHref } from "@/lib/slug";
|
||||
import SectionHeading from "./ui/SectionHeading";
|
||||
|
||||
export default function BlogCarousel({ posts }: { posts: ApiBlog[] }) {
|
||||
@@ -27,7 +28,7 @@ export default function BlogCarousel({ posts }: { posts: ApiBlog[] }) {
|
||||
return (
|
||||
<SwiperSlide key={post.id}>
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
href={blogHref(post)}
|
||||
className="group flex flex-col h-full bg-white border border-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import Link from "next/link";
|
||||
import type { CategoryNode } from "@/lib/types";
|
||||
import SectionHeading from "./ui/SectionHeading";
|
||||
|
||||
const PLACEHOLDER_IMAGES = [
|
||||
"/images/categories/girls.svg",
|
||||
"/images/categories/boys.svg",
|
||||
"/images/categories/baby.svg",
|
||||
"/images/categories/party.svg",
|
||||
"/images/categories/sets.svg",
|
||||
"/images/categories/outerwear.svg",
|
||||
];
|
||||
|
||||
export default function Categories({ categoryColumns }: { categoryColumns: CategoryNode[] }) {
|
||||
const items = categoryColumns.flatMap((col) => col.children);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="pt-5 md:pt-7 pb-10 md:pb-14">
|
||||
<div className="boxed">
|
||||
<SectionHeading kicker="Categories" title="دستهبندی محصولات" />
|
||||
|
||||
<div className="bg-gold rounded-2xl md:rounded-3xl py-8 px-4 md:px-8">
|
||||
<Swiper
|
||||
spaceBetween={10}
|
||||
slidesPerView={2}
|
||||
breakpoints={{
|
||||
768: { slidesPerView: 3 },
|
||||
1024: { slidesPerView: 6, spaceBetween: 14 },
|
||||
1280: { slidesPerView: 8, spaceBetween: 14 },
|
||||
}}
|
||||
>
|
||||
{items.map((cat, i) => (
|
||||
<SwiperSlide key={cat.id}>
|
||||
<Link href={`/products?category=${cat.slug}`} className="group block text-center">
|
||||
<div className="overflow-hidden rounded-lg bg-white">
|
||||
<img
|
||||
src={PLACEHOLDER_IMAGES[i % PLACEHOLDER_IMAGES.length]}
|
||||
alt={cat.nameFa}
|
||||
className="w-full aspect-square object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 block text-xs md:text-sm font-bold text-ink">
|
||||
{cat.nameFa}
|
||||
</span>
|
||||
</Link>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import Logo from "./Logo";
|
||||
import { headerNavLinks, siteInfo } from "@/data/site";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import type { CategoryNode } from "@/lib/types";
|
||||
import {
|
||||
InstagramIcon,
|
||||
@@ -66,7 +67,7 @@ export default function Footer({ categoryColumns }: { categoryColumns: CategoryN
|
||||
<ul className="space-y-2.5 text-sm">
|
||||
{categoryItems.map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<Link href={`/products?category=${cat.slug}`} className="hover:text-gold transition-colors">
|
||||
<Link href={categoryHref(cat)} className="hover:text-gold transition-colors">
|
||||
{cat.nameFa}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./icons";
|
||||
import { headerNavLinks } from "@/data/site";
|
||||
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import type { CategoryNode } from "@/lib/types";
|
||||
|
||||
const socialLinks = [
|
||||
@@ -91,7 +92,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN
|
||||
{categoryColumns.map((col) => (
|
||||
<div key={col.id} className="min-w-[180px]">
|
||||
<Link
|
||||
href={`/products?category=${col.slug}`}
|
||||
href={categoryHref(col)}
|
||||
className="block font-bold text-ink text-sm mb-2.5 hover:text-brand transition-colors"
|
||||
>
|
||||
{col.nameFa}
|
||||
@@ -100,7 +101,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN
|
||||
{col.children.map((sub) => (
|
||||
<li key={sub.id}>
|
||||
<Link
|
||||
href={`/products?category=${sub.slug}`}
|
||||
href={categoryHref(sub)}
|
||||
className="text-[13px] text-body hover:text-gold-dark transition-colors"
|
||||
>
|
||||
{sub.nameFa}
|
||||
@@ -182,7 +183,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN
|
||||
{categoryColumns.map((col) => (
|
||||
<div key={col.id}>
|
||||
<Link
|
||||
href={`/products?category=${col.slug}`}
|
||||
href={categoryHref(col)}
|
||||
className="block text-sm font-bold text-ink"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
@@ -192,7 +193,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN
|
||||
{col.children.map((sub) => (
|
||||
<li key={sub.id}>
|
||||
<Link
|
||||
href={`/products?category=${sub.slug}`}
|
||||
href={categoryHref(sub)}
|
||||
className="text-sm text-body"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { getProducts } from "@/lib/api";
|
||||
import type { ApiProduct } from "@/lib/types";
|
||||
import { formatToman } from "@/lib/format";
|
||||
import { productHref } from "@/lib/slug";
|
||||
import { SearchIcon } from "./icons";
|
||||
|
||||
const MIN_QUERY_LENGTH = 3;
|
||||
@@ -92,7 +93,7 @@ export default function SearchBox({
|
||||
results.map((product) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
href={`/products/${product.slug}`}
|
||||
href={productHref(product)}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-3 p-3 hover:bg-surface transition-colors border-b border-line last:border-0"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { StaticImageSlot } from "@/lib/types";
|
||||
import { toCssAspectRatio } from "@/lib/format";
|
||||
import SectionHeading from "./ui/SectionHeading";
|
||||
|
||||
const fallbackBanners = [
|
||||
{ image: "/images/categories/girls.svg", href: "#" },
|
||||
{ image: "/images/categories/boys.svg", href: "#" },
|
||||
{ image: "/images/categories/baby.svg", href: "#" },
|
||||
];
|
||||
|
||||
export default function ThreeBanners({ slot }: { slot?: StaticImageSlot }) {
|
||||
const images = slot?.images ?? [];
|
||||
const cssAspectRatio = toCssAspectRatio(slot?.aspectRatio);
|
||||
const aspectStyle = cssAspectRatio ? { aspectRatio: cssAspectRatio } : undefined;
|
||||
|
||||
const banners =
|
||||
images.length > 0
|
||||
? images.map((img) => ({ image: img.url, href: img.linkUrl || undefined }))
|
||||
: fallbackBanners;
|
||||
|
||||
return (
|
||||
<section className="boxed py-10 md:py-14">
|
||||
<SectionHeading kicker="Categories" title="دستهبندیها" />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
{banners.map((b, i) => {
|
||||
const Wrapper = b.href ? "a" : "div";
|
||||
return (
|
||||
<Wrapper
|
||||
key={i}
|
||||
{...(b.href ? { href: b.href } : {})}
|
||||
className="group block overflow-hidden rounded-2xl border border-line transition-all duration-300 hover:-translate-y-1.5 hover:border-brand hover:shadow-xl"
|
||||
>
|
||||
<img
|
||||
src={b.image}
|
||||
alt=""
|
||||
style={aspectStyle}
|
||||
className={`w-full object-cover transition-transform duration-500 ease-out group-hover:scale-110 ${
|
||||
aspectStyle ? "" : "aspect-square"
|
||||
}`}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { ApiProduct, ProductDiscount } from "@/lib/types";
|
||||
import { formatToman } from "@/lib/format";
|
||||
import { productHref } from "@/lib/slug";
|
||||
import { CheckIcon, PlusIcon } from "../icons";
|
||||
|
||||
export default function ProductCard({
|
||||
@@ -29,7 +30,7 @@ export default function ProductCard({
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/products/${product.slug}`}
|
||||
href={productHref(product)}
|
||||
className="group flex flex-col h-full bg-white border border-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="relative">
|
||||
|
||||
+17
-9
@@ -63,16 +63,18 @@ export function getProducts(
|
||||
return apiGet<PagedResult<ApiProduct>>(`/tenants/${SITE_DOMAIN}/products`, params);
|
||||
}
|
||||
|
||||
export function getProduct(
|
||||
slug: string
|
||||
// id-based lookups — public URLs are id-driven (see src/lib/slug.ts), the
|
||||
// DB slug (e.g. "...-old-28353") never appears in a route.
|
||||
export function getProductById(
|
||||
id: string
|
||||
): Promise<{ product: ApiProduct; relatedProducts: ApiProduct[] }> {
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}`);
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/products/by-id/${id}`);
|
||||
}
|
||||
|
||||
export function getProductVariations(
|
||||
slug: string
|
||||
productId: string
|
||||
): Promise<{ variations: ProductVariation[] }> {
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}/variations`);
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/products/by-id/${productId}/variations`);
|
||||
}
|
||||
|
||||
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
|
||||
@@ -80,9 +82,15 @@ export function getStoreItemsByProduct(productId: string): Promise<{ items: Stor
|
||||
}
|
||||
|
||||
export function getProductTechnicalInfo(
|
||||
slug: string
|
||||
productId: string
|
||||
): Promise<{ form: unknown; values: TechnicalInfoValue[] }> {
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}/technical-info`);
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/products/by-id/${productId}/technical-info`);
|
||||
}
|
||||
|
||||
export function getCategoryById(id: string): Promise<ApiCategory> {
|
||||
return apiGet<{ category: ApiCategory }>(`/tenants/${SITE_DOMAIN}/categories/by-id/${id}`).then(
|
||||
(r) => r.category
|
||||
);
|
||||
}
|
||||
|
||||
export function getComments(
|
||||
@@ -98,8 +106,8 @@ export function getBlogs(
|
||||
return apiGet<PagedResult<ApiBlog>>(`/tenants/${SITE_DOMAIN}/blogs`, params);
|
||||
}
|
||||
|
||||
export function getBlog(slug: string): Promise<{ blog: ApiBlog }> {
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/blogs/${slug}`);
|
||||
export function getBlogById(id: string): Promise<{ blog: ApiBlog }> {
|
||||
return apiGet(`/tenants/${SITE_DOMAIN}/blogs/by-id/${id}`);
|
||||
}
|
||||
|
||||
export function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const SITE_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "simode.shop";
|
||||
export const API_BASE = "https://api.meshkee.com/api/v1";
|
||||
export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
|
||||
export const SITE_URL = `https://${SITE_DOMAIN}`;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ApiBlog, ApiProduct } from "./types";
|
||||
|
||||
// Per SEO spec: keep Farsi letters, turn spaces into "-", strip everything
|
||||
// else. Deliberately does not transliterate to Latin — Persian slugs rank
|
||||
// better for Persian-language search.
|
||||
export function slugifyFa(input: string): string {
|
||||
return input
|
||||
.replace(/[^-ۿ0-9a-zA-Z\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
}
|
||||
|
||||
// Canonical public URLs — id-driven, DB slugs never appear here.
|
||||
export function productHref(product: Pick<ApiProduct, "id" | "nameFa" | "title">): string {
|
||||
return `/products/${product.id}/${slugifyFa(product.nameFa || product.title)}`;
|
||||
}
|
||||
|
||||
export function categoryHref(category: { id: string; nameFa: string }): string {
|
||||
return `/products/category/${category.id}/${slugifyFa(category.nameFa)}`;
|
||||
}
|
||||
|
||||
export function blogHref(blog: Pick<ApiBlog, "id" | "title" | "nameFa">): string {
|
||||
return `/blog/${blog.id}/${slugifyFa(blog.title || blog.nameFa || "")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user