feat: implementar generación de metadatos para servicios con soporte para SEO y datos estructurados
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user