Files
backend/src/business-settings/website-schema.util.ts
T
Alireza HassaniandCursor aaf7a83742 Add Schema.org JSON-LD, SEO meta overrides, and resilient bulk SMS.
Expose Organization/LocalBusiness and Product/BlogPosting jsonLd, optional seoMeta fields on content/categories, and continue mass SMS after per-recipient failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 23:30:24 +03:30

196 lines
5.7 KiB
TypeScript

import {
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
WEBSITE_SCHEMA_TYPE_IDS,
type WebsiteSchemaSettings,
type WebsiteSchemaType,
} from './business-settings.types';
import type { BusinessSocialMedia } from '../business-profile/business-profile.types';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readTrimmed(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeSameAs(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
if (typeof item !== 'string') continue;
const url = item.trim();
if (!url || seen.has(url)) continue;
if (!/^https?:\/\//i.test(url)) continue;
seen.add(url);
out.push(url);
if (out.length >= 20) break;
}
return out;
}
export function normalizeWebsiteSchemaSettings(
raw: unknown,
): WebsiteSchemaSettings {
const source = isRecord(raw) ? raw : {};
const typeRaw = typeof source.type === 'string' ? source.type : '';
const type = (WEBSITE_SCHEMA_TYPE_IDS as readonly string[]).includes(typeRaw)
? (typeRaw as WebsiteSchemaType)
: DEFAULT_WEBSITE_SCHEMA_SETTINGS.type;
return {
enabled:
typeof source.enabled === 'boolean'
? source.enabled
: DEFAULT_WEBSITE_SCHEMA_SETTINGS.enabled,
type,
name: readTrimmed(source.name),
nameFa: readTrimmed(source.nameFa),
description: readTrimmed(source.description),
priceRange: readTrimmed(source.priceRange),
sameAs: normalizeSameAs(source.sameAs),
};
}
/** Turn social handles / URLs into absolute profile URLs for schema.org sameAs. */
export function socialMediaToSameAsUrls(social: BusinessSocialMedia): string[] {
const urls: string[] = [];
const push = (value: string, build: (handle: string) => string) => {
const trimmed = value.trim();
if (!trimmed) return;
if (/^https?:\/\//i.test(trimmed)) {
urls.push(trimmed);
return;
}
const handle = trimmed.replace(/^@/, '').replace(/^\/+/, '');
if (!handle) return;
urls.push(build(handle));
};
push(social.instagram, (h) => `https://www.instagram.com/${h}`);
push(social.telegram, (h) =>
h.startsWith('t.me/') ? `https://${h}` : `https://t.me/${h}`,
);
push(social.linkedin, (h) =>
h.includes('linkedin.com') ? (h.startsWith('http') ? h : `https://${h}`) : `https://www.linkedin.com/in/${h}`,
);
push(social.youtube, (h) =>
h.includes('youtube.com') || h.includes('youtu.be')
? h.startsWith('http')
? h
: `https://${h}`
: `https://www.youtube.com/@${h}`,
);
push(social.aparat, (h) =>
h.includes('aparat.com')
? h.startsWith('http')
? h
: `https://${h}`
: `https://www.aparat.com/${h}`,
);
// WhatsApp is a phone link, not a public profile — skip for sameAs.
return normalizeSameAs(urls);
}
export type WebsiteSchemaJsonLd = Record<string, unknown>;
export type BuildWebsiteSchemaJsonLdInput = {
settings: WebsiteSchemaSettings;
host: string;
name: string;
nameFa: string;
about: string;
logoUrl: string | null;
emails: string[];
phoneNumbers: Array<{ type: string; number: string }>;
socialMedia: BusinessSocialMedia;
addresses: Array<{
province: string;
city: string;
address: string;
postalCode: string | null;
landline: string | null;
}>;
};
/**
* Build site-wide Organization / LocalBusiness JSON-LD for storefronts.
* Returns null when schema is disabled.
*/
export function buildWebsiteSchemaJsonLd(
input: BuildWebsiteSchemaJsonLdInput,
): WebsiteSchemaJsonLd | null {
const { settings } = input;
if (!settings.enabled) return null;
const displayName =
settings.nameFa ||
input.nameFa?.trim() ||
settings.name ||
input.name?.trim() ||
input.host;
const description =
settings.description || input.about?.trim() || undefined;
const sameAs = normalizeSameAs([
...socialMediaToSameAsUrls(input.socialMedia),
...settings.sameAs,
]);
const telephone =
input.phoneNumbers.find((p) => p.number.trim())?.number.trim() ||
input.addresses.find((a) => a.landline?.trim())?.landline?.trim() ||
undefined;
const email = input.emails.find((e) => e.trim())?.trim() || undefined;
const url = `https://${input.host.replace(/^https?:\/\//i, '').replace(/\/+$/, '')}`;
const jsonLd: WebsiteSchemaJsonLd = {
'@context': 'https://schema.org',
'@type': settings.type,
name: displayName,
url,
};
if (description) jsonLd.description = description;
if (input.logoUrl) {
jsonLd.logo = input.logoUrl;
jsonLd.image = input.logoUrl;
}
if (telephone) jsonLd.telephone = telephone;
if (email) jsonLd.email = email;
if (sameAs.length > 0) jsonLd.sameAs = sameAs;
const primaryAddress = input.addresses[0];
if (primaryAddress) {
const street = primaryAddress.address?.trim();
const city = primaryAddress.city?.trim();
const province = primaryAddress.province?.trim();
const postalCode = primaryAddress.postalCode?.trim() || undefined;
if (street || city || province || postalCode) {
jsonLd.address = {
'@type': 'PostalAddress',
...(street ? { streetAddress: street } : {}),
...(city ? { addressLocality: city } : {}),
...(province ? { addressRegion: province } : {}),
...(postalCode ? { postalCode } : {}),
addressCountry: 'IR',
};
}
}
if (settings.type === 'LocalBusiness' && settings.priceRange) {
jsonLd.priceRange = settings.priceRange;
}
return jsonLd;
}