Add Torob tags, Meshkee SEO routes, and updated static-image slots.

Product pages emit crawler-readable meta, public URLs use id plus Farsi slugs, and the homepage catalog matches the dashboard slot keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-01 16:12:26 +03:30
co-authored by Cursor
parent f42c30a4b7
commit c84147e765
40 changed files with 838 additions and 288 deletions
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { ProductTabs } from "@/components/ProductTabs";
import { ProductView } from "@/components/ProductView";
import { getComments, getProductById, getStoreItemByProduct } from "@/lib/api";
import { productHref, productNameSlug, productTitle } from "@/lib/paths";
import { SITE } from "@/lib/site";
import { torobMetadata } from "@/lib/torob";
type Props = {
params: Promise<{ id: string; slug: string }>;
};
async function loadProduct(id: string) {
return getProductById(id);
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
try {
const product = await loadProduct(id);
if (!product) return { title: SITE.nameFa };
let storeItem = null;
try {
storeItem = await getStoreItemByProduct(product.id);
} catch {
storeItem = null;
}
const title = productTitle(product);
return {
title: `${title} | ${SITE.nameFa}`,
description: product.summary?.trim() || SITE.tagline,
alternates: { canonical: productHref(product) },
...torobMetadata(product, storeItem),
};
} catch {
return { title: SITE.nameFa };
}
}
export default async function ProductDetailsPage({ params }: Props) {
const { id, slug } = await params;
const product = await loadProduct(id);
if (!product) notFound();
const title = productTitle(product);
if (decodeURIComponent(slug) !== productNameSlug(title)) {
permanentRedirect(productHref(product));
}
const [storeItem, comments] = await Promise.all([
getStoreItemByProduct(product.id),
getComments("product", product.id),
]);
return (
<>
<PageHero
title={title}
english={product.title}
crumbs={[{ name: "محصولات", href: "/products" }, { name: title }]}
/>
<section className="container-site py-10 md:py-14">
<ProductView product={product} variants={storeItem?.variants ?? []} />
<ProductTabs productId={product.id} descriptionHtml={product.descriptionHtml} comments={comments} />
</section>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { notFound, permanentRedirect } from "next/navigation";
import { getCategoryById, getCategoryBySlug, getProduct, getProductById } from "@/lib/api";
import { categoryHref, productHref } from "@/lib/paths";
/** Legacy `/products/{id-or-slug}` → canonical product or category URL. */
export default async function LegacyProductOrCategoryRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const productById = await getProductById(id);
if (productById) permanentRedirect(productHref(productById));
const productBySlug = await getProduct(id);
if (productBySlug) permanentRedirect(productHref(productBySlug));
const categoryById = await getCategoryById(id);
if (categoryById) permanentRedirect(categoryHref(categoryById));
const categoryBySlug = await getCategoryBySlug(id);
if (categoryBySlug) permanentRedirect(categoryHref(categoryBySlug));
notFound();
}
-111
View File
@@ -1,111 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { Pagination } from "@/components/Pagination";
import { ProductGrid } from "@/components/ProductGrid";
import { ProductTabs } from "@/components/ProductTabs";
import { ProductView } from "@/components/ProductView";
import { getCategories, getCategoryBySlug, getComments, getProduct, getStoreItemByProduct } from "@/lib/api";
import { listMappedProductsForCategory } from "@/lib/mappers";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
try {
const product = await getProduct(slug);
if (product) {
return {
title: `${product.nameFa || product.title} | ${SITE.nameFa}`,
description: product.summary || SITE.tagline,
};
}
const category = await getCategoryBySlug(slug);
if (category) {
return {
title: `${category.nameFa || category.name} | ${SITE.nameFa}`,
description: SITE.tagline,
};
}
} catch {
// fall through
}
return { title: SITE.nameFa };
}
export default async function ProductOrCategoryPage({ params, searchParams }: Props) {
const { slug } = await params;
const product = await getProduct(slug);
if (product) {
const [storeItem, comments] = await Promise.all([
getStoreItemByProduct(product.id),
getComments("product", product.id),
]);
return (
<>
<PageHero
title={product.nameFa || product.title}
english={product.title}
crumbs={[{ name: "محصولات", href: "/products" }, { name: product.nameFa || product.title }]}
/>
<section className="container-site py-10 md:py-14">
<ProductView product={product} variants={storeItem?.variants ?? []} />
<ProductTabs productId={product.id} descriptionHtml={product.descriptionHtml} comments={comments} />
</section>
</>
);
}
const [category, categories] = await Promise.all([getCategoryBySlug(slug), getCategories()]);
if (!category) notFound();
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const children = categories.filter((item) => item.parentId === category.id);
const data = await listMappedProductsForCategory(
category.id,
children.map((item) => item.id),
page,
16,
);
return (
<>
<PageHero
title={category.nameFa || category.name}
english={category.name}
crumbs={[{ name: "محصولات", href: "/products" }, { name: category.nameFa || category.name }]}
/>
<section className="container-site py-10 md:py-14">
{children.length > 0 && (
<div className="mb-8 flex flex-wrap gap-2">
{children.map((item) => (
<Link
key={item.id}
href={`/products/${item.slug}`}
className="rounded-full border border-black/10 px-4 py-2 text-sm hover:border-brand hover:text-brand"
>
{item.nameFa || item.name}
</Link>
))}
</div>
)}
<ProductGrid products={data.items} />
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
makeHref={(nextPage) => (nextPage > 1 ? `/products/${slug}?page=${nextPage}` : `/products/${slug}`)}
/>
</section>
</>
);
}
@@ -0,0 +1,87 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, permanentRedirect } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { Pagination } from "@/components/Pagination";
import { ProductGrid } from "@/components/ProductGrid";
import { getCategories, getCategoryById } from "@/lib/api";
import { listMappedProductsForCategory } from "@/lib/mappers";
import { categoryHref, categoryNameSlug, categoryTitle } from "@/lib/paths";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ id: string; slug: string }>;
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
try {
const category = await getCategoryById(id);
if (!category) return { title: SITE.nameFa };
const title = categoryTitle(category);
return {
title: `${title} | ${SITE.nameFa}`,
description: category.description?.trim() || `خرید ${title} از فروشگاه لوازم ورزشی و کوهنوردی سالار استور`,
alternates: { canonical: categoryHref(category) },
};
} catch {
return { title: SITE.nameFa };
}
}
export default async function CategoryProductsPage({ params, searchParams }: Props) {
const { id, slug } = await params;
const [category, categories] = await Promise.all([getCategoryById(id), getCategories()]);
if (!category || category.isActive === false) notFound();
const title = categoryTitle(category);
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
if (decodeURIComponent(slug) !== categoryNameSlug(title)) {
const href = categoryHref(category);
permanentRedirect(page > 1 ? `${href}?page=${page}` : href);
}
const children = categories.filter((item) => item.parentId === category.id);
const data = await listMappedProductsForCategory(
category.id,
children.map((item) => item.id),
page,
16,
);
const baseHref = categoryHref(category);
return (
<>
<PageHero
title={title}
english={category.name}
crumbs={[{ name: "محصولات", href: "/products" }, { name: title }]}
/>
<section className="container-site py-10 md:py-14">
{children.length > 0 && (
<div className="mb-8 flex flex-wrap gap-2">
{children.map((item) => (
<Link
key={item.id}
href={categoryHref(item)}
className="rounded-full border border-black/10 px-4 py-2 text-sm hover:border-brand hover:text-brand"
>
{item.nameFa || item.name}
</Link>
))}
</div>
)}
<ProductGrid products={data.items} />
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
makeHref={(nextPage) => (nextPage > 1 ? `${baseHref}?page=${nextPage}` : baseHref)}
/>
</section>
</>
);
}
+2 -1
View File
@@ -7,7 +7,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `محصولات | ${SITE.nameFa}`,
description: SITE.tagline,
description: "خرید لوازم ورزشی و کوهنوردی از فروشگاه سالار استور",
alternates: { canonical: "/products" },
};
type Props = {