diff --git a/txclient/src/app/landing/service/[sid]/page.tsx b/txclient/src/app/landing/service/[sid]/page.tsx index 82fbbad..a01381e 100644 --- a/txclient/src/app/landing/service/[sid]/page.tsx +++ b/txclient/src/app/landing/service/[sid]/page.tsx @@ -1,3 +1,5 @@ +import type { Metadata } from "next"; +import { cache } from "react"; import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer"; import style from "./profile.module.css"; import ApiServerService from "@services/Api.Server.Service"; @@ -14,7 +16,8 @@ import { RatingTargetType } from "@core/Models/Ratings.model"; import ServiceDetailCard from "./ServiceDetailCard"; import OrganizationHeaderEdit from "@components/OrganizationHeaderEdit/OrganizationHeaderEdit"; -const GetServiceData = async (serviceId: string): Promise => { +// `cache()` makes generateMetadata and the page component share one API call per request. +const GetServiceData = cache(async (serviceId: string): Promise => { try { const organizationServiceData = await ApiServerService.post( "views/service", @@ -27,7 +30,7 @@ const GetServiceData = async (serviceId: string): Promise; }; +type Service = PublicOrganizationServiceView["service"]; +type Organization = PublicOrganizationServiceView["organization"]; + +const getBaseUrl = (): string => process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar"; + +const getServiceUrl = (sid: string): string => `${getBaseUrl()}/landing/service/${sid}`; + +// Meta tags must be plain text: collapse whitespace and drop any markup. +const toPlainText = (value?: string): string => + (value || "") + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); + +// Cut on a word boundary so snippets never end mid-word. +const truncate = (value: string, max: number): string => { + if (value.length <= max) return value; + const cut = value.slice(0, max - 1); + const lastSpace = cut.lastIndexOf(" "); + return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`; +}; + +// `image` can be an absolute external URL or a path served from this origin. +const resolveImage = (src: string, baseUrl: string): string | undefined => { + if (!src) return undefined; + if (/^https?:\/\//i.test(src)) return src; + return `${baseUrl}${src.startsWith("/") ? "" : "/"}${src}`; +}; + +const buildTitle = (service: Service, org: Organization): string => { + const MAX = 70; + const full = `${service.name} | ${org.name} | TurnosXpress`; + // Drop the organisation rather than truncating the brand out of the title. + return full.length <= MAX ? full : truncate(`${service.name} | TurnosXpress`, MAX); +}; + +const buildDescription = (service: Service, org: Organization): string => { + const MAX = 160; + const cta = `Reservá online con ${org.name} en TurnosXpress.`; + const body = toPlainText(service.description); + + if (!body) return truncate(`${service.name}: ${cta}`, MAX); + + const room = MAX - cta.length - 1; + // Keep the call to action intact; it is what is cut first when space runs out. + return room >= 60 ? `${truncate(body, room)} ${cta}` : truncate(`${body} ${cta}`, MAX); +}; + +export async function generateMetadata({ params }: Props): Promise { + const { sid } = await params; + const organizationService = await GetServiceData(sid); + const service = organizationService?.service; + const org = organizationService?.organization; + + if (!service?.id || !org?.id) { + return { + robots: { index: false, follow: false }, + }; + } + + const baseUrl = getBaseUrl(); + const url = getServiceUrl(sid); + const title = buildTitle(service, org); + const description = buildDescription(service, org); + const image = resolveImage(service.image, baseUrl) ?? resolveImage(org.logoFile, baseUrl); + const ogImage = image ? [{ url: image, alt: service.name }] : undefined; + + return { + title, + description, + alternates: { + canonical: url, + }, + openGraph: { + type: "website", + title, + description, + url, + siteName: "TurnosXpress", + locale: "es_AR", + images: ogImage, + }, + twitter: { + card: image ? "summary_large_image" : "summary", + title, + description, + images: image ? [image] : undefined, + }, + robots: { + index: true, + follow: true, + }, + }; +} + +const buildServiceJsonLd = (service: Service, org: Organization, url: string) => { + const baseUrl = getBaseUrl(); + const orgUrl = org.slug + ? `${baseUrl}/landing/org/${org.slug}` + : `${baseUrl}/landing/org/${org.id}`; + + return { + "@context": "https://schema.org", + "@type": "Service", + "@id": url, + name: service.name, + description: toPlainText(service.description) || `${service.name} ofrecido por ${org.name}.`, + url, + image: resolveImage(service.image, baseUrl) ?? resolveImage(org.logoFile, baseUrl), + provider: { + "@type": "Organization", + "@id": orgUrl, + name: org.name, + url: orgUrl, + }, + offers: { + "@type": "Offer", + price: service.price, + priceCurrency: "ARS", + availability: "https://schema.org/InStock", + url, + }, + }; +}; + export default async function OrganizationPublicProfile({ params }: Props) { const { sid } = await params; const organizationService = await GetServiceData(sid); return ( <> + {/* AnimatedContainer returns null during SSR, so structured data has to sit + outside it to reach the static HTML that crawlers read first. */} + {organizationService.service && organizationService.organization && ( +