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 AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||||
import style from "./profile.module.css";
|
import style from "./profile.module.css";
|
||||||
import ApiServerService from "@services/Api.Server.Service";
|
import ApiServerService from "@services/Api.Server.Service";
|
||||||
@@ -14,7 +16,8 @@ import { RatingTargetType } from "@core/Models/Ratings.model";
|
|||||||
import ServiceDetailCard from "./ServiceDetailCard";
|
import ServiceDetailCard from "./ServiceDetailCard";
|
||||||
import OrganizationHeaderEdit from "@components/OrganizationHeaderEdit/OrganizationHeaderEdit";
|
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 {
|
try {
|
||||||
const organizationServiceData = await ApiServerService.post<PublicOrganizationServiceView>(
|
const organizationServiceData = await ApiServerService.post<PublicOrganizationServiceView>(
|
||||||
"views/service",
|
"views/service",
|
||||||
@@ -27,7 +30,7 @@ const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServ
|
|||||||
} catch {
|
} catch {
|
||||||
return {} as PublicOrganizationServiceView;
|
return {} as PublicOrganizationServiceView;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
sid: string;
|
sid: string;
|
||||||
@@ -37,12 +40,153 @@ type Props = {
|
|||||||
params: Promise<Params>;
|
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) {
|
export default async function OrganizationPublicProfile({ params }: Props) {
|
||||||
const { sid } = await params;
|
const { sid } = await params;
|
||||||
const organizationService = await GetServiceData(sid);
|
const organizationService = await GetServiceData(sid);
|
||||||
|
|
||||||
return (
|
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 && <ServicePrivate />}
|
||||||
{organizationService.organization && organizationService.service && (
|
{organizationService.organization && organizationService.service && (
|
||||||
<AnimatedContainer
|
<AnimatedContainer
|
||||||
|
|||||||
Reference in New Issue
Block a user