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
+2 -2
View File
@@ -1,6 +1,6 @@
---
description: Keep Meshkee static-image slot catalog in sync with on-page image sizes.
globs: lib/static-images.ts,app/meshkee/**,components/HeroSlider.tsx,components/Banners.tsx,components/Categories.tsx,components/AboutUs.tsx,app/page.tsx,app/aboutus/page.tsx
globs: lib/static-images.ts,app/meshkee/**,components/HeroSlider.tsx,components/Banners.tsx,components/Categories.tsx,components/Brands.tsx,components/AboutUs.tsx,app/page.tsx,app/aboutus/page.tsx
alwaysApply: false
---
@@ -14,4 +14,4 @@ When you change a slot’s layout, CSS aspect ratio, or displayed width:
2. Keep using `slot.aspectRatio` from the Meshkee API on the rendered frame.
3. Do not invent upload or CMS APIs. Empty/failed slots keep the local placeholder.
Keys: `slider`, `banners-below-slider`, `categories`, `four-banner-section`, `three-banner-row`, `about-us-image`.
Keys: `slider` (duplicatable), `two-banner-below-slider` (2), `categories` (duplicatable), `four-banner` (4), `two-banner-row` (2), `brands` (duplicatable), `about-us-image` (2).
+9 -6
View File
@@ -25,12 +25,15 @@ Source of truth: `lib/static-images.ts` (`STATIC_IMAGE_SLOT_CATALOG`).
| key | kind | itemCount | aspectRatio | where |
| --- | --- | --- | --- | --- |
| `slider` | list (duplicatable) | unbounded | `21:8` | Homepage hero |
| `banners-below-slider` | list | 2 | `13:5` | Two tiles under the slider |
| `categories` | list (duplicatable) | unbounded | `1:1` | Category row |
| `four-banner-section` | list | 4 | `21:9` | 2×2 banner grid |
| `three-banner-row` | list | 3 | `12:5` | Three-up row |
| `about-us-image` | single | 1 | `8:5` | Home about block + `/aboutus` hero |
| `slider` | list (duplicatable) | unbounded | `21:8` | Homepage hero. Overlay `titleFa` / `titleEn` / `subtext` when set. |
| `two-banner-below-slider` | list | 2 | `13:5` | Two tiles under the slider. Overlay `titleFa`, `titleEn`, `subtext` like the original UI. |
| `categories` | list (duplicatable) | unbounded | `1:1` | Category row. `titleFa` + `titleEn` beside the icon. |
| `four-banner` | list | 4 | `21:9` | 2×2 banner grid. Overlay `titleFa` / `titleEn` / `subtext` when set. |
| `two-banner-row` | list | 2 | `12:5` | Two-up row after top-selling. |
| `brands` | list (duplicatable) | unbounded | `2:1` | Brand logo carousel. |
| `about-us-image` | list | 2 | `8:5` | Home about uses image 1. `/aboutus` uses image 1 as hero and image 2 as the side portrait. |
Previous keys (`banners-below-slider`, `four-banner-section`, `three-banner-row`) are still read as aliases until the dashboard Refresh imports the catalog again.
Rules:
+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 = {
+4 -2
View File
@@ -6,12 +6,14 @@ import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
export function AboutUs({
image,
images = [],
aspectRatio,
}: {
image?: StaticImageWithUrl;
images?: StaticImageWithUrl[];
aspectRatio?: string;
}) {
const image = images[0];
return (
<section id="about" className="container-site mt-14">
<div className="grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
+25 -21
View File
@@ -30,7 +30,7 @@ export function BannerGrid4({ images = [], aspectRatio }: BannerSlotProps) {
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(aspectRatio, "21:9")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
{hasCopy ? (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]" style={banner4Overlay} />
@@ -85,28 +85,32 @@ export function BannerGrid4({ images = [], aspectRatio }: BannerSlotProps) {
);
}
export function BannerRow3({ images = [], aspectRatio }: BannerSlotProps) {
export function BannerRow({ images = [], aspectRatio }: BannerSlotProps) {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
{images.length > 0
? images.map((image) => (
<div key={image.id} className="overflow-hidden rounded-2xl">
<SlotImage
image={image}
alt={slotAlt(image)}
imgClassName="h-auto w-full object-cover"
aspectRatio={aspectRatio}
fallbackAspect="12:5"
/>
</div>
))
: banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
</div>
{images.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
{images.map((image) => (
<div key={image.id} className="overflow-hidden rounded-2xl">
<SlotImage
image={image}
alt={slotAlt(image, "بنر سالار استور")}
imgClassName="h-auto w-full object-cover"
aspectRatio={aspectRatio}
fallbackAspect="12:5"
/>
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
{banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
</div>
)}
</section>
);
}
+3 -3
View File
@@ -18,11 +18,11 @@ export async function BlogCarousel() {
<Carousel ariaLabel="مقالات">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
key={post.id || post.slug}
href={post.href || `/blog/${post.slug}`}
className="snap-start shrink-0 w-[240px] sm:w-[280px] rounded-2xl bg-white border border-black/5 overflow-hidden hover:border-brand/40"
>
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="280px" />
<Media src={post.image} alt={post.title} className="aspect-[16/10]" sizes="280px" />
<div className="p-4">
<time className="text-xs text-zinc-400">{post.date}</time>
<h3 className="mt-2 text-sm leading-7 line-clamp-2">{post.title}</h3>
+45 -9
View File
@@ -1,20 +1,56 @@
import Image from "next/image";
import { SlotImage } from "@/components/SlotImage";
import { brands } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { Carousel } from "./Carousel";
export function Brands() {
type Props = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
};
export function Brands({ images = [], aspectRatio }: Props) {
return (
<section className="container-site mt-12">
<h2 className="section-title mb-6">برندها</h2>
<Carousel ariaLabel="برندها">
{brands.map((brand) => (
<div
key={brand.name}
className="snap-start shrink-0 w-[168px] sm:w-[196px] h-[108px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
>
<Image src={brand.image} alt={brand.name} width={180} height={80} className="max-h-[72px] w-auto object-contain" />
</div>
))}
{images.length > 0
? images.map((image) => {
const card = (
<div
className="snap-start shrink-0 w-[168px] sm:w-[196px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
style={slotAspectStyle(aspectRatio, "2:1")}
>
<SlotImage
image={{ ...image, linkUrl: null }}
alt={slotAlt(image, "برند")}
imgClassName="max-h-[72px] w-auto object-contain"
/>
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="snap-start shrink-0">
{card}
</a>
) : (
<div key={image.id}>{card}</div>
);
})
: brands.map((brand) => (
<div
key={brand.name}
className="snap-start shrink-0 w-[168px] sm:w-[196px] h-[108px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
>
<Image
src={brand.image}
alt={brand.name}
width={180}
height={80}
className="max-h-[72px] w-auto object-contain"
/>
</div>
))}
</Carousel>
</section>
);
+1 -1
View File
@@ -84,7 +84,7 @@ export function CartPopup({ open, onClose }: Props) {
onClick={onClose}
className="size-20 shrink-0 overflow-hidden rounded-xl border border-black/5"
>
<Media src={item.image || PLACEHOLDER.product} alt="" className="size-full" sizes="80px" />
<Media src={item.image || PLACEHOLDER.product} alt={item.name} className="size-full" sizes="80px" />
</Link>
<div className="min-w-0 flex-1">
<Link
+29 -12
View File
@@ -2,11 +2,21 @@ import Link from "next/link";
import Image from "next/image";
import { SlotImage } from "@/components/SlotImage";
import { categories } from "@/lib/data";
import { getCategories } from "@/lib/api";
import { categoryHref } from "@/lib/paths";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { DrawFrame } from "./DrawFrame";
export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
export async function Categories({
images = [],
aspectRatio,
}: {
images?: StaticImageWithUrl[];
aspectRatio?: string;
}) {
const apiCategories = images.length > 0 ? [] : await getCategories();
const bySlug = new Map(apiCategories.map((item) => [item.slug, item]));
return (
<section className="container-site mt-12">
<div className="flex items-end justify-between mb-6">
@@ -18,27 +28,31 @@ export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
{images.length > 0
? images.map((image) => {
const label = slotAlt(image);
const nameFa = image.titleFa?.trim();
const nameEn = image.titleEn?.trim();
const card = (
<div className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand">
<div className="size-[72px] sm:size-20 shrink-0 overflow-hidden" style={slotAspectStyle("1:1")}>
<div
className="size-[72px] sm:size-20 shrink-0 overflow-hidden"
style={slotAspectStyle(aspectRatio, "1:1")}
>
<SlotImage
image={{ ...image, linkUrl: null }}
alt={label}
alt={slotAlt(image, nameFa || nameEn || "دسته‌بندی")}
imgClassName="h-full w-full object-contain"
/>
</div>
{label || image.subtext ? (
{nameFa || nameEn ? (
<span className="min-w-0">
{label ? (
<span className="block text-lg font-medium text-ink leading-none">{label}</span>
{nameFa ? (
<span className="block text-lg font-medium text-ink leading-none">{nameFa}</span>
) : null}
{image.subtext ? (
{nameEn ? (
<span
dir="ltr"
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
>
{image.subtext}
{nameEn}
</span>
) : null}
</span>
@@ -54,10 +68,12 @@ export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
<div key={image.id}>{card}</div>
);
})
: categories.map((cat) => (
: categories.map((cat) => {
const api = bySlug.get(cat.slug);
return (
<Link
key={cat.slug}
href={`/products/${cat.slug}`}
href={api ? categoryHref(api) : `/products/${cat.slug}`}
className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand"
>
<Image
@@ -78,7 +94,8 @@ export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
</span>
<DrawFrame />
</Link>
))}
);
})}
</div>
</section>
);
+1 -1
View File
@@ -35,7 +35,7 @@ export function Footer() {
<div className="container-site grid grid-cols-2 md:grid-cols-4 gap-6 py-8">
{features.map((item) => (
<div key={item.title} className="flex items-center gap-3">
<Image src={item.image} alt="" width={48} height={48} className="w-12 h-12 object-contain" />
<Image src={item.image} alt={item.title} width={48} height={48} className="w-12 h-12 object-contain" />
<h3 className="text-sm font-medium text-ink">{item.title}</h3>
</div>
))}
+12 -12
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { customerLoginUrl } from "@/lib/customer";
import { parentCategories } from "@/lib/data";
import type { MenuCategory } from "@/lib/nav";
import { SITE } from "@/lib/site";
import { useCart } from "@/lib/cart";
import { useSession } from "@/lib/session";
@@ -20,7 +20,7 @@ const nav = [
{ name: "تماس با ما", href: "/contactus" },
];
export function Header() {
export function Header({ menuCategories = [] }: { menuCategories?: MenuCategory[] }) {
const [open, setOpen] = useState(false);
const [mega, setMega] = useState(false);
const [cartOpen, setCartOpen] = useState(false);
@@ -60,16 +60,16 @@ export function Header() {
</Link>
{mega && (
<div className="absolute top-full right-0 w-[760px] rounded-2xl bg-white border border-black/5 shadow-xl p-5 grid grid-cols-4 gap-5">
{parentCategories.map((cat) => (
<div key={cat.slug}>
<Link href={`/products/${cat.slug}`} className="block mb-3 text-sm font-bold text-brand">
{menuCategories.map((cat) => (
<div key={cat.href}>
<Link href={cat.href} className="block mb-3 text-sm font-bold text-brand">
{cat.name}
</Link>
<ul className="space-y-1.5">
{cat.children.slice(0, 8).map((child) => (
<li key={child.slug}>
<li key={child.href}>
<Link
href={`/products/${child.slug}`}
href={child.href}
className="block text-[13px] text-zinc-600 hover:text-ink"
>
{child.name}
@@ -131,15 +131,15 @@ export function Header() {
<SearchBox className="mb-5 rounded-full bg-zinc-100 px-3" onNavigate={() => setOpen(false)} />
<p className="text-xs text-zinc-400 mb-2">محصولات</p>
<div className="space-y-4 mb-6">
{parentCategories.map((cat) => (
<div key={cat.slug}>
<Link href={`/products/${cat.slug}`} className="font-bold text-brand" onClick={() => setOpen(false)}>
{menuCategories.map((cat) => (
<div key={cat.href}>
<Link href={cat.href} className="font-bold text-brand" onClick={() => setOpen(false)}>
{cat.name}
</Link>
<ul className="mt-2 grid grid-cols-2 gap-1">
{cat.children.slice(0, 6).map((child) => (
<li key={child.slug}>
<Link href={`/products/${child.slug}`} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
<li key={child.href}>
<Link href={child.href} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
{child.name}
</Link>
</li>
+1 -1
View File
@@ -177,7 +177,7 @@ export function HeroSlider({
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(sideAspectRatio, "13:5")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
{hasCopy ? (
<>
<div
+2 -2
View File
@@ -8,14 +8,14 @@ export function ProductCard({ product }: { product: Product }) {
const unavailable = product.stock === 0 || product.price === 0;
return (
<Link href={product.slug ? `/products/${product.slug}` : "/products"} className="relative flex h-full min-h-[300px] flex-col overflow-hidden rounded-xl bg-white p-2">
<Link href={product.href || "/products"} className="relative flex h-full min-h-[300px] flex-col overflow-hidden rounded-xl bg-white p-2">
{off > 0 && (
<span className="absolute top-2 left-2 z-[2] rounded-full bg-brand px-2.5 py-1.5 font-oswald text-[13px] font-bold leading-none text-white shadow-md">
{off}%
</span>
)}
<div className="relative">
<Media src={product.image} alt="" className="aspect-square rounded-lg" sizes="220px" />
<Media src={product.image} alt={product.name} className="aspect-square rounded-lg" sizes="220px" />
</div>
<h3 className="mt-2 px-1 text-[13px] leading-6 text-ink line-clamp-2 min-h-12">{product.name}</h3>
<div className="mt-auto px-1 pb-1 pt-2">
+1 -1
View File
@@ -22,7 +22,7 @@ export function ProductShelf({ id, image, products, ariaLabel, tone = "red" }: P
<div className="rounded-2xl p-4 md:p-5" style={{ background: gradients[tone] }}>
<div className="flex items-stretch gap-3">
<div className="relative w-[150px] sm:w-[190px] shrink-0 min-h-[300px]">
<Image src={image} alt="" fill sizes="190px" className="object-contain object-center" />
<Image src={image} alt={ariaLabel} fill sizes="190px" className="object-contain object-center" />
</div>
<Carousel ariaLabel={ariaLabel} className="min-w-0 flex-1 py-1" tight>
{products.map((product) => (
+7 -6
View File
@@ -29,9 +29,10 @@ export function ProductView({ product, variants }: Props) {
const [qty, setQty] = useState(1);
const [added, setAdded] = useState(false);
const name = product.nameFa || product.title;
const variant = variants.find((item) => item.id === variantId) ?? variants[0];
const price = variant ? (variant.discountedPrice ?? variant.price) : (product.store?.minPrice ?? 0);
const oldPrice = variant?.discountedPrice ? variant.price : undefined;
const price = variant ? (variant.discountedPrice ?? variant.price ?? 0) : (product.store?.minPrice ?? 0);
const oldPrice = variant?.discountedPrice ? (variant.price ?? undefined) : undefined;
const stock = variant ? (variant.stockQuantity ?? 0) : product.store?.inStock ? 1 : 0;
const unavailable = !price || stock === 0;
const off = discountPercent(price, oldPrice);
@@ -45,11 +46,11 @@ export function ProductView({ product, variants }: Props) {
{off}%
</span>
)}
<Media src={image} alt={product.nameFa || product.title} className="aspect-square" sizes="560px" priority />
<Media src={image} alt={name} className="aspect-square" sizes="560px" priority />
</div>
{images.length > 1 && (
<div className="mt-3 grid grid-cols-6 gap-2">
{images.map((src) => (
{images.map((src, index) => (
<button
key={src}
type="button"
@@ -58,7 +59,7 @@ export function ProductView({ product, variants }: Props) {
src === image ? "border-brand" : "border-black/10"
}`}
>
<Media src={src} alt="" className="size-full" sizes="80px" />
<Media src={src} alt={`${name} — تصویر ${(index + 1).toLocaleString("fa-IR")}`} className="size-full" sizes="80px" />
</button>
))}
</div>
@@ -67,7 +68,7 @@ export function ProductView({ product, variants }: Props) {
<div className="rounded-2xl border border-black/5 p-5 md:p-6 lg:col-span-6">
{product.categoryNameFa && <p className="mb-2 text-xs text-zinc-400">{product.categoryNameFa}</p>}
<h1 className="text-xl md:text-2xl font-extrabold leading-9">{product.nameFa || product.title}</h1>
<p className="text-xl md:text-2xl font-extrabold leading-9">{name}</p>
{product.title && product.nameFa && (
<p dir="ltr" className="mt-1 text-sm text-zinc-400">
{product.title}
+2 -2
View File
@@ -100,11 +100,11 @@ export function SearchBox({ className = "", onNavigate }: Props) {
return (
<li key={product.id}>
<Link
href={product.slug ? `/products/${product.slug}` : `/products?q=${encodeURIComponent(query)}`}
href={product.href || `/products?q=${encodeURIComponent(query)}`}
onClick={onNavigate}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-50"
>
<Media src={product.image} alt="" className="size-12 shrink-0 rounded-lg" sizes="48px" />
<Media src={product.image} alt={product.name} className="size-12 shrink-0 rounded-lg" sizes="48px" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] leading-6">{product.name}</p>
{unavailable ? (
+47 -9
View File
@@ -87,13 +87,18 @@ export type ApiCategory = {
name: string;
nameFa: string | null;
slug: string;
description?: string | null;
isActive?: boolean;
};
export type ApiVariant = {
id: string;
productId?: string;
productTitle?: string | null;
productNameFa?: string | null;
productImage?: string | null;
label?: string | null;
price: number;
price: number | null;
discountedPrice?: number | null;
stockQuantity?: number | null;
sortOrder?: number;
@@ -152,6 +157,18 @@ export async function getProduct(slug: string) {
}
}
export async function getProductById(productId: string) {
try {
const data = await apiGet<{ product: ApiProduct }>(
`${tenant()}/products/by-id/${encodeURIComponent(productId)}`,
);
return data.product ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
export async function getBlogs(query: { page?: number; pageSize?: number; title?: string } = {}) {
return apiGet<Paginated<ApiBlog>>(`${tenant()}/blogs`, query);
}
@@ -166,6 +183,16 @@ export async function getBlog(slug: string) {
}
}
export async function getBlogById(blogId: string) {
try {
const data = await apiGet<{ blog: ApiBlog }>(`${tenant()}/blogs/by-id/${encodeURIComponent(blogId)}`);
return data.blog ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
export async function getStoreSpecials() {
const data = await apiGet<{ items: ApiStoreSpecial[] }>(`${tenant()}/store-specials`);
return data.items ?? [];
@@ -173,8 +200,10 @@ export async function getStoreSpecials() {
export async function getStoreItemByProduct(productId: string) {
try {
const data = await apiGet<{ storeItem: ApiStoreItem }>(`${tenant()}/store-items/by-product/${productId}`);
return data.storeItem;
const data = await apiGet<{ storeItem: ApiStoreItem | null }>(
`${tenant()}/store-items/by-product/${productId}`,
);
return data.storeItem ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
@@ -195,6 +224,21 @@ export async function getCategoryBySlug(slug: string) {
return categories.find((category) => category.slug === slug) ?? null;
}
export async function getCategoryById(categoryId: string) {
try {
const data = await apiGet<{ category: ApiCategory }>(
`${tenant()}/categories/by-id/${encodeURIComponent(categoryId)}`,
);
return data.category ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
const categories = await getCategories();
return categories.find((category) => category.id === categoryId) ?? null;
}
throw error;
}
}
export type ApiTenant = {
id: string | number;
name: string;
@@ -219,9 +263,3 @@ export async function getComments(entityType: "product" | "blog", entityId: stri
return [];
}
}
export async function resolveProductSlug(productId: string, name: string) {
if (!name) return "";
const { items } = await getProducts({ name, pageSize: 20 });
return items.find((item) => item.id === productId)?.slug ?? "";
}
+3
View File
@@ -7,6 +7,7 @@ export type Product = {
id: string;
name: string;
slug: string;
href?: string;
price: number;
oldPrice?: number;
stock?: number | null;
@@ -14,8 +15,10 @@ export type Product = {
};
export type Post = {
id?: string;
slug: string;
title: string;
href?: string;
date: string;
image: ImageSrc;
abstract?: string;
+17 -17
View File
@@ -3,13 +3,13 @@ import {
getBlogs,
getProducts,
getStoreSpecials,
resolveProductSlug,
type ApiBlog,
type ApiProduct,
type ApiSpecialItem,
type ApiVariant,
} from "./api";
import { formatDate } from "./format";
import { blogHref, productHref } from "./paths";
function productImage(url?: string | null) {
return url || PLACEHOLDER.product;
@@ -23,35 +23,39 @@ function pickVariantPrice(variants: ApiVariant[]) {
const inStock = variants.filter((variant) => (variant.stockQuantity ?? 0) > 0);
const pool = inStock.length ? inStock : variants;
const best = pool.reduce((current, next) => {
const currentPrice = current.discountedPrice ?? current.price;
const nextPrice = next.discountedPrice ?? next.price;
const currentPrice = current.discountedPrice ?? current.price ?? 0;
const nextPrice = next.discountedPrice ?? next.price ?? 0;
return nextPrice < currentPrice ? next : current;
});
const price = best.discountedPrice ?? best.price ?? 0;
const oldPrice = best.discountedPrice ? best.price : undefined;
const oldPrice = best.discountedPrice ? (best.price ?? undefined) : undefined;
const stock = variants.reduce((sum, variant) => sum + (variant.stockQuantity ?? 0), 0);
return { price, oldPrice, stock };
}
export function mapProduct(item: ApiProduct): Product {
const name = item.nameFa || item.title;
return {
id: item.id,
name: item.nameFa || item.title,
name,
slug: item.slug,
href: productHref(item),
price: item.store?.minPrice ?? 0,
stock: item.store?.inStock ? item.store.variantCount || 1 : 0,
image: productImage(item.thumbnailUrl || item.image || item.thumbnail),
};
}
export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
export function mapSpecialItem(item: ApiSpecialItem): Product {
const { price, oldPrice, stock } = pickVariantPrice(item.variants ?? []);
const name = item.productNameFa || item.productTitle;
return {
id: item.productId,
name: item.productNameFa || item.productTitle,
slug,
name,
slug: "",
href: productHref(item.productId, name),
price,
oldPrice,
stock,
@@ -61,8 +65,10 @@ export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
export function mapBlog(item: ApiBlog): Post {
return {
id: item.id,
slug: item.slug,
title: item.title,
href: blogHref(item),
date: formatDate(item.publishedAt || item.createdAt || ""),
image: item.titleImageUrl || PLACEHOLDER.blog,
abstract: item.abstract?.trim() || undefined,
@@ -75,16 +81,10 @@ export async function getSpecialProducts(key: string): Promise<Product[]> {
const special = specials.find((item) => item.isActive && item.key.toLowerCase() === key.toLowerCase());
if (!special) return [];
const items = special.items
return special.items
.filter((item) => item.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder);
return Promise.all(
items.map(async (item) => {
const slug = await resolveProductSlug(item.productId, item.productTitle || item.productNameFa || "");
return mapSpecialItem(item, slug);
}),
);
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item) => mapSpecialItem(item));
} catch {
return [];
}
+26
View File
@@ -0,0 +1,26 @@
import { getCategories, type ApiCategory } from "./api";
import { parentCategories } from "./data";
import { categoryHref } from "./paths";
export type MenuCategory = {
name: string;
href: string;
children: { name: string; href: string }[];
};
function hrefForSlug(categories: ApiCategory[], slug: string) {
const match = categories.find((category) => category.slug === slug);
return match ? categoryHref(match) : `/products/${slug}`;
}
export async function getMenuCategories(): Promise<MenuCategory[]> {
const categories = await getCategories();
return parentCategories.map((parent) => ({
name: parent.name,
href: hrefForSlug(categories, parent.slug),
children: parent.children.map((child) => ({
name: child.name,
href: hrefForSlug(categories, child.slug),
})),
}));
}
+40
View File
@@ -0,0 +1,40 @@
import type { ApiBlog, ApiCategory, ApiProduct } from "./api";
import { detailPath, slugifyForUrl } from "./seo-slug";
export function productTitle(product: Pick<ApiProduct, "id" | "nameFa" | "title">) {
return product.nameFa?.trim() || product.title?.trim() || String(product.id);
}
export function categoryTitle(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
return category.nameFa?.trim() || category.name?.trim() || String(category.id);
}
/** Public product URL: /products/{id}/{nameFaSlug} — never use legacy DB slug. */
export function productHref(product: Pick<ApiProduct, "id" | "nameFa" | "title"> | string, title?: string | null) {
if (typeof product === "string") {
return detailPath("/products", product, title?.trim() || product);
}
return detailPath("/products", product.id, productTitle(product));
}
/** Public category URL: /products/category/{id}/{nameFaSlug} */
export function categoryHref(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
return detailPath("/products/category", category.id, categoryTitle(category));
}
/** Public blog URL: /blog/{id}/{titleSlug} */
export function blogHref(blog: Pick<ApiBlog, "id" | "title">) {
return detailPath("/blog", blog.id, blog.title);
}
export function productNameSlug(title: string) {
return slugifyForUrl(title);
}
export function categoryNameSlug(title: string) {
return slugifyForUrl(title);
}
export function blogTitleSlug(title: string) {
return slugifyForUrl(title);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Build a URL path segment from a title (Persian / Arabic / Latin).
* Keeps letters and digits; spaces → hyphens; strips other punctuation.
*/
export function slugifyForUrl(value: string, fallback = "item"): string {
const slug = value
.trim()
.normalize("NFC")
.replace(/\s+/g, "-")
.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug || fallback;
}
export function detailPath(base: string, id: string | number, title: string) {
const slug = slugifyForUrl(title);
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
}
+28
View File
@@ -0,0 +1,28 @@
{
"baseUrl": "https://salarstore.com",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/aboutus",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/contactus",
"priority": 0.5
},
{
"path": "/products",
"changefreq": "daily",
"priority": 0.9
}
]
}
+53 -26
View File
@@ -44,13 +44,21 @@ export type StaticImageCatalogSlot = {
*/
export const STATIC_IMAGE_KEYS = {
slider: "slider",
bannersBelowSlider: "banners-below-slider",
twoBannerBelowSlider: "two-banner-below-slider",
categories: "categories",
fourBannerSection: "four-banner-section",
threeBannerRow: "three-banner-row",
fourBanner: "four-banner",
twoBannerRow: "two-banner-row",
brands: "brands",
aboutUsImage: "about-us-image",
} as const;
/** Previous catalog keys still returned by the tenant API until dashboard Refresh. */
const SLOT_KEY_ALIASES: Record<string, string[]> = {
"two-banner-below-slider": ["banners-below-slider"],
"four-banner": ["four-banner-section"],
"two-banner-row": ["three-banner-row"],
};
export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
{
key: STATIC_IMAGE_KEYS.slider,
@@ -62,8 +70,8 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
recommendedWidth: 1200,
},
{
key: STATIC_IMAGE_KEYS.bannersBelowSlider,
label: "Banners below slider",
key: STATIC_IMAGE_KEYS.twoBannerBelowSlider,
label: "Two banners below slider",
kind: "list",
pageKey: "home",
aspectRatio: "13:5",
@@ -80,7 +88,7 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
recommendedWidth: 160,
},
{
key: STATIC_IMAGE_KEYS.fourBannerSection,
key: STATIC_IMAGE_KEYS.fourBanner,
label: "Four banner section",
kind: "list",
pageKey: "home",
@@ -89,21 +97,30 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.threeBannerRow,
label: "Three banner row",
key: STATIC_IMAGE_KEYS.twoBannerRow,
label: "Two banner row",
kind: "list",
pageKey: "home",
aspectRatio: "12:5",
itemCount: 3,
recommendedWidth: 480,
itemCount: 2,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.brands,
label: "Brand logos",
kind: "list",
pageKey: "home",
aspectRatio: "2:1",
itemCount: null,
recommendedWidth: 196,
},
{
key: STATIC_IMAGE_KEYS.aboutUsImage,
label: "About us image",
kind: "single",
label: "About us images",
kind: "list",
pageKey: "home",
aspectRatio: "8:5",
itemCount: 1,
itemCount: 2,
recommendedWidth: 720,
},
];
@@ -121,8 +138,14 @@ export function slotAlt(image: StaticImage, fallback = ""): string {
return image.titleFa?.trim() || image.titleEn?.trim() || fallback;
}
export function slotByKey(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
const aliases = SLOT_KEY_ALIASES[key] ?? [];
return slots.find((slot) => slot.key === key) ?? slots.find((slot) => aliases.includes(slot.key));
}
export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImageWithUrl[] {
const slot = slots.find((item) => item.key === key);
const slot = slotByKey(slots, key);
const catalog = STATIC_IMAGE_SLOT_CATALOG.find((item) => item.key === key);
const raw = slot?.images ?? [];
const sorted = [...raw].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const result: StaticImageWithUrl[] = [];
@@ -131,7 +154,11 @@ export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImag
if (!url) continue;
result.push({ ...image, url });
}
if (slot?.kind === "single") return result.slice(0, 1);
const kind = catalog?.kind ?? slot?.kind ?? "list";
const limit = catalog?.itemCount ?? (kind === "single" ? 1 : null);
if (kind === "single" || limit === 1) return result.slice(0, 1);
if (limit && limit > 0) return result.slice(0, limit);
return result;
}
@@ -142,10 +169,6 @@ export function firstImageForSlot(
return imagesForSlot(slots, key)[0];
}
export function slotByKey(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
return slots.find((slot) => slot.key === key);
}
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images`);
@@ -160,12 +183,16 @@ export async function getStaticImageSlots(pageKey?: string): Promise<StaticImage
}
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${key}`);
const response = await fetch(url, { next: { revalidate: 120 } });
if (!response.ok) return null;
return (await response.json()) as StaticImageSlot;
} catch {
return null;
const keys = [key, ...(SLOT_KEY_ALIASES[key] ?? [])];
for (const candidate of keys) {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${candidate}`);
const response = await fetch(url, { next: { revalidate: 120 } });
if (!response.ok) continue;
return (await response.json()) as StaticImageSlot;
} catch {
// try next alias
}
}
return null;
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import type { ApiProduct, ApiStoreItem, ApiVariant } from "./api";
function sellingPrice(variant: ApiVariant): number {
const value = variant.discountedPrice ?? variant.price;
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
}
function inStock(variant: ApiVariant): boolean {
return variant.stockQuantity === null || (typeof variant.stockQuantity === "number" && variant.stockQuantity > 0);
}
export function pickTorobVariant(variants: ApiVariant[]): ApiVariant | undefined {
if (!variants.length) return undefined;
const available = variants.filter(inStock);
const pool = available.length ? available : variants;
return pool.reduce((cheapest, variant) =>
sellingPrice(variant) < sellingPrice(cheapest) ? variant : cheapest,
);
}
function toHttpsUrl(url?: string | null): string | undefined {
if (!url) return undefined;
const trimmed = url.trim();
if (trimmed.startsWith("https://")) return trimmed;
if (trimmed.startsWith("http://")) return `https://${trimmed.slice("http://".length)}`;
if (trimmed.startsWith("//")) return `https:${trimmed}`;
return undefined;
}
export function torobMetadata(product: ApiProduct, storeItem: ApiStoreItem | null): Pick<Metadata, "other" | "openGraph"> {
const variant = storeItem ? pickTorobVariant(storeItem.variants ?? []) : undefined;
const price = storeItem && variant ? sellingPrice(variant) : 0;
const available = Boolean(storeItem && variant && inStock(variant));
const name = variant?.productNameFa || variant?.productTitle || product.nameFa || product.title;
const image =
toHttpsUrl(variant?.productImage) ||
toHttpsUrl(product.thumbnailUrl) ||
toHttpsUrl(product.image) ||
toHttpsUrl(product.thumbnail) ||
toHttpsUrl(product.images?.[0]?.url);
const other: Record<string, string> = {
product_id: product.id,
product_name: name,
product_price: String(price),
availability: available ? "instock" : "outofstock",
};
if (variant && variant.discountedPrice != null) {
other.product_old_price = String(typeof variant.price === "number" ? Math.trunc(variant.price) : 0);
}
return {
other,
...(image ? { openGraph: { images: [{ url: image }] } } : {}),
};
}
+3 -1
View File
@@ -3,10 +3,12 @@
"version": "0.1.0",
"private": true,
"scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
},
"dependencies": {
"next": "16.3.1",
+93
View File
@@ -0,0 +1,93 @@
/**
* Scans app page routes and writes lib/sitemap-config.generated.json
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const appDir = path.join(root, "app");
const outFile = path.join(root, "lib", "sitemap-config.generated.json");
const EXCLUDE_SEGMENTS = new Set([
"api",
"meshkee",
"admin",
"auth",
"login",
"checkout",
"cart",
"account",
]);
const PRIORITY = {
"/": { changefreq: "daily", priority: 1.0 },
"/products": { changefreq: "daily", priority: 0.9 },
"/blog": { changefreq: "weekly", priority: 0.8 },
"/aboutus": { priority: 0.6 },
"/contactus": { priority: 0.5 },
};
function websiteDomain() {
return (
process.env.NEXT_PUBLIC_DOMAIN ||
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
"salarstore.com"
);
}
function walkPages(dir, segments = []) {
/** @type {string[]} */
const pages = [];
if (!fs.existsSync(dir)) return pages;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
if (/^\(.+\)$/.test(entry.name)) {
pages.push(...walkPages(full, segments));
}
continue;
}
pages.push(...walkPages(full, [...segments, entry.name]));
continue;
}
if (entry.name === "page.tsx" || entry.name === "page.ts" || entry.name === "page.jsx" || entry.name === "page.js") {
const routePath = segments.length ? `/${segments.join("/")}` : "/";
pages.push(routePath);
}
}
return pages;
}
function main() {
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b);
});
const staticPages = paths.map((routePath) => {
const meta = PRIORITY[routePath] ?? { priority: 0.5 };
return { path: routePath, ...meta };
});
const config = {
baseUrl: `https://${websiteDomain()}`,
staticPages,
};
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`);
}
main();