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>
This commit is contained in:
co-authored by
Cursor
parent
db305c58a4
commit
aaf7a83742
@@ -0,0 +1,195 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user