diff --git a/scripts/verify-prerender.mjs b/scripts/verify-prerender.mjs index cafab35..2c3325e 100644 --- a/scripts/verify-prerender.mjs +++ b/scripts/verify-prerender.mjs @@ -85,10 +85,10 @@ const expectedRoutes = [ lang: "es", title: "Servicios de desarrollo web y software a medida | Rodrigo Opalo", description: - "Desarrollo sitios web profesionales, sistemas a medida y automatizaciones para empresas. Trabajo directo conmigo, en español e inglés, desde South Florida y en remoto para Estados Unidos y Latinoamérica.", + "Desarrollo sitios web profesionales, sistemas a medida y automatizaciones para empresas. Trabajo directo conmigo, en español e inglés, desde el sur de Florida y en remoto para Estados Unidos y Latinoamérica.", content: "Sitios web, sistemas y automatización para empresas.", metadataHash: - "270c8a9a1de7606d8bf4e9d6c09cda7fbb8757b1c42174417cbcc56f425fa21d", + "c028ef27f8fadbcb764f357884105455a8c3fa625c78f14ba2f2b70aa4d9d71c", noJavaScriptMarkers: 1, }, { diff --git a/src/Components/services/EditorialNote.tsx b/src/Components/services/EditorialNote.tsx new file mode 100644 index 0000000..4093255 --- /dev/null +++ b/src/Components/services/EditorialNote.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; + +type EditorialNoteTone = "dark" | "light"; + +interface EditorialNoteProps { + children: ReactNode; + className?: string; + tone?: EditorialNoteTone; +} + +const TONE_CLASSNAME: Record = { + dark: "border-primary/60 text-gray-300/90", + light: "border-primary-on-light/70 text-gray-800", +}; + +export default function EditorialNote({ + children, + className = "", + tone = "dark", +}: EditorialNoteProps) { + return ( +

+ {children} +

+ ); +} diff --git a/src/Components/services/ServiceDirectoryItem.tsx b/src/Components/services/ServiceDirectoryItem.tsx new file mode 100644 index 0000000..dc353eb --- /dev/null +++ b/src/Components/services/ServiceDirectoryItem.tsx @@ -0,0 +1,54 @@ +import { ArrowRight as ArrowRightIcon } from "lucide-react"; +import { Link } from "react-router-dom"; + +interface ServiceDirectoryItemProps { + bordered?: boolean; + ctaLabel: string; + description: string; + title: string; + to: string; +} + +export default function ServiceDirectoryItem({ + bordered = false, + ctaLabel, + description, + title, + to, +}: ServiceDirectoryItemProps) { + return ( + +
+

+ {title} +

+

+ {description} +

+ + {ctaLabel} + +
+ + + ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b552f92..59005e2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -263,7 +263,7 @@ export const en = { text: "In development: website and e-commerce with subscriptions, an admin panel, and inventory management.", }, ], - note: "Each project had a different scope; the portfolio details the role and technology behind each one.", + note: "Each project had a different scope. In the portfolio, you can see the role and technologies used in each one.", portfolioLink: "See these projects in the portfolio", }, method: { diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 86c5ab3..66807a4 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -108,7 +108,7 @@ export const es = { seo: { title: "Servicios de desarrollo web y software a medida | Rodrigo Opalo", description: - "Desarrollo sitios web profesionales, sistemas a medida y automatizaciones para empresas. Trabajo directo conmigo, en español e inglés, desde South Florida y en remoto para Estados Unidos y Latinoamérica.", + "Desarrollo sitios web profesionales, sistemas a medida y automatizaciones para empresas. Trabajo directo conmigo, en español e inglés, desde el sur de Florida y en remoto para Estados Unidos y Latinoamérica.", }, header: { eyebrow: "Servicios", @@ -173,7 +173,7 @@ export const es = { }, coverage: { title: "Dónde trabajo", - text: "Estoy basado en South Florida y trabajo con empresas de Miami y del resto de Estados Unidos, y en remoto con Latinoamérica. La comunicación puede ser en español o en inglés durante todo el proyecto.", + text: "Trabajo desde el sur de Florida con empresas de Miami y del resto de Estados Unidos, y también de forma remota con clientes de Latinoamérica. La comunicación puede ser en español o en inglés durante todo el proyecto.", }, proof: { title: "Trabajo real, no promesas", @@ -260,7 +260,7 @@ export const es = { text: "Proyecto en desarrollo: sitio web y e-commerce con suscripciones, panel administrativo y gestión de stock.", }, ], - note: "Cada proyecto tuvo un alcance distinto; en el portfolio está el detalle de rol y tecnología de cada uno.", + note: "Cada proyecto tuvo un alcance distinto. En el portfolio podés ver el rol y las tecnologías utilizadas en cada uno.", portfolioLink: "Ver estos proyectos en el portfolio", }, method: { @@ -274,7 +274,7 @@ export const es = { }, coverage: { title: "Miami y trabajo remoto", - text: "Trabajo desde South Florida con empresas de Miami y del resto de Estados Unidos, y en remoto con Latinoamérica, en español e inglés.", + text: "Trabajo desde el sur de Florida con empresas de Miami y del resto de Estados Unidos, y también de forma remota con clientes de Latinoamérica. La comunicación puede ser en español o en inglés durante todo el proyecto.", }, cta: { title: "¿Tu empresa necesita un sitio que trabaje?", @@ -364,7 +364,7 @@ export const es = { }, coverage: { title: "Dónde y cómo trabajo", - text: "Desde South Florida, con empresas de Miami y de Estados Unidos, y en remoto con Latinoamérica. El trabajo es directo conmigo, en español o en inglés, durante todo el proyecto.", + text: "Trabajo desde el sur de Florida con empresas de Miami y del resto de Estados Unidos, y también de forma remota con clientes de Latinoamérica. La comunicación puede ser en español o en inglés durante todo el proyecto.", }, cta: { title: "¿Tu operación necesita su propio sistema?", diff --git a/src/pages/BusinessWebsitesPage.tsx b/src/pages/BusinessWebsitesPage.tsx index 661df2a..e32882b 100644 --- a/src/pages/BusinessWebsitesPage.tsx +++ b/src/pages/BusinessWebsitesPage.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router-dom"; import { useLanguage } from "../i18n/useLanguage"; import translations from "../i18n"; import { getLocalizedPath } from "../routes/siteRoutes"; +import EditorialNote from "../Components/services/EditorialNote"; export default function BusinessWebsitesPage() { const { language } = useLanguage(); @@ -24,7 +25,10 @@ export default function BusinessWebsitesPage() {

{page.header.eyebrow}

-

+

{page.header.title}

@@ -89,9 +93,12 @@ export default function BusinessWebsitesPage() { ))} -

+ {page.cases.note} -

+
-
+

-
+
{page.coverage.title}

-

+

{page.coverage.text}

diff --git a/src/pages/LemBoxCasePage.tsx b/src/pages/LemBoxCasePage.tsx index a32a56f..845e7ca 100644 --- a/src/pages/LemBoxCasePage.tsx +++ b/src/pages/LemBoxCasePage.tsx @@ -1,4 +1,5 @@ import { Link } from "react-router-dom"; +import { ArrowUpRight as ArrowUpRightIcon } from "lucide-react"; import { lemBoxAudienceIntro, lemBoxCase, @@ -384,9 +385,15 @@ export default function LemBoxCasePage() { ); diff --git a/src/pages/ServicesHubPage.tsx b/src/pages/ServicesHubPage.tsx index 3f9566c..85af0d5 100644 --- a/src/pages/ServicesHubPage.tsx +++ b/src/pages/ServicesHubPage.tsx @@ -2,9 +2,8 @@ import { Link } from "react-router-dom"; import { useLanguage } from "../i18n/useLanguage"; import translations from "../i18n"; import { getLocalizedPath } from "../routes/siteRoutes"; - -const SERVICE_ROW_CLASSNAME = - "group grid min-h-[44px] grid-cols-[minmax(0,1fr)_auto] gap-6 py-7 text-white no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary sm:py-8"; +import EditorialNote from "../Components/services/EditorialNote"; +import ServiceDirectoryItem from "../Components/services/ServiceDirectoryItem"; export default function ServicesHubPage() { const { language } = useLanguage(); @@ -53,9 +52,9 @@ export default function ServicesHubPage() {
))}
-

+ {page.choose.note} -

+
@@ -71,64 +70,26 @@ export default function ServicesHubPage() { {page.directory.title}
- -
-

- {page.directory.web.title} -

-

- {page.directory.web.text} -

- - {page.directory.web.linkLabel} - -
- - - + -
-

- {page.directory.systems.title} -

-

- {page.directory.systems.text} -

- - {page.directory.systems.linkLabel} - -
- - -
-

- {page.directory.automation.title} -

-

- {page.directory.automation.text} -

- - {page.directory.automation.linkLabel} - -
+ title={page.directory.systems.title} + description={page.directory.systems.text} + ctaLabel={page.directory.systems.linkLabel} + /> +
@@ -161,7 +122,7 @@ export default function ServicesHubPage() {
-
+
{page.coverage.title} -

+

{page.coverage.text}

diff --git a/src/test/HeadHydration.test.tsx b/src/test/HeadHydration.test.tsx index 11517bd..2aa8098 100644 --- a/src/test/HeadHydration.test.tsx +++ b/src/test/HeadHydration.test.tsx @@ -13,8 +13,30 @@ type JsonLdNode = { "@type": string; "@id"?: string } & Record< type JsonLdGraph = { "@graph": JsonLdNode[] }; const routes = [ - { locale: "es" as const, pathname: "/" }, - { locale: "en" as const, pathname: "/en" }, + { hasJsonLd: true, locale: "es" as const, pathname: "/" }, + { hasJsonLd: true, locale: "en" as const, pathname: "/en" }, + { hasJsonLd: false, locale: "es" as const, pathname: "/servicios" }, + { hasJsonLd: false, locale: "en" as const, pathname: "/en/services" }, + { + hasJsonLd: false, + locale: "es" as const, + pathname: "/servicios/sitios-web-para-empresas", + }, + { + hasJsonLd: false, + locale: "en" as const, + pathname: "/en/services/business-websites", + }, + { + hasJsonLd: false, + locale: "es" as const, + pathname: "/servicios/sistemas-a-medida", + }, + { + hasJsonLd: false, + locale: "en" as const, + pathname: "/en/services/custom-software", + }, ]; function jsonLdScripts() { @@ -84,19 +106,21 @@ afterEach(() => { describe("prerendered head hydration", () => { it.each(routes)( "hydrates $pathname without duplicating the head or the Person node", - async ({ locale, pathname }) => { + async ({ hasJsonLd, locale, pathname }) => { const { appHtml, head } = await renderPrerenderedHead(pathname); document.head.innerHTML = head; document.body.innerHTML = `
${appHtml}
`; const prerenderedScripts = jsonLdScripts(); - expect(prerenderedScripts).toHaveLength(1); - expect(prerenderedScripts[0]).toHaveAttribute("data-rh", "true"); - const prerenderedPerson = personNode(prerenderedScripts[0]); - expect(prerenderedPerson["@id"]).toBe(PERSON_ID); - expect(prerenderedPerson.description).toBe(PERSON_DESCRIPTIONS[locale]); - expect(prerenderedPerson.jobTitle).toBe(PERSON_JOB_TITLES[locale]); + expect(prerenderedScripts).toHaveLength(hasJsonLd ? 1 : 0); + if (hasJsonLd) { + expect(prerenderedScripts[0]).toHaveAttribute("data-rh", "true"); + const prerenderedPerson = personNode(prerenderedScripts[0]); + expect(prerenderedPerson["@id"]).toBe(PERSON_ID); + expect(prerenderedPerson.description).toBe(PERSON_DESCRIPTIONS[locale]); + expect(prerenderedPerson.jobTitle).toBe(PERSON_JOB_TITLES[locale]); + } const container = document.getElementById("root"); if (container === null) { @@ -138,7 +162,7 @@ describe("prerendered head hydration", () => { }); await waitFor(() => { - expect(jsonLdScripts()).toHaveLength(1); + expect(jsonLdScripts()).toHaveLength(hasJsonLd ? 1 : 0); }); expect(consoleError.mock.calls.flat().join(" ")).not.toMatch( @@ -157,11 +181,13 @@ describe("prerendered head hydration", () => { ).toHaveLength(3); const hydratedScripts = jsonLdScripts(); - expect(hydratedScripts).toHaveLength(1); - const hydratedPerson = personNode(hydratedScripts[0]); - expect(hydratedPerson["@id"]).toBe(PERSON_ID); - expect(hydratedPerson.description).toBe(PERSON_DESCRIPTIONS[locale]); - expect(hydratedPerson.jobTitle).toBe(PERSON_JOB_TITLES[locale]); + expect(hydratedScripts).toHaveLength(hasJsonLd ? 1 : 0); + if (hasJsonLd) { + const hydratedPerson = personNode(hydratedScripts[0]); + expect(hydratedPerson["@id"]).toBe(PERSON_ID); + expect(hydratedPerson.description).toBe(PERSON_DESCRIPTIONS[locale]); + expect(hydratedPerson.jobTitle).toBe(PERSON_JOB_TITLES[locale]); + } } finally { consoleError.mockRestore(); await act(async () => { diff --git a/src/test/Routing.test.tsx b/src/test/Routing.test.tsx index fb2eeec..7bc33b4 100644 --- a/src/test/Routing.test.tsx +++ b/src/test/Routing.test.tsx @@ -1455,6 +1455,17 @@ describe("application routing", () => { section.querySelectorAll("[data-public-link-row]"), ); expect(rows).toHaveLength(3); + const externalIcons = Array.from( + section.querySelectorAll('svg[data-navigation-icon="arrow-up-right"]'), + ); + expect(externalIcons).toHaveLength(3); + for (const icon of externalIcons) { + expect(icon).toHaveAttribute("aria-hidden", "true"); + expect(icon).toHaveAttribute("focusable", "false"); + expect(icon).toHaveAttribute("fill", "none"); + expect(icon).toHaveAttribute("stroke", "currentColor"); + expect(icon).toHaveAttribute("viewBox", "0 0 24 24"); + } expect(rows[0]).toHaveClass("lg:pl-0"); expect(rows[0]).not.toHaveClass("border-t", "lg:border-l"); expect(rows[1]).toHaveClass( diff --git a/src/test/ServicePagePolish.test.tsx b/src/test/ServicePagePolish.test.tsx new file mode 100644 index 0000000..cf95285 --- /dev/null +++ b/src/test/ServicePagePolish.test.tsx @@ -0,0 +1,245 @@ +import { render, screen, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import App from "../App"; +import { RoutedLanguageProvider } from "../i18n/LanguageProvider"; +import translations from "../i18n"; + +interface FileSystemApi { + readFileSync(path: string, encoding: "utf8"): string; +} + +interface PathApi { + dirname(path: string): string; + join(...paths: string[]): string; +} + +interface UrlApi { + fileURLToPath(url: string): string; +} + +const fs = await vi.importActual("node:fs"); +const path = await vi.importActual("node:path"); +const url = await vi.importActual("node:url"); +const projectRoot = path.join( + path.dirname(url.fileURLToPath(import.meta.url)), + "../..", +); + +const approvedCoverageEs = + "Trabajo desde el sur de Florida con empresas de Miami y del resto de Estados Unidos, y también de forma remota con clientes de Latinoamérica. La comunicación puede ser en español o en inglés durante todo el proyecto."; +const approvedProjectNoteEs = + "Cada proyecto tuvo un alcance distinto. En el portfolio podés ver el rol y las tecnologías utilizadas en cada uno."; +const approvedProjectNoteEn = + "Each project had a different scope. In the portfolio, you can see the role and technologies used in each one."; + +function renderApp(pathname: string) { + return render( + + + + + , + ); +} + +describe("VIS-SVC-01 service page polish contracts", () => { + it.each([ + { + heading: "Sitios web, sistemas y automatización para empresas.", + hrefs: [ + "/servicios/sitios-web-para-empresas", + "/servicios/sistemas-a-medida", + "/#contacto", + ], + path: "/servicios", + }, + { + heading: "Websites, custom systems, and automation for businesses.", + hrefs: [ + "/en/services/business-websites", + "/en/services/custom-software", + "/en#contacto", + ], + path: "/en/services", + }, + ])("renders three identical full-row links on $path", async ({ heading, hrefs, path }) => { + renderApp(path); + await screen.findByRole("heading", { level: 1, name: heading }); + + const directory = document.querySelector( + '[aria-labelledby="services-directory"]', + ); + expect(directory).not.toBeNull(); + if (directory === null) throw new Error("Missing service directory"); + + const rows = Array.from( + directory.querySelectorAll( + "a[data-service-directory-item]", + ), + ); + expect(rows).toHaveLength(3); + expect(rows.map((row) => row.getAttribute("href"))).toEqual(hrefs); + expect( + new Set(rows.map((row) => row.dataset.interactionContract)), + ).toEqual(new Set(["service-directory-row"])); + + for (const row of rows) { + const accessibleName = [ + row.querySelector("h3")?.textContent, + row.querySelector("p")?.textContent, + row.querySelector("span.mt-5")?.textContent, + ] + .map((value) => value?.trim()) + .filter(Boolean) + .join(" "); + expect(row.querySelector("a")).toBeNull(); + expect(row).toHaveAccessibleName(accessibleName); + expect(row).toHaveClass( + "min-h-[44px]", + "active:bg-white/[0.03]", + "focus-visible:ring-2", + "focus-visible:ring-inset", + ); + expect(row.querySelectorAll("[data-service-directory-arrow]")).toHaveLength( + 1, + ); + const icon = row.querySelector( + 'svg[data-navigation-icon="arrow-right"]', + ); + expect(icon).not.toBeNull(); + expect(icon).toHaveAttribute("aria-hidden", "true"); + expect(icon).toHaveAttribute("focusable", "false"); + expect(icon).toHaveAttribute("fill", "none"); + expect(icon).toHaveAttribute("stroke", "currentColor"); + expect(icon).toHaveAttribute("viewBox", "0 0 24 24"); + expect(row.querySelector("h3")).toHaveClass( + "group-hover:text-primary", + "group-active:text-primary", + "group-focus-visible:text-primary", + ); + } + + const automationRow = rows[2]; + expect(automationRow).toBeDefined(); + if (automationRow === undefined) throw new Error("Missing automation row"); + const automationCta = within(automationRow).getByText( + path.startsWith("/en") + ? "Tell me which process you want to automate" + : "Contame qué proceso querés automatizar", + ); + expect(automationCta).toHaveClass("text-primary"); + expect(automationCta.className).not.toContain("hover:text-white"); + expect( + automationRow.querySelector('svg[data-navigation-icon="arrow-right"]'), + ).not.toBeNull(); + }); + + it("uses SVG-only navigation controls without Unicode arrows", () => { + const itemSource = fs.readFileSync( + path.join( + projectRoot, + "src/Components/services/ServiceDirectoryItem.tsx", + ), + "utf8", + ); + const lemBoxSource = fs.readFileSync( + path.join(projectRoot, "src/pages/LemBoxCasePage.tsx"), + "utf8", + ); + + expect(itemSource).toContain("ArrowRight as ArrowRightIcon"); + expect(lemBoxSource).toContain("ArrowUpRight as ArrowUpRightIcon"); + for (const unicodeArrow of ["→", "↗", "➡", "\uFE0F"]) { + expect(itemSource).not.toContain(unicodeArrow); + expect(lemBoxSource).not.toContain(unicodeArrow); + } + }); + + it("reuses the editorial note pattern and scopes the wider H1 to Business Websites", async () => { + const businessPage = renderApp("/servicios/sitios-web-para-empresas"); + const businessHeading = await screen.findByRole("heading", { + level: 1, + name: "Sitios web profesionales para empresas.", + }); + expect(businessHeading).toHaveAttribute( + "data-hero-width", + "business-websites", + ); + expect(businessHeading).toHaveClass("max-w-[52rem]", "lg:max-w-[64rem]"); + + const projectNote = screen.getByText(approvedProjectNoteEs); + expect(projectNote).toHaveAttribute("data-editorial-note"); + expect(projectNote).toHaveClass( + "border-l", + "lg:max-w-none", + "text-gray-800", + ); + expect(projectNote.className).not.toMatch(/bg-|shadow/); + businessPage.unmount(); + + renderApp("/servicios"); + const hubHeading = await screen.findByRole("heading", { + level: 1, + name: "Sitios web, sistemas y automatización para empresas.", + }); + expect(hubHeading).toHaveClass("max-w-[52rem]"); + expect(hubHeading).not.toHaveClass("lg:max-w-[64rem]"); + expect( + screen.getByText( + "Algunos proyectos combinan las tres cosas. El alcance se define por el problema, no por la tecnología.", + ), + ).toHaveClass("border-l", "lg:max-w-[64rem]"); + }); + + it("localizes only Spanish geo copy and preserves the English construction", () => { + const spanishPages = JSON.stringify(translations.es.servicesPages); + const englishPages = JSON.stringify(translations.en.servicesPages); + + expect(spanishPages).not.toContain("South Florida"); + expect(spanishPages.match(/sur de Florida/g)?.length ?? 0).toBeGreaterThanOrEqual( + 4, + ); + expect(translations.es.servicesPages.hub.coverage.text).toBe( + approvedCoverageEs, + ); + expect(translations.es.servicesPages.web.coverage.text).toBe( + approvedCoverageEs, + ); + expect(translations.es.servicesPages.systems.coverage.text).toBe( + approvedCoverageEs, + ); + expect(englishPages).toContain("South Florida"); + expect(englishPages).not.toContain("sur de Florida"); + }); + + it("locks the exact localized project notes and focused source contracts", () => { + expect(translations.es.servicesPages.web.cases.note).toBe( + approvedProjectNoteEs, + ); + expect(translations.en.servicesPages.web.cases.note).toBe( + approvedProjectNoteEn, + ); + + const businessSource = fs.readFileSync( + path.join(projectRoot, "src/pages/BusinessWebsitesPage.tsx"), + "utf8", + ); + const systemsSource = fs.readFileSync( + path.join(projectRoot, "src/pages/CustomSoftwarePage.tsx"), + "utf8", + ); + const hubSource = fs.readFileSync( + path.join(projectRoot, "src/pages/ServicesHubPage.tsx"), + "utf8", + ); + + expect(businessSource).toContain('data-hero-width="business-websites"'); + expect(businessSource).toContain("lg:max-w-[64rem]"); + expect(systemsSource).not.toContain('data-hero-width="business-websites"'); + expect(hubSource).not.toContain('data-hero-width="business-websites"'); + expect(hubSource.match(/