feat: implementar generación de metadatos para servicios con soporte para SEO y datos estructurados

This commit is contained in:
2026-09-26 12:05:45 -03:00
parent 1a9de06393
commit 2df20233d4
+146 -2
View File
@@ -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<PublicOrganizationServiceView> => {
// `cache()` makes generateMetadata and the page component share one API call per request.
const GetServiceData = cache(async (serviceId: string): Promise<PublicOrganizationServiceView> => {
try {
const organizationServiceData = await ApiServerService.post<PublicOrganizationServiceView>(
"views/service",
@@ -27,7 +30,7 @@ const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServ
} catch {
return {} as PublicOrganizationServiceView;
}
};
});
type Params = {
sid: string;
@@ -37,12 +40,153 @@ type Props = {
params: Promise<Params>;
};
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<Metadata> {
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 && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildServiceJsonLd(
organizationService.service,
organizationService.organization,
getServiceUrl(sid),
),
),
}}
/>
)}
{!organizationService.organization && !organizationService.service && <ServicePrivate />}
{organizationService.organization && organizationService.service && (
<AnimatedContainer