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
+22 -11
View File
@@ -5,27 +5,30 @@ import { SlotImage } from "@/components/SlotImage";
import { SITE } from "@/lib/site";
import {
STATIC_IMAGE_KEYS,
firstImageForSlot,
getStaticImageSlot,
imagesForSlot,
slotAlt,
} from "@/lib/static-images";
export const metadata: Metadata = {
title: `درباره ما | ${SITE.nameFa}`,
description: SITE.about,
alternates: { canonical: "/aboutus" },
};
export default async function AboutPage() {
const slot = await getStaticImageSlot(STATIC_IMAGE_KEYS.aboutUsImage);
const image = slot ? firstImageForSlot([slot], STATIC_IMAGE_KEYS.aboutUsImage) : undefined;
const images = slot ? imagesForSlot([slot], STATIC_IMAGE_KEYS.aboutUsImage) : [];
const hero = images[0];
const side = images[1];
return (
<>
<PageHero title="درباره ما" english="About Us" />
<section className="container-site py-10 md:py-14">
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
{image ? (
<SlotImage image={image} fill alt={slotAlt(image, SITE.nameFa)} />
{hero ? (
<SlotImage image={hero} fill alt={slotAlt(hero, SITE.nameFa)} />
) : (
<Image
src="/images/pages/about-bg.webp"
@@ -56,13 +59,21 @@ export default async function AboutPage() {
<p className="text-zinc-600 leading-8 text-justify">{SITE.about}</p>
</div>
<div className="relative mx-auto w-full max-w-xs">
<Image
src="/images/pages/about-side.png"
alt={SITE.nameFa}
width={300}
height={603}
className="h-auto w-full object-contain"
/>
{side ? (
<SlotImage
image={side}
alt={slotAlt(side, SITE.nameFa)}
imgClassName="h-auto w-full object-contain"
/>
) : (
<Image
src="/images/pages/about-side.png"
alt={SITE.nameFa}
width={300}
height={603}
className="h-auto w-full object-contain"
/>
)}
</div>
</div>
</section>
@@ -1,25 +1,27 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect } from "next/navigation";
import { Media } from "@/components/Media";
import { PageHero } from "@/components/PageHero";
import { RichContent } from "@/components/RichContent";
import { getBlog } from "@/lib/api";
import { getBlogById } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { PLACEHOLDER } from "@/lib/data";
import { blogHref, blogTitleSlug } from "@/lib/paths";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ slug: string }>;
params: Promise<{ id: string; slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const { id } = await params;
try {
const blog = await getBlog(slug);
const blog = await getBlogById(id);
if (!blog) return { title: SITE.nameFa };
return {
title: `${blog.title} | ${SITE.nameFa}`,
description: blog.abstract || SITE.aboutShort,
description: blog.abstract?.trim() || SITE.aboutShort,
alternates: { canonical: blogHref(blog) },
};
} catch {
return { title: SITE.nameFa };
@@ -27,10 +29,14 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
export default async function BlogDetailPage({ params }: Props) {
const { slug } = await params;
const blog = await getBlog(slug);
const { id, slug } = await params;
const blog = await getBlogById(id);
if (!blog) notFound();
if (decodeURIComponent(slug) !== blogTitleSlug(blog.title)) {
permanentRedirect(blogHref(blog));
}
const author = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ");
return (
+20
View File
@@ -0,0 +1,20 @@
import { notFound, permanentRedirect } from "next/navigation";
import { getBlog, getBlogById } from "@/lib/api";
import { blogHref } from "@/lib/paths";
/** Legacy `/blog/{slug-or-id}` → canonical `/blog/{id}/{titleSlug}`. */
export default async function LegacyBlogRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const byId = await getBlogById(id);
if (byId) permanentRedirect(blogHref(byId));
const bySlug = await getBlog(id);
if (bySlug) permanentRedirect(blogHref(bySlug));
notFound();
}
+5 -4
View File
@@ -9,7 +9,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `وبلاگ | ${SITE.nameFa}`,
description: "آخرین مقالات سالار استور",
description: "مقالات کوهنوردی، کمپینگ و راهنمای خرید لوازم ورزشی از سالار استور",
alternates: { canonical: "/blog" },
};
type Props = {
@@ -35,11 +36,11 @@ export default async function BlogPage({ searchParams }: Props) {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
key={post.id || post.slug}
href={post.href || `/blog/${post.slug}`}
className="overflow-hidden rounded-2xl border border-black/5 bg-white hover:border-brand/40"
>
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="400px" />
<Media src={post.image} alt={post.title} className="aspect-[16/10]" sizes="400px" />
<div className="p-4">
<time className="text-xs text-zinc-400">{post.date}</time>
<h2 className="mt-2 text-base font-bold leading-8 line-clamp-2">{post.title}</h2>
+2 -1
View File
@@ -6,7 +6,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `تماس با ما | ${SITE.nameFa}`,
description: SITE.address,
description: `تماس با فروشگاه لوازم ورزشی سالار استور — ${SITE.address}`,
alternates: { canonical: "/contactus" },
};
const contacts = [
+4 -2
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { TopHeader } from "@/components/TopHeader";
import { getTenant } from "@/lib/api";
import { getMenuCategories } from "@/lib/nav";
import { SITE } from "@/lib/site";
import "./globals.css";
@@ -28,19 +29,20 @@ const carter = Carter_One({
});
export const metadata: Metadata = {
metadataBase: new URL(`https://${SITE.domain}`),
title: `${SITE.nameFa} | ${SITE.tagline}`,
description: SITE.aboutShort,
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const tenant = await getTenant();
const [tenant, menuCategories] = await Promise.all([getTenant(), getMenuCategories()]);
return (
<html lang="fa" dir="rtl" className={`${vazir.variable} ${oswald.variable} ${carter.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-white text-ink">
<AppProviders businessId={tenant ? String(tenant.id) : ""}>
<TopHeader />
<Header />
<Header menuCategories={menuCategories} />
<main className="flex-1">{children}</main>
<Footer />
</AppProviders>
+17
View File
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
+7
View File
@@ -1,4 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `صفحه پیدا نشد | ${SITE.nameFa}`,
description: "صفحه مورد نظر در سالار استور پیدا نشد.",
};
export default function NotFound() {
return (
+28 -16
View File
@@ -1,5 +1,5 @@
import { AboutUs } from "@/components/AboutUs";
import { BannerGrid4, BannerRow3 } from "@/components/Banners";
import { BannerGrid4, BannerRow } from "@/components/Banners";
import { BlogCarousel } from "@/components/BlogCarousel";
import { Brands } from "@/components/Brands";
import { Categories } from "@/components/Categories";
@@ -8,44 +8,56 @@ import { SpecialSale } from "@/components/SpecialSale";
import { TopSelling } from "@/components/TopSelling";
import {
STATIC_IMAGE_KEYS,
firstImageForSlot,
getStaticImageSlots,
imagesForSlot,
slotByKey,
} from "@/lib/static-images";
import { SITE } from "@/lib/site";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: `${SITE.nameFa} | ${SITE.tagline}`,
description: SITE.aboutShort,
alternates: { canonical: "/" },
};
export default async function Home() {
const slots = await getStaticImageSlots("home");
const sliderSlot = slotByKey(slots, STATIC_IMAGE_KEYS.slider);
const sideSlot = slotByKey(slots, STATIC_IMAGE_KEYS.bannersBelowSlider);
const fourSlot = slotByKey(slots, STATIC_IMAGE_KEYS.fourBannerSection);
const threeSlot = slotByKey(slots, STATIC_IMAGE_KEYS.threeBannerRow);
const sideSlot = slotByKey(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider);
const categorySlot = slotByKey(slots, STATIC_IMAGE_KEYS.categories);
const fourSlot = slotByKey(slots, STATIC_IMAGE_KEYS.fourBanner);
const twoRowSlot = slotByKey(slots, STATIC_IMAGE_KEYS.twoBannerRow);
const brandsSlot = slotByKey(slots, STATIC_IMAGE_KEYS.brands);
const aboutSlot = slotByKey(slots, STATIC_IMAGE_KEYS.aboutUsImage);
return (
<>
<h1 className="sr-only">
{SITE.nameFa}؛ {SITE.tagline}
</h1>
<HeroSlider
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)}
aspectRatio={sliderSlot?.aspectRatio}
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.bannersBelowSlider)}
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider)}
sideAspectRatio={sideSlot?.aspectRatio}
/>
<SpecialSale />
<Categories images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)} />
<Categories
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)}
aspectRatio={categorySlot?.aspectRatio}
/>
<BannerGrid4
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBannerSection)}
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBanner)}
aspectRatio={fourSlot?.aspectRatio}
/>
<TopSelling />
<BannerRow3
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.threeBannerRow)}
aspectRatio={threeSlot?.aspectRatio}
/>
<Brands />
<AboutUs
image={firstImageForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)}
aspectRatio={aboutSlot?.aspectRatio}
<BannerRow
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerRow)}
aspectRatio={twoRowSlot?.aspectRatio}
/>
<Brands images={imagesForSlot(slots, STATIC_IMAGE_KEYS.brands)} aspectRatio={brandsSlot?.aspectRatio} />
<AboutUs images={imagesForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)} aspectRatio={aboutSlot?.aspectRatio} />
<BlogCarousel />
</>
);
+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 = {