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>
196 lines
5.7 KiB
TypeScript
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;
|
|
}
|