Add Meshkee SEO, customer auth menu, and footer polish.

Implement canonical product/category/blog URLs, sitemap-config endpoint, page metadata, customer login dropdown with admin handoff, safeteb-style footer bar, backend favicon, and remove the standalone partners strip.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-02 23:43:40 +03:30
co-authored by Cursor
parent 7a0de3c1f4
commit b2a0fe73bc
38 changed files with 1902 additions and 660 deletions
+145 -3
View File
@@ -3,6 +3,52 @@ import { toFa } from "@/lib/format";
const API_BASE = "https://api.meshkee.com/api/v1";
export const TENANT_DOMAIN = "aradarisman.com";
export type Tenant = {
id: number;
name: string;
nameFa: string;
slug: string;
domain: string;
};
export async function getTenant(): Promise<Tenant | null> {
try {
return await meshkeeFetch<Tenant>(`/tenants/${TENANT_DOMAIN}`, 3600);
} catch {
return null;
}
}
export async function getBusinessId(): Promise<string> {
const tenant = await getTenant();
return tenant?.id != null ? String(tenant.id) : "";
}
export type WebsiteFavicon = {
faviconUrl: string | null;
logoUrl: string | null;
logoDarkUrl: string | null;
hasDedicatedFavicon: boolean;
};
const defaultFavicon: WebsiteFavicon = {
faviconUrl: null,
logoUrl: null,
logoDarkUrl: null,
hasDedicatedFavicon: false,
};
export async function getWebsiteFavicon(): Promise<WebsiteFavicon> {
try {
return await meshkeeFetch<WebsiteFavicon>(
`/tenants/${TENANT_DOMAIN}/website/favicon`,
3600
);
} catch {
return defaultFavicon;
}
}
/**
* Business-managed image overrides (hero slider, category tiles, feature
* icons, about photo, brand logos). Each slot has a stable `key`; when a
@@ -139,6 +185,17 @@ export async function getProduct(slug: string): Promise<Product | null> {
return data.product as Product;
}
export async function getProductById(id: string): Promise<Product | null> {
const res = await fetch(
`${API_BASE}/tenants/${TENANT_DOMAIN}/products/by-id/${id}`,
{ next: { revalidate: 300 } }
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Meshkee API product id "${id}" failed: ${res.status}`);
const data = await res.json();
return data.product as Product;
}
export function productImage(product: Product): string | null {
return product.thumbnail || product.image || product.thumbnailUrl || null;
}
@@ -151,9 +208,72 @@ export function productPriceLabel(product: Product): string {
return "تماس بگیرید";
}
export type Category = {
id: string;
parentId: string | null;
name: string;
nameFa: string;
slug: string;
sortOrder: number;
isActive: boolean;
};
export async function getCategories(): Promise<Category[]> {
try {
const data = await meshkeeFetch<{ items: Category[] }>(
`/tenants/${TENANT_DOMAIN}/categories`
);
return data.items.filter((c) => c.isActive);
} catch {
return [];
}
}
export async function getCategoryById(id: string): Promise<Category | null> {
const res = await fetch(
`${API_BASE}/tenants/${TENANT_DOMAIN}/categories/by-id/${id}`,
{ next: { revalidate: 300 } }
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Meshkee API category id "${id}" failed: ${res.status}`);
const data = await res.json();
return data.category as Category;
}
export async function getTopLevelCategories(): Promise<Category[]> {
const items = await getCategories();
return items
.filter((c) => c.parentId === null)
.sort(
(a, b) =>
a.sortOrder - b.sortOrder ||
a.nameFa.localeCompare(b.nameFa, "fa")
);
}
/** Includes the category itself and all nested descendants. */
export function getCategoryDescendantIds(
categoryId: string,
categories: Category[]
): string[] {
const ids = new Set<string>([categoryId]);
let changed = true;
while (changed) {
changed = false;
for (const c of categories) {
if (c.parentId && ids.has(c.parentId) && !ids.has(c.id)) {
ids.add(c.id);
changed = true;
}
}
}
return Array.from(ids);
}
export type CategorySummary = {
categoryId: string;
name: string;
nameEn: string | null;
count: number;
image: string | null;
};
@@ -172,7 +292,7 @@ export async function getCategorySummary(limit = 8): Promise<CategorySummary[]>
const perId = new Map<
string,
{ id: string; name: string; count: number; image: string | null }
{ id: string; name: string; nameEn: string | null; count: number; image: string | null }
>();
for (const p of items) {
if (!p.categoryId || !p.categoryNameFa) continue;
@@ -181,8 +301,15 @@ export async function getCategorySummary(limit = 8): Promise<CategorySummary[]>
if (entry) {
entry.count += 1;
if (!entry.image && img) entry.image = img;
if (!entry.nameEn && p.categoryName) entry.nameEn = p.categoryName;
} else {
perId.set(p.categoryId, { id: p.categoryId, name: p.categoryNameFa, count: 1, image: img });
perId.set(p.categoryId, {
id: p.categoryId,
name: p.categoryNameFa,
nameEn: p.categoryName ?? null,
count: 1,
image: img,
});
}
}
@@ -193,6 +320,7 @@ export async function getCategorySummary(limit = 8): Promise<CategorySummary[]>
perName.set(entry.name, {
categoryId: entry.id,
name: entry.name,
nameEn: entry.nameEn,
count: entry.count,
image: entry.image,
});
@@ -200,8 +328,12 @@ export async function getCategorySummary(limit = 8): Promise<CategorySummary[]>
}
existing.count += entry.count;
const currentBest = perId.get(existing.categoryId)?.count ?? 0;
if (entry.count > currentBest) existing.categoryId = entry.id;
if (entry.count > currentBest) {
existing.categoryId = entry.id;
if (entry.nameEn) existing.nameEn = entry.nameEn;
}
if (!existing.image && entry.image) existing.image = entry.image;
if (!existing.nameEn && entry.nameEn) existing.nameEn = entry.nameEn;
}
return Array.from(perName.values())
@@ -290,6 +422,16 @@ export async function getPost(slug: string): Promise<Post | null> {
return (data.blog ?? data.post ?? data) as Post;
}
export async function getPostById(id: string): Promise<Post | null> {
const res = await fetch(`${API_BASE}/tenants/${TENANT_DOMAIN}/blogs/by-id/${id}`, {
next: { revalidate: 300 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Meshkee API blog id "${id}" failed: ${res.status}`);
const data = await res.json();
return (data.blog ?? data.post ?? data) as Post;
}
export function postTitle(post: Post): string {
return post.nameFa || post.titleFa || post.title || "بدون عنوان";
}