import type { StoreInfo } from 'brainerce'; import { getNonce } from '@/core/lib/nonce'; import { stripHtmlForSeo } from '@/core/lib/seo'; interface OrganizationJsonLdProps { storeInfo: StoreInfo; baseUrl: string; } // Emits Organization + WebSite JSON-LD on the storefront homepage. // // Why these two specifically: // - Organization → feeds the Google Knowledge Panel (store name, logo, // description) and is the primary signal AI Overviews / ChatGPT / // Perplexity ingest when citing the store. // - WebSite → enables Sitelinks Searchbox (Google) and tells crawlers // the canonical site URL. The `potentialAction` block is what makes // the search box appear in branded SERP results. export async function OrganizationJsonLd({ storeInfo, baseUrl }: OrganizationJsonLdProps) { const nonce = await getNonce(); const cleanDescription = stripHtmlForSeo(storeInfo.metaDescription ?? undefined); const logo = typeof storeInfo.logo === 'string' && storeInfo.logo.trim() ? storeInfo.logo : undefined; const contactEmail = typeof storeInfo.contactEmail === 'string' && storeInfo.contactEmail.trim() ? storeInfo.contactEmail.trim() : undefined; const contactPhone = typeof storeInfo.contactPhone === 'string' && storeInfo.contactPhone.trim() ? storeInfo.contactPhone.trim() : undefined; // sameAs is the JSON-LD property Google uses to associate a brand with its // social profiles. Order doesn't matter; we just filter out empties. const sameAs = storeInfo.socialLinks ? Object.values(storeInfo.socialLinks).filter( (url): url is string => typeof url === 'string' && url.trim().length > 0 ) : []; const organizationJsonLd: Record = { '@context': 'https://schema.org', '@type': 'Organization', name: storeInfo.name, url: baseUrl, ...(logo ? { logo } : {}), ...(cleanDescription ? { description: cleanDescription } : {}), ...(contactEmail ? { email: contactEmail } : {}), ...(contactPhone ? { telephone: contactPhone } : {}), ...(sameAs.length > 0 ? { sameAs } : {}), }; const websiteJsonLd: Record = { '@context': 'https://schema.org', '@type': 'WebSite', name: storeInfo.name, url: baseUrl, potentialAction: { '@type': 'SearchAction', target: { // Must match the storefront's real search param — the product listing // reads `?search=` (see use-product-listing.ts), not `?q=`. A wrong // param makes the sitelinks search box land on an empty listing. '@type': 'EntryPoint', urlTemplate: `${baseUrl}/products?search={search_term_string}`, }, 'query-input': 'required name=search_term_string', }, }; return ( <> ` or inject HTML. Same approach as // product-json-ld.tsx — keep the two in sync. function serializeJsonLd(value: unknown): string { return JSON.stringify(value) .replace(//g, '\\u003e') .replace(/&/g, '\\u0026'); }