Switch admin auth to mobile, restyle shell/login with Vuesax icons, and add product detail fields plus public product pages. Co-authored-by: Cursor <cursoragent@cursor.com>
143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
import type { PublicCategory, PublicProductDetail, SiteSettings } from "@imen/shared";
|
|
import { siteConfig } from "../config/site";
|
|
|
|
type Json = Record<string, unknown>;
|
|
|
|
/** Values every schema builder needs: editable settings + the absolute site URL */
|
|
export interface SeoContext {
|
|
settings: SiteSettings;
|
|
siteUrl: string;
|
|
}
|
|
|
|
const abs = (ctx: SeoContext, p: string) => new URL(p, ctx.siteUrl).toString();
|
|
|
|
/** Social profile links worth listing in `sameAs` (not mail/phone/own site) */
|
|
const PROFILE_TYPES = new Set(["telegram", "instagram", "linkedin", "whatsapp", "bale", "eitaa", "aparat", "youtube"]);
|
|
|
|
const clean = <T extends object>(o: T): Json =>
|
|
JSON.parse(
|
|
JSON.stringify(o, (_, v) =>
|
|
v === "" || (Array.isArray(v) && !v.length) ? undefined : v,
|
|
),
|
|
);
|
|
|
|
export const organizationSchema = (ctx: SeoContext) => {
|
|
const s = ctx.settings;
|
|
return clean({
|
|
"@context": "https://schema.org",
|
|
"@type": "LocalBusiness",
|
|
"@id": `${ctx.siteUrl}/#organization`,
|
|
name: s.name,
|
|
alternateName: s.nameEn,
|
|
url: abs(ctx, "/"),
|
|
logo: abs(ctx, siteConfig.logo),
|
|
image: abs(ctx, s.ogImage.url),
|
|
description: s.description,
|
|
email: s.email,
|
|
telephone: s.phone,
|
|
foundingDate: s.foundingDate,
|
|
areaServed: siteConfig.areaServed,
|
|
address: {
|
|
"@type": "PostalAddress",
|
|
streetAddress: s.address.street,
|
|
addressLocality: s.address.locality,
|
|
addressCountry: s.address.country,
|
|
},
|
|
openingHoursSpecification: {
|
|
"@type": "OpeningHoursSpecification",
|
|
dayOfWeek: s.openingHours.days,
|
|
opens: s.openingHours.opens,
|
|
closes: s.openingHours.closes,
|
|
},
|
|
sameAs: s.socials.filter((x) => PROFILE_TYPES.has(x.type)).map((x) => x.url),
|
|
});
|
|
};
|
|
|
|
export const websiteSchema = (ctx: SeoContext) => ({
|
|
"@context": "https://schema.org",
|
|
"@type": "WebSite",
|
|
"@id": `${ctx.siteUrl}/#website`,
|
|
url: abs(ctx, "/"),
|
|
name: ctx.settings.name,
|
|
inLanguage: siteConfig.lang,
|
|
publisher: { "@id": `${ctx.siteUrl}/#organization` },
|
|
});
|
|
|
|
export const serviceSchema = (
|
|
ctx: SeoContext,
|
|
opts: { name: string; description: string; areaServed?: string },
|
|
) =>
|
|
clean({
|
|
"@context": "https://schema.org",
|
|
"@type": "Service",
|
|
name: opts.name,
|
|
description: opts.description,
|
|
areaServed: opts.areaServed ?? siteConfig.areaServed,
|
|
provider: { "@id": `${ctx.siteUrl}/#organization` },
|
|
});
|
|
|
|
export const categoryPageSchema = (ctx: SeoContext, category: PublicCategory) =>
|
|
clean({
|
|
"@context": "https://schema.org",
|
|
"@type": "CollectionPage",
|
|
"@id": `${ctx.siteUrl}/categories/${category.slug}`,
|
|
name: category.name,
|
|
description: category.seoDescription || category.fullDesc,
|
|
url: abs(ctx, `/categories/${category.slug}`),
|
|
isPartOf: { "@id": `${ctx.siteUrl}/#website` },
|
|
provider: { "@id": `${ctx.siteUrl}/#organization` },
|
|
mainEntity: {
|
|
"@type": "ItemList",
|
|
numberOfItems: category.products.length,
|
|
itemListElement: category.products.map((p, i) => ({
|
|
"@type": "ListItem",
|
|
position: i + 1,
|
|
item: {
|
|
"@type": "Product",
|
|
name: p.name,
|
|
description: p.desc,
|
|
sku: p.code,
|
|
url: abs(ctx, `/products/${p.slug}`),
|
|
...(p.image && { image: abs(ctx, p.image) }),
|
|
},
|
|
})),
|
|
},
|
|
});
|
|
|
|
export const productSchema = (ctx: SeoContext, p: PublicProductDetail) =>
|
|
clean({
|
|
"@context": "https://schema.org",
|
|
"@type": "Product",
|
|
"@id": `${ctx.siteUrl}/products/${p.slug}#product`,
|
|
name: p.name,
|
|
alternateName: p.nameEn,
|
|
sku: p.code,
|
|
mpn: p.code,
|
|
description: p.seoDescription || p.desc || p.details.slice(0, 300),
|
|
url: abs(ctx, `/products/${p.slug}`),
|
|
image: p.images.map((i) => abs(ctx, i.url)),
|
|
category: p.category.name,
|
|
...(p.brand && { brand: { "@type": "Brand", name: p.brand } }),
|
|
countryOfOrigin: p.origin,
|
|
additionalProperty: [
|
|
...p.attributes.map((a) => ({ "@type": "PropertyValue", name: a.name, value: a.value })),
|
|
...p.standards.map((s) => ({ "@type": "PropertyValue", name: "استاندارد", value: s })),
|
|
],
|
|
seller: { "@id": `${ctx.siteUrl}/#organization` },
|
|
});
|
|
|
|
export const breadcrumbSchema = (ctx: SeoContext, items: { name: string; path: string }[]) => ({
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
itemListElement: items.map((it, i) => ({
|
|
"@type": "ListItem",
|
|
position: i + 1,
|
|
name: it.name,
|
|
item: abs(ctx, it.path),
|
|
})),
|
|
});
|
|
|
|
/** Serialize JSON-LD safely for an inline <script> (escape "<"). */
|
|
export const toJsonLd = (data: Json) =>
|
|
JSON.stringify(data).replace(/</g, "\\u003c");
|