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
+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 }] } } : {}),
};
}