import { computed, inject, Injectable, signal } from '@angular/core'; import { Router } from '@angular/router'; import { StationConfigService } from './station-config.service'; import { resolveImageUrl } from './app-config.service'; import { JsonLdEntry, ListItem, OrganizationData, WebSiteData, WebPageData, BreadcrumbList, EventData, SiteStructuredData, RouteStructuredData, } from '../interfaces/structured-data'; import { StationConfig } from '../interfaces/station-config'; import { Event } from '../interfaces/event'; import { Show } from '../interfaces/show'; /** Map route path segments to human-readable page titles. */ const PAGE_TITLES: Record = { '': 'Home', about: 'About', schedule: 'Schedule', events: 'Events', contact: 'Contact', }; /** Page-specific descriptions for structured data. */ const PAGE_DESCRIPTIONS: Record string> = { '': (cfg) => `${cfg.name_primary} ${cfg.name_secondary} — ${cfg.tagline}. ${cfg.hero_subtitle}`, about: (cfg) => cfg.about_intro, schedule: (cfg) => cfg.schedule_intro, events: (cfg) => cfg.events_intro, contact: (cfg) => `Get in touch with ${cfg.name_primary} ${cfg.name_secondary}. ${cfg.address.replace(/\n/g, ', ')}. Phone: ${cfg.phone}, Email: ${cfg.email}.`, }; /** * Resolves the canonical website URL for structured data. * Uses the StationConfig website field (white-label aware). */ function canonicalBaseUrl(cfg: StationConfig): string { const website = cfg.website?.trim(); if (!website) return 'https://kmountainflower.org'; if (website.startsWith('http://') || website.startsWith('https://')) return website; return `https://${website}`; } /** * Structured data service: builds Schema.org JSON-LD for the current page. * * Site-wide data (Organization, WebSite) is shared across all pages. * Per-route data (WebPage, Event, Schedule) is computed from the active route * and available data (events, shows). * * Usage: inject StructuredDataService, read structuredData() signal. * The StructuredDataComponent handles DOM injection. */ @Injectable({ providedIn: 'root', }) export class StructuredDataService { private router = inject(Router); private stationConfig = inject(StationConfigService); /** Current route path (e.g., '', 'about', 'events'). */ readonly currentRoute = computed(() => { const url = this.router.url.split('?')[0].replace(/^\/$/, '').replace(/\/$/, ''); return url || ''; }); // ── External data hooks (set by route components) ────────────── readonly activeEvents = signal([]); readonly activeShows = signal([]); // ── Site-wide structured data (shared across all pages) ──────── readonly siteData = computed(() => { const cfg = this.stationConfig.config(); const baseUrl = canonicalBaseUrl(cfg); const fullName = `${cfg.name_primary} ${cfg.name_secondary}`.trim(); const logoUrl = cfg.logo_url ? resolveImageUrl(cfg.logo_url) : ''; const organization: OrganizationData = { '@context': 'https://schema.org', '@type': 'NCBOrganization', name: fullName, legalName: fullName, url: baseUrl, logo: logoUrl || undefined, description: `${fullName} — ${cfg.tagline}`, foundingDate: cfg.founded_year ? `${cfg.founded_year}-01-01` : undefined, contactPoint: [ { '@type': 'ContactPoint', telephone: cfg.phone || undefined, email: cfg.email || undefined, contactType: 'customer service', areaServed: 'US', availableLanguage: 'English', }, ], address: cfg.address ? this.parsePostalAddress(cfg.address) : undefined, }; const website: WebSiteData = { '@context': 'https://schema.org', '@type': 'WebSite', name: fullName, url: baseUrl, description: `${fullName} — ${cfg.tagline}`, publisher: { '@type': 'Organization', name: fullName, ...(logoUrl ? { logo: { '@type': 'ImageObject', url: logoUrl } } : {}), }, }; return { organization, website }; }); // ── Per-route structured data ────────────────────────────────── readonly routeData = computed(() => { const cfg = this.stationConfig.config(); const route = this.currentRoute(); const baseUrl = canonicalBaseUrl(cfg); const fullName = `${cfg.name_primary} ${cfg.name_secondary}`.trim(); const pageTitle = PAGE_TITLES[route] || 'Page'; const pageDescFn = PAGE_DESCRIPTIONS[route]; const description = pageDescFn ? pageDescFn(cfg) : undefined; const webpage: WebPageData = { '@context': 'https://schema.org', '@type': 'WebPage', name: `${pageTitle} — ${fullName}`, url: route ? `${baseUrl}/${route}` : baseUrl, description, isPartOf: { '@type': 'WebSite', url: baseUrl, name: fullName, }, about: { '@type': 'Organization', name: fullName, url: baseUrl, }, breadcrumb: this.buildBreadcrumbs(route, baseUrl, fullName), }; const additional: JsonLdEntry[] = []; // Events page: add Event schema for upcoming events if (route === 'events') { for (const evt of this.activeEvents()) { additional.push(this.buildEventSchema(evt, baseUrl, fullName)); } } // Schedule page: add RadioSeason / Show schemas if (route === 'schedule') { const scheduleData = this.buildScheduleSchema(cfg, baseUrl, fullName); if (scheduleData) additional.push(scheduleData); } return { webpage, additional: additional.length ? additional : undefined }; }); // ── Combined output: all JSON-LD entries for the current page ── readonly structuredData = computed(() => { const site = this.siteData(); const route = this.routeData(); const entries: JsonLdEntry[] = [ site.organization, site.website, route.webpage, ...(route.additional ?? []), ]; return entries; }); // ── Private helpers ──────────────────────────────────────────── private parsePostalAddress(address: string): { '@type': 'PostalAddress'; streetAddress?: string; addressLocality?: string; addressRegion?: string; postalCode?: string; addressCountry?: string; } { const lines = address.split('\n').map((l) => l.trim()).filter(Boolean); const result: { '@type': 'PostalAddress'; streetAddress?: string; addressLocality?: string; addressRegion?: string; postalCode?: string; addressCountry?: string; } = { '@type': 'PostalAddress' }; if (lines.length >= 1) result.streetAddress = lines[0]; if (lines.length >= 2) { const parts = lines[1].split(',').map((p) => p.trim()); if (parts.length >= 1) result.addressLocality = parts[0]; if (parts.length >= 2) result.addressRegion = parts[1]; if (parts.length >= 3) result.postalCode = parts[2]; } result.addressCountry = 'US'; return result; } private buildBreadcrumbs( route: string, baseUrl: string, siteName: string, ): BreadcrumbList { const items: ListItem[] = [ { '@type': 'ListItem', position: 1, name: siteName, item: baseUrl, }, ]; if (route) { const title = PAGE_TITLES[route] || route; items.push({ '@type': 'ListItem', position: 2, name: title, item: `${baseUrl}/${route}`, }); } return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items, }; } private buildEventSchema( evt: Event, baseUrl: string, stationName: string, ): EventData { return { '@context': 'https://schema.org', '@type': 'Event', name: evt.title, url: `${baseUrl}/events`, description: evt.description || undefined, startDate: evt.date || undefined, location: evt.location ? { '@type': 'Place', name: evt.location, } : undefined, organizer: { '@context': 'https://schema.org', '@type': 'Organization', name: stationName, url: baseUrl, }, offers: { '@context': 'https://schema.org', '@type': 'Offer', price: '0', priceCurrency: 'USD', availability: 'https://schema.org/InStock', }, }; } private buildScheduleSchema( cfg: StationConfig, baseUrl: string, fullName: string, ): JsonLdEntry | null { const shows = this.activeShows(); if (!shows.length) return null; return { '@context': 'https://schema.org', '@type': 'RadioSeason', name: `${fullName} Program Schedule`, url: `${baseUrl}/schedule`, description: cfg.schedule_intro || undefined, performer: { '@type': 'RadioStation', name: fullName, url: baseUrl, }, episode: shows.map((show) => ({ '@type': 'RadioEpisode', name: show.title, description: show.description || undefined, ...(show.host ? { director: { '@type': 'Person', name: show.host } } : {}), })), }; } }