diff --git a/README.en.md b/README.en.md index d993c93..ca68424 100644 --- a/README.en.md +++ b/README.en.md @@ -11,18 +11,20 @@ Production: [www.devrodri.com](https://www.devrodri.com) - Vitest 4 - Node.js 22 and npm 9.9.4 -The site uses Vite + React and is prerendered at build time. It publishes six -static public routes: +The site uses Vite + React and is prerendered at build time. It publishes +twelve static public routes: -- Spanish: `/`, `/portfolio`, `/portfolio/lem-box` -- English: `/en`, `/en/portfolio`, `/en/portfolio/lem-box` +- Spanish: `/`, `/portfolio`, `/portfolio/lem-box`, `/servicios`, + `/servicios/sitios-web-para-empresas`, `/servicios/sistemas-a-medida` +- English: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`, `/en/services`, + `/en/services/business-websites`, `/en/services/custom-software` Each route ships server-visible HTML and content, including its localized `title`, description, canonical URL, `hreflang`, Open Graph, and Twitter metadata. Structured data is scoped per route where applicable, and React hydrates the client after the initial document loads. Unknown paths return a real HTTP 404 localized in Spanish or English. The build also publishes -`robots.txt` and `sitemap.xml`, which lists the six public URLs. +`robots.txt` and `sitemap.xml`, which lists the twelve public URLs. ## Development diff --git a/README.es.md b/README.es.md index 9b933a0..483c26e 100644 --- a/README.es.md +++ b/README.es.md @@ -11,11 +11,13 @@ Producción: [www.devrodri.com](https://www.devrodri.com) - Vitest 4 - Node.js 22 y npm 9.9.4 -El sitio usa Vite + React y se prerenderiza durante el build. Publica seis rutas -estáticas: +El sitio usa Vite + React y se prerenderiza durante el build. Publica doce +rutas estáticas: -- Español: `/`, `/portfolio`, `/portfolio/lem-box` -- Inglés: `/en`, `/en/portfolio`, `/en/portfolio/lem-box` +- Español: `/`, `/portfolio`, `/portfolio/lem-box`, `/servicios`, + `/servicios/sitios-web-para-empresas`, `/servicios/sistemas-a-medida` +- Inglés: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`, `/en/services`, + `/en/services/business-websites`, `/en/services/custom-software` Cada ruta entrega HTML y contenido visibles desde el servidor, incluidos su `title`, descripción, URL canonical, `hreflang`, Open Graph y metadata de @@ -23,7 +25,7 @@ Twitter localizados. Los datos estructurados se definen por ruta cuando corresponde, y React hidrata el cliente después de cargar el documento inicial. Las rutas desconocidas devuelven un HTTP 404 real localizado en español o inglés. El build también publica `robots.txt` y `sitemap.xml`, que enumera las -seis URLs públicas. +doce URLs públicas. ## Desarrollo diff --git a/README.md b/README.md index d58c646..fb9e949 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,20 @@ Production: [www.devrodri.com](https://www.devrodri.com) - Vitest 4 - Node.js 22 and npm 9.9.4 -The site uses Vite + React and is prerendered at build time. It publishes six -static public routes: +The site uses Vite + React and is prerendered at build time. It publishes +twelve static public routes: -- Spanish: `/`, `/portfolio`, `/portfolio/lem-box` -- English: `/en`, `/en/portfolio`, `/en/portfolio/lem-box` +- Spanish: `/`, `/portfolio`, `/portfolio/lem-box`, `/servicios`, + `/servicios/sitios-web-para-empresas`, `/servicios/sistemas-a-medida` +- English: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`, `/en/services`, + `/en/services/business-websites`, `/en/services/custom-software` Each route ships server-visible HTML and content, including its localized `title`, description, canonical URL, `hreflang`, Open Graph, and Twitter metadata. Structured data is scoped per route where applicable, and React hydrates the client after the initial document loads. Unknown paths return a real HTTP 404 localized in Spanish or English. The build also publishes -`robots.txt` and `sitemap.xml`, which lists the six public URLs. +`robots.txt` and `sitemap.xml`, which lists the twelve public URLs. ## Local development diff --git a/scripts/verify-http.mjs b/scripts/verify-http.mjs index 845b8e2..31e54e3 100644 --- a/scripts/verify-http.mjs +++ b/scripts/verify-http.mjs @@ -93,9 +93,15 @@ try { "/", "/portfolio", "/portfolio/lem-box", + "/servicios", + "/servicios/sitios-web-para-empresas", + "/servicios/sistemas-a-medida", "/en", "/en/portfolio", "/en/portfolio/lem-box", + "/en/services", + "/en/services/business-websites", + "/en/services/custom-software", ]; for (const pathname of indexablePaths) { const response = await request(pathname); @@ -206,6 +212,24 @@ try { ctaPath: "/en", }, ], + [ + "/servicios/no-existe", + { + lang: "es", + heading: "Página no encontrada", + cta: "Volver al inicio", + ctaPath: "/", + }, + ], + [ + "/en/services/no-existe", + { + lang: "en", + heading: "Page not found", + cta: "Back to home", + ctaPath: "/en", + }, + ], ]); const invalidBodies = []; for (const [pathname, expected] of invalidPaths) { @@ -242,12 +266,30 @@ try { "/portfolio/lem-box/?source=vis01", "/portfolio/lem-box?source=vis01", ], + ["/servicios/?source=vis01", "/servicios?source=vis01"], + [ + "/servicios/sitios-web-para-empresas/?source=vis01", + "/servicios/sitios-web-para-empresas?source=vis01", + ], + [ + "/servicios/sistemas-a-medida/?source=vis01", + "/servicios/sistemas-a-medida?source=vis01", + ], ["/en/?source=vis01", "/en?source=vis01"], ["/en/portfolio/?source=vis01", "/en/portfolio?source=vis01"], [ "/en/portfolio/lem-box/?source=vis01", "/en/portfolio/lem-box?source=vis01", ], + ["/en/services/?source=vis01", "/en/services?source=vis01"], + [ + "/en/services/business-websites/?source=vis01", + "/en/services/business-websites?source=vis01", + ], + [ + "/en/services/custom-software/?source=vis01", + "/en/services/custom-software?source=vis01", + ], ["/gracias/?source=vis01", "/gracias?source=vis01"], [ "/en/thank-you/?source=vis01", @@ -313,9 +355,10 @@ try { const assetFiles = await readdir(path.join(projectRoot, "dist", "assets")); const lazyChunks = assetFiles.filter((file) => - /^(PortfolioPage|LemBoxCasePage)-.*\.js$/.test(file), + /^(PortfolioPage|LemBoxCasePage|ServicesHubPage|BusinessWebsitesPage|CustomSoftwarePage)-.*\.js$/ + .test(file), ); - assert.equal(lazyChunks.length, 2); + assert.equal(lazyChunks.length, 5); for (const lazyChunk of lazyChunks) { const response = await request(`/assets/${lazyChunk}`); assert.equal(response.status, 200, lazyChunk); diff --git a/scripts/verify-prerender.mjs b/scripts/verify-prerender.mjs index 7975127..cafab35 100644 --- a/scripts/verify-prerender.mjs +++ b/scripts/verify-prerender.mjs @@ -79,6 +79,79 @@ const expectedRoutes = [ "a82aa0233255e55dded2e20c18b752f88b22965f1692be24dfe75fa34319a27c", noJavaScriptMarkers: 1, }, + { + pathname: "/servicios", + file: "servicios/index.html", + 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.", + content: "Sitios web, sistemas y automatización para empresas.", + metadataHash: + "270c8a9a1de7606d8bf4e9d6c09cda7fbb8757b1c42174417cbcc56f425fa21d", + noJavaScriptMarkers: 1, + }, + { + pathname: "/en/services", + file: "en/services/index.html", + lang: "en", + title: "Web development and custom software services | Rodrigo Opalo", + description: + "I build business websites, custom software, and automations for companies. You work directly with me, in English or Spanish, from South Florida and remotely across the United States and Latin America.", + content: "Websites, custom systems, and automation for businesses.", + metadataHash: + "a739a0d0ae391f63797c2a285e3fe3d43b44afe75b9fb8bda18413b8dffb07df", + noJavaScriptMarkers: 1, + }, + { + pathname: "/servicios/sitios-web-para-empresas", + file: "servicios/sitios-web-para-empresas/index.html", + lang: "es", + title: "Sitios web profesionales para empresas | Rodrigo Opalo", + description: + "Diseño y desarrollo de sitios web profesionales para empresas: institucionales, landings y catálogos bilingües, rápidos, con base técnica SEO y formularios o WhatsApp para captar consultas.", + content: "Sitios web profesionales para empresas.", + metadataHash: + "c187c60f21e06be64ab215566532b98faab3d813c7517ac88ab1a1c0e8ce4627", + noJavaScriptMarkers: 1, + }, + { + pathname: "/en/services/business-websites", + file: "en/services/business-websites/index.html", + lang: "en", + title: "Business website design and development | Rodrigo Opalo", + description: + "Custom website development for businesses: institutional sites, landing pages, and bilingual catalogs that load fast, with a technical SEO foundation and forms or WhatsApp to capture inquiries.", + content: "Professional websites for businesses.", + metadataHash: + "40cbbc7774f39ffa5be5a080634a209a1bb47eb154c711f90592d554a3b26176", + noJavaScriptMarkers: 1, + }, + { + pathname: "/servicios/sistemas-a-medida", + file: "servicios/sistemas-a-medida/index.html", + lang: "es", + title: "Sistemas y aplicaciones a medida para empresas | Rodrigo Opalo", + description: + "Desarrollo de software a medida: aplicaciones web, portales de clientes y paneles internos con autenticación, roles, datos e integraciones. Implementación por etapas para empresas de Estados Unidos y Latinoamérica.", + content: "Sistemas y aplicaciones a medida para empresas.", + metadataHash: + "4f8aa5c638a7a52dca18a493c6a12d856e6cdf959751ad1f2427596ebf749f12", + noJavaScriptMarkers: 1, + }, + { + pathname: "/en/services/custom-software", + file: "en/services/custom-software/index.html", + lang: "en", + title: + "Custom software and web applications for businesses | Rodrigo Opalo", + description: + "Custom software development: web applications, client portals, and internal panels with authentication, roles, data, and integrations. Phased delivery for businesses in the United States and Latin America.", + content: "Custom software and web applications for businesses.", + metadataHash: + "094a6a33e91d01670b3b638fc24aff51161f035ed0cd849ba695caaa0feabbba", + noJavaScriptMarkers: 1, + }, ]; const expectedThankYouRoutes = [ { @@ -112,12 +185,17 @@ const localePairs = [ ["/", "/en"], ["/portfolio", "/en/portfolio"], ["/portfolio/lem-box", "/en/portfolio/lem-box"], + ["/servicios", "/en/services"], + ["/servicios/sitios-web-para-empresas", "/en/services/business-websites"], + ["/servicios/sistemas-a-medida", "/en/services/custom-software"], ]; const suspenseFallbacks = [ "Cargando portfolio…", "Loading portfolio…", "Cargando caso LEM-BOX…", "Loading LEM-BOX case study…", + ">Cargando…<", + ">Loading…<", ]; const expectedPersonContent = new Map([ [ @@ -234,12 +312,14 @@ function assertNoJavaScriptNavigation( const navbar = html.slice(navbarStart, mobileFallbackEnd); const mobileFallback = html.slice(mobileFallbackStart, mobileFallbackEnd); const localizedHomePath = lang === "es" ? "/" : "/en"; + const localizedServicesPath = lang === "es" ? "/servicios" : "/en/services"; const localizedPortfolioPath = lang === "es" ? "/portfolio" : "/en/portfolio"; const expectedNavigationHrefs = [ `${localizedHomePath}#sobremi`, `${localizedHomePath}#porqueelegirnos`, + localizedServicesPath, localizedPortfolioPath, `${localizedHomePath}#contacto`, `${localizedHomePath}#faq`, @@ -298,7 +378,7 @@ function assertNoJavaScriptNavigation( `${lang}: fallback hrefs`, ); assert.ok(fallbackAnchors.every((anchor) => /\shref="[^"]+"/.test(anchor))); - assert.equal(count(mobileFallback, "focus-visible:ring-2"), 7); + assert.equal(count(mobileFallback, "focus-visible:ring-2"), 8); assert.ok(!mobileFallback.includes(" import("./pages/PortfolioPage")); const LemBoxCasePage = lazy(() => import("./pages/LemBoxCasePage")); +const ServicesHubPage = lazy(() => import("./pages/ServicesHubPage")); +const BusinessWebsitesPage = lazy(() => import("./pages/BusinessWebsitesPage")); +const CustomSoftwarePage = lazy(() => import("./pages/CustomSoftwarePage")); const HomePage = () => ( <> @@ -75,6 +78,11 @@ const HomePage = () => ( function PublicPage({ page }: { page: PageKey }) { const { language } = useLanguage(); + const lazyPageFallback = ( +
+ {language === "es" ? "Cargando…" : "Loading…"} +
+ ); if (page === "home") return ; if (page === "thank-you") return ; @@ -91,6 +99,27 @@ function PublicPage({ page }: { page: PageKey }) { ); } + if (page === "services") { + return ( + + + + ); + } + if (page === "business-websites") { + return ( + + + + ); + } + if (page === "custom-software") { + return ( + + + + ); + } return ( { /structured data is scoped per route/i, /hydrates the client after/i, /real HTTP 404 localized in Spanish or English/i, - /sitemap\.xml.*, which lists the six public URLs/is, + /sitemap\.xml.*, which lists the twelve public URLs/is, ], }, { @@ -210,7 +210,7 @@ describe("repository hygiene", () => { /datos estructurados se definen por ruta/i, /hidrata el cliente después/i, /HTTP 404 real localizado en español o\s+inglés/i, - /sitemap\.xml.*, que enumera las\s+seis URLs públicas/is, + /sitemap\.xml.*, que enumera las\s+doce URLs públicas/is, ], }, { @@ -222,7 +222,7 @@ describe("repository hygiene", () => { /structured data is scoped per route/i, /hydrates the client after/i, /real HTTP 404 localized in Spanish or English/i, - /sitemap\.xml.*, which lists the six public URLs/is, + /sitemap\.xml.*, which lists the twelve public URLs/is, ], }, ]; @@ -230,9 +230,15 @@ describe("repository hygiene", () => { "`/`", "`/portfolio`", "`/portfolio/lem-box`", + "`/servicios`", + "`/servicios/sitios-web-para-empresas`", + "`/servicios/sistemas-a-medida`", "`/en`", "`/en/portfolio`", "`/en/portfolio/lem-box`", + "`/en/services`", + "`/en/services/business-websites`", + "`/en/services/custom-software`", ]; const serverMetadata = [ "`title`", diff --git a/src/__tests__/webDeliveryPolicy.test.ts b/src/__tests__/webDeliveryPolicy.test.ts index e72ae5e..8f432b3 100644 --- a/src/__tests__/webDeliveryPolicy.test.ts +++ b/src/__tests__/webDeliveryPolicy.test.ts @@ -530,7 +530,7 @@ describe("web delivery policy", () => { expect(configuration.trailingSlash).toBe(false); expect(configuration.rewrites).toEqual([]); const localizedNotFoundRouteSource = - "/en/(?!(?:portfolio(?:/lem-box)?|thank-you)/?$).+$"; + "/en/(?!(?:portfolio(?:/lem-box)?|services(?:/business-websites|/custom-software)?|thank-you)/?$).+$"; const canonicalRouteSecurityHeaders = Object.fromEntries( globalRule.headers.map(({ key, value }) => [key, value]), ); @@ -587,12 +587,19 @@ describe("web delivery policy", () => { "/en/portfolio/", "/en/portfolio/lem-box", "/en/portfolio/lem-box/", + "/en/services", + "/en/services/", + "/en/services/business-websites", + "/en/services/business-websites/", + "/en/services/custom-software", + "/en/services/custom-software/", "/en/thank-you", "/en/thank-you/", ].some((pathname) => localizedNotFoundPattern.test(pathname))).toBe(false); expect([ "/en/no-existe", "/en/portfolio/no-existe", + "/en/services/no-existe", ].every((pathname) => localizedNotFoundPattern.test(pathname))).toBe(true); expect(source).not.toContain('"destination": "/index.html"'); }); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5e20189..b552f92 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -106,6 +106,276 @@ export const en = { keywords: "web development, seo, internationalization, design, digital services", }, }, + servicesPages: { + hub: { + seo: { + title: "Web development and custom software services | Rodrigo Opalo", + description: + "I build business websites, custom software, and automations for companies. You work directly with me, in English or Spanish, from South Florida and remotely across the United States and Latin America.", + }, + header: { + eyebrow: "Services", + title: "Websites, custom systems, and automation for businesses.", + subtitle: + "A specific need can become a clear digital solution: a website that communicates and converts, a system that organizes the operation, or an automation that removes manual work. You work directly with me at every stage.", + }, + choose: { + title: "What does your business need?", + intro: + "Not every project starts the same way. Here is the simplest way to get oriented:", + items: [ + { + title: "A website", + text: "When the goal is to communicate, attract inquiries, or sell: a professional company presence, landing pages, catalogs, and bilingual experiences.", + }, + { + title: "A custom system", + text: "When the problem is operational: users, roles, data, and workflows that a generic tool doesn't handle well.", + }, + { + title: "An automation", + text: "When the repetitive work is copying data between tools, sending status updates by hand, or rebuilding spreadsheets: that can be connected and solved automatically.", + }, + ], + note: "Some projects combine all three. Scope is defined by the problem, not by the technology.", + }, + directory: { + title: "Services", + web: { + title: "Business websites", + text: "Company sites, landing pages, and bilingual catalogs with responsive design, a technical SEO foundation, and a focus on conversion.", + linkLabel: "Explore business website development", + }, + systems: { + title: "Custom software and web applications", + text: "Web applications, client portals, and internal panels with authentication, roles, data, and integrations.", + linkLabel: "Explore custom software development", + }, + automation: { + title: "Automation and integrations", + text: "I connect APIs, payments, forms, CRMs, and webhooks to reduce manual work. Today I deliver this inside a website or a system, not as a standalone service.", + linkLabel: "Tell me which process you want to automate", + }, + }, + method: { + title: "How I work", + items: [ + { + title: "Direct work", + text: "You talk to me from start to finish. No intermediaries, no rotating teams: decisions are made with context.", + }, + { + title: "Strategy, UX, and technology", + text: "First the problem and the user; then the interface; the tool comes last. Technology supports the solution. It doesn't define it.", + }, + { + title: "Phased development", + text: "Every stage delivers something usable: first a clear foundation, then the improvements the business actually needs.", + }, + ], + }, + coverage: { + title: "Where I work", + text: "I'm based in South Florida and work with businesses in Miami and across the United States, and remotely with Latin America. Communication can be in English or Spanish throughout the project.", + }, + proof: { + title: "Real work, not promises", + text: "You can review the projects published in the portfolio, including LEM-BOX: the logistics platform I build and operate as my own product.", + portfolioLink: "View the full portfolio", + lemboxLink: "View the LEM-BOX case study", + }, + cta: { + title: "Tell me what your business needs", + text: "Share the context and the goal. I'll reply with an honest read on scope and a concrete first step.", + buttonLabel: "Tell me about your project", + }, + }, + web: { + seo: { + title: "Business website design and development | Rodrigo Opalo", + description: + "Custom website development for businesses: institutional sites, landing pages, and bilingual catalogs that load fast, with a technical SEO foundation and forms or WhatsApp to capture inquiries.", + }, + header: { + eyebrow: "Services · Business websites", + backLabel: "Back to services", + title: "Professional websites for businesses.", + subtitle: + "A good website isn't a business card: it's a commercial tool. I design and build sites that communicate clearly, load fast, and turn visits into inquiries.", + }, + deliverables: { + title: "What your website can include", + intro: + "Scope is defined by the business goal. These are the usual building blocks:", + items: [ + { + title: "Company sites and landing pages", + text: "A professional presence for the brand, or pages focused on a specific campaign or service.", + }, + { + title: "Catalogs and content", + text: "Products, projects, or services organized so customers can find them and reach out without friction.", + }, + { + title: "Bilingual experience", + text: "English and Spanish versions with their own URLs, built for audiences in the United States and Latin America.", + }, + { + title: "Responsive design and UX", + text: "Clear hierarchy and comfortable navigation on phone, tablet, and desktop.", + }, + { + title: "Performance and technical SEO", + text: "Fast loading, indexable HTML, and correct metadata as a foundation. An SEO foundation does not guarantee specific search rankings.", + }, + { + title: "Forms and WhatsApp", + text: "Direct channels to capture inquiries, with confirmation and follow-up.", + }, + { + title: "Payments and administration", + text: "Checkout and a self-managed content panel when the project calls for it.", + }, + { + title: "Integrations", + text: "Connections to maps, calendars, payments, and other services the business already uses.", + }, + ], + }, + cases: { + title: "Real websites", + intro: "Some of the websites I've designed and built for businesses:", + items: [ + { + name: "Esteban Firpo · Miami Real Estate", + text: "Bilingual real-estate site with a project catalog and WhatsApp integration.", + }, + { + name: "Mutter Games", + text: "E-commerce with a dynamic catalog and Mercado Pago checkout.", + }, + { + name: "Imprenta Magenta", + text: "Optimized catalog with a dynamic quote form. Functional MVP currently live.", + }, + { + name: "ZENTRA Scent", + 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.", + portfolioLink: "See these projects in the portfolio", + }, + method: { + title: "Phased evolution", + text: "A website can start simple and grow: first the foundation the business needs today; later a catalog, payments, content, or integrations. Each stage is defined with clear deliverables.", + }, + crossLink: { + title: "What if the problem is operational?", + text: "When what's missing isn't communication but order across processes, users, and data, the answer is usually a custom system.", + linkLabel: "Explore custom software development", + }, + coverage: { + title: "Miami and remote work", + text: "I work from South Florida with businesses in Miami and across the United States, and remotely with Latin America, in English and Spanish.", + }, + cta: { + title: "Does your business need a website that brings in inquiries?", + text: "Tell me what you sell, to whom, and what you expect from the site. I'll propose a concrete initial scope.", + buttonLabel: "Tell me about your project", + }, + }, + systems: { + seo: { + title: "Custom software and web applications for businesses | Rodrigo Opalo", + description: + "Custom software development: web applications, client portals, and internal panels with authentication, roles, data, and integrations. Phased delivery for businesses in the United States and Latin America.", + }, + header: { + eyebrow: "Services · Custom software", + backLabel: "Back to services", + title: "Custom software and web applications for businesses.", + subtitle: + "When spreadsheets and generic tools fall short, a custom system organizes users, data, and processes around how your operation actually works.", + }, + scope: { + title: "What I build", + items: [ + { + title: "Web applications", + text: "Tools that run in the browser, with no installation, built around the real workflow.", + }, + { + title: "Client portals", + text: "Authenticated access so each client can see their information, statuses, and documents.", + }, + { + title: "Internal panels", + text: "Operations, content, and data administration for the team, with role-based permissions.", + }, + { + title: "Authentication and roles", + text: "Each profile sees and does only what it's meant to.", + }, + { + title: "Data and files", + text: "Structured information, history, and centralized documentation, including images and evidence.", + }, + { + title: "Operational flows", + text: "Statuses, tracking, and end-to-end traceability of processes.", + }, + { + title: "Integrations", + text: "Connections to payments, APIs, and external services the operation already uses.", + }, + { + title: "Automations", + text: "Repetitive tasks the system handles on its own, reducing errors and manual work.", + }, + ], + }, + proof: { + eyebrow: "Main case study", + title: "LEM-BOX: a system running a real operation", + text: "LEM-BOX is the logistics platform I build and operate as my own product. Clients, partners, and the operations team use it through role-based experiences: package intake, photo evidence, box consolidation, shipments, and status tracking. It's not a demo: it's the business's real operation.", + linkLabel: "View the full LEM-BOX case study", + supporting: + "The portfolio also includes the Mutter Games e-commerce, with a managed catalog and Mercado Pago checkout, and a full-stack campground booking prototype.", + }, + method: { + title: "Phased delivery", + items: [ + { + title: "The process first", + text: "Before writing code, I understand how the operation works: users, edge cases, and priorities.", + }, + { + title: "A useful foundation", + text: "The first stage delivers a usable system that solves the core of the problem.", + }, + { + title: "Deliberate evolution", + text: "Roles, modules, and integrations are added when the operation calls for them, with security and maintenance considered from the start.", + }, + ], + }, + crossLink: { + title: "Only need to communicate and sell?", + text: "If the goal is presence, inquiries, or sales (not managing an operation), a professional business website is probably enough.", + linkLabel: "Explore business website development", + }, + coverage: { + title: "Where and how I work", + text: "From South Florida, with businesses in Miami and across the United States, and remotely with Latin America. You work directly with me, in English or Spanish, throughout the project.", + }, + cta: { + title: "Does your operation need its own system?", + text: "Tell me how you work today and what's slowing you down. I'll tell you honestly whether a custom system makes sense and where to start.", + buttonLabel: "Tell me about your process", + }, + }, + }, portfolio: { title: "Some Work", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 04955d0..86c5ab3 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6,7 +6,7 @@ export const es = { nav: { about: "Sobre mí", why: "Por qué elegirme", - services: "Servicios", // si lo dejás o lo quitás depende de si usarás esa sección + services: "Servicios", portfolio: "Portfolio", contact: "Contacto", faq: "FAQ" @@ -103,6 +103,276 @@ export const es = { keywords: "desarrollo web, seo, internacionalización, diseño, servicios digitales", }, }, + servicesPages: { + hub: { + 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.", + }, + header: { + eyebrow: "Servicios", + title: "Sitios web, sistemas y automatización para empresas.", + subtitle: + "Una necesidad concreta puede convertirse en una solución digital clara: un sitio que comunica y convierte, un sistema que ordena la operación o una automatización que reduce trabajo manual. Trabajás directo conmigo en cada etapa.", + }, + choose: { + title: "¿Qué necesita tu empresa?", + intro: + "No todos los proyectos empiezan igual. Esta es la forma más simple de orientarse:", + items: [ + { + title: "Un sitio web", + text: "Cuando el objetivo es comunicar, captar consultas o vender: presencia institucional, landings, catálogos y experiencias bilingües.", + }, + { + title: "Un sistema a medida", + text: "Cuando el problema es operativo: usuarios, roles, datos y flujos de trabajo que una herramienta genérica no resuelve bien.", + }, + { + title: "Una automatización", + text: "Cuando el trabajo repetitivo está en copiar datos entre herramientas, avisar estados a mano o rearmar planillas: eso se puede conectar y resolver solo.", + }, + ], + note: "Algunos proyectos combinan las tres cosas. El alcance se define por el problema, no por la tecnología.", + }, + directory: { + title: "Servicios", + web: { + title: "Sitios web para empresas", + text: "Sitios institucionales, landings y catálogos bilingües con diseño responsive, base técnica SEO y foco en conversión.", + linkLabel: "Ver desarrollo de sitios web para empresas", + }, + systems: { + title: "Sistemas y aplicaciones a medida", + text: "Aplicaciones web, portales de clientes y paneles internos con autenticación, roles, datos e integraciones.", + linkLabel: "Conocer sistemas a medida", + }, + automation: { + title: "Automatización e integraciones", + text: "Conecto APIs, pagos, formularios, CRMs y webhooks para reducir tareas manuales. Hoy lo trabajo dentro de un sitio o de un sistema, no como servicio aislado.", + linkLabel: "Contame qué proceso querés automatizar", + }, + }, + method: { + title: "Cómo trabajo", + items: [ + { + title: "Trabajo directo", + text: "Hablás conmigo de principio a fin. Sin intermediarios ni equipos rotativos: las decisiones se toman con contexto.", + }, + { + title: "Estrategia, UX y tecnología", + text: "Primero el problema y el usuario; después la interfaz; al final la herramienta. La tecnología respalda la solución, no la define.", + }, + { + title: "Desarrollo por etapas", + text: "Cada etapa entrega algo usable: primero una base clara, después las mejoras que el negocio realmente necesita.", + }, + ], + }, + 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.", + }, + proof: { + title: "Trabajo real, no promesas", + text: "Podés revisar los proyectos publicados en el portfolio, incluido LEM-BOX: la plataforma logística que desarrollo y opero como producto propio.", + portfolioLink: "Ver portfolio completo", + lemboxLink: "Ver el caso LEM-BOX", + }, + cta: { + title: "Contame qué necesita tu empresa", + text: "Compartime el contexto y el objetivo. Te respondo con una lectura honesta del alcance y un primer paso concreto.", + buttonLabel: "Contame tu proyecto", + }, + }, + web: { + seo: { + title: "Sitios web profesionales para empresas | Rodrigo Opalo", + description: + "Diseño y desarrollo de sitios web profesionales para empresas: institucionales, landings y catálogos bilingües, rápidos, con base técnica SEO y formularios o WhatsApp para captar consultas.", + }, + header: { + eyebrow: "Servicios · Sitios web", + backLabel: "Volver a servicios", + title: "Sitios web profesionales para empresas.", + subtitle: + "Un buen sitio no es una tarjeta de presentación: es una herramienta comercial. Diseño y desarrollo sitios que comunican con claridad, cargan rápido y convierten visitas en consultas.", + }, + deliverables: { + title: "Qué puede incluir tu sitio", + intro: + "El alcance se define según el objetivo del negocio. Estos son los componentes habituales:", + items: [ + { + title: "Sitios institucionales y landings", + text: "Presencia profesional para la marca, o páginas enfocadas en una campaña o servicio específico.", + }, + { + title: "Catálogos y contenido", + text: "Productos, proyectos o servicios organizados para que el cliente encuentre y consulte sin fricción.", + }, + { + title: "Experiencia bilingüe", + text: "Versiones en español e inglés con URLs propias, pensadas para públicos de Estados Unidos y Latinoamérica.", + }, + { + title: "Diseño responsive y UX", + text: "Jerarquía clara y navegación cómoda en celular, tablet y escritorio.", + }, + { + title: "Performance y SEO técnico", + text: "Carga rápida, HTML indexable y metadata correcta como base. La base SEO no garantiza posiciones específicas en buscadores.", + }, + { + title: "Formularios y WhatsApp", + text: "Canales directos para captar consultas, con confirmación y seguimiento.", + }, + { + title: "Pagos y administración", + text: "Checkout y panel autoadministrable cuando el proyecto lo requiere.", + }, + { + title: "Integraciones", + text: "Conexión con mapas, calendarios, pagos y otros servicios que el negocio ya usa.", + }, + ], + }, + cases: { + title: "Sitios reales", + intro: "Algunos de los sitios que diseñé y desarrollé para empresas:", + items: [ + { + name: "Esteban Firpo · Miami Real Estate", + text: "Sitio inmobiliario bilingüe con catálogo de proyectos e integración con WhatsApp.", + }, + { + name: "Mutter Games", + text: "E-commerce con catálogo dinámico y checkout con Mercado Pago.", + }, + { + name: "Imprenta Magenta", + text: "Catálogo optimizado con formulario dinámico de cotización. MVP funcional ya activo.", + }, + { + name: "ZENTRA Scent", + 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.", + portfolioLink: "Ver estos proyectos en el portfolio", + }, + method: { + title: "Evolución por etapas", + text: "Un sitio puede empezar simple y crecer: primero la base que el negocio necesita hoy; después catálogo, pagos, contenido o integraciones. Cada etapa se define con entregables claros.", + }, + crossLink: { + title: "¿Y si el problema es operativo?", + text: "Cuando lo que falta no es comunicación sino orden en procesos, usuarios y datos, la respuesta suele ser un sistema a medida.", + linkLabel: "Conocer sistemas a medida", + }, + 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.", + }, + cta: { + title: "¿Tu empresa necesita un sitio que trabaje?", + text: "Contame qué vendés, a quién y qué esperás del sitio. Te propongo un alcance inicial concreto.", + buttonLabel: "Contame tu proyecto", + }, + }, + systems: { + seo: { + title: "Sistemas y aplicaciones a medida para empresas | Rodrigo Opalo", + description: + "Desarrollo de software a medida: aplicaciones web, portales de clientes y paneles internos con autenticación, roles, datos e integraciones. Implementación por etapas para empresas de Estados Unidos y Latinoamérica.", + }, + header: { + eyebrow: "Servicios · Sistemas a medida", + backLabel: "Volver a servicios", + title: "Sistemas y aplicaciones a medida para empresas.", + subtitle: + "Cuando las planillas y las herramientas genéricas quedan chicas, un sistema a medida ordena usuarios, datos y procesos alrededor de cómo trabaja realmente tu operación.", + }, + scope: { + title: "Qué desarrollo", + items: [ + { + title: "Aplicaciones web", + text: "Herramientas accesibles desde el navegador, sin instalación, construidas alrededor del flujo real de trabajo.", + }, + { + title: "Portales de clientes", + text: "Acceso autenticado para que cada cliente vea su información, sus estados y sus documentos.", + }, + { + title: "Paneles internos", + text: "Administración de operación, contenido y datos para el equipo, con permisos por rol.", + }, + { + title: "Autenticación y roles", + text: "Cada perfil ve y hace únicamente lo que le corresponde.", + }, + { + title: "Datos y archivos", + text: "Información estructurada, historial y documentación centralizada, incluyendo imágenes y evidencia.", + }, + { + title: "Flujos operativos", + text: "Estados, seguimiento y trazabilidad de procesos de punta a punta.", + }, + { + title: "Integraciones", + text: "Conexión con pagos, APIs y servicios externos que la operación ya usa.", + }, + { + title: "Automatizaciones", + text: "Tareas repetitivas que el sistema resuelve solo, para reducir errores y trabajo manual.", + }, + ], + }, + proof: { + eyebrow: "Caso principal", + title: "LEM-BOX: un sistema operando de verdad", + text: "LEM-BOX es la plataforma logística que desarrollo y opero como producto propio. La usan clientes, partners y equipo operativo con experiencias por rol: ingreso de paquetes, evidencia fotográfica, consolidación de cajas, embarques y seguimiento de estados. No es un demo: es la operación real del negocio.", + linkLabel: "Ver el caso LEM-BOX completo", + supporting: + "En el portfolio también están el e-commerce Mutter Games, con catálogo administrado y checkout con Mercado Pago, y un prototipo full-stack de reservas de campings.", + }, + method: { + title: "Implementación por etapas", + items: [ + { + title: "Primero el proceso", + text: "Antes de programar, entiendo cómo trabaja la operación: usuarios, casos límite y prioridades.", + }, + { + title: "Una base útil", + text: "La primera etapa entrega un sistema usable que resuelve el núcleo del problema.", + }, + { + title: "Evolución con criterio", + text: "Roles, módulos e integraciones se suman cuando la operación los pide, con seguridad y mantenimiento considerados desde el inicio.", + }, + ], + }, + crossLink: { + title: "¿Solo necesitás comunicar y vender?", + text: "Si el objetivo es presencia, consultas o ventas (y no gestionar una operación), probablemente alcance con un sitio web profesional.", + linkLabel: "Ver desarrollo de sitios web para empresas", + }, + 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.", + }, + cta: { + title: "¿Tu operación necesita su propio sistema?", + text: "Contame cómo trabajan hoy y dónde duele. Te digo con honestidad si conviene un sistema a medida y por dónde empezar.", + buttonLabel: "Contame tu proceso", + }, + }, + }, portfolio: { title: "Algunos trabajos", boating: { diff --git a/src/pages/BusinessWebsitesPage.tsx b/src/pages/BusinessWebsitesPage.tsx new file mode 100644 index 0000000..661df2a --- /dev/null +++ b/src/pages/BusinessWebsitesPage.tsx @@ -0,0 +1,186 @@ +import { Link } from "react-router-dom"; +import { useLanguage } from "../i18n/useLanguage"; +import translations from "../i18n"; +import { getLocalizedPath } from "../routes/siteRoutes"; + +export default function BusinessWebsitesPage() { + const { language } = useLanguage(); + const page = translations[language].servicesPages.web; + const homePath = getLocalizedPath("home", language); + + return ( +
+
+
+ + + {page.header.backLabel} + +

+ {page.header.eyebrow} +

+

+ {page.header.title} +

+

+ {page.header.subtitle} +

+
+
+ +
+
+

+ {page.deliverables.title} +

+

+ {page.deliverables.intro} +

+
+ {page.deliverables.items.map((item) => ( +
+

+ {item.title} +

+

+ {item.text} +

+
+ ))} +
+
+
+ +
+
+

+ {page.cases.title} +

+

+ {page.cases.intro} +

+
+ {page.cases.items.map((item) => ( +
+

{item.name}

+

+ {item.text} +

+
+ ))} +
+

+ {page.cases.note} +

+ + {page.cases.portfolioLink} + +
+
+ +
+
+
+

+ {page.method.title} +

+

+ {page.method.text} +

+
+
+ +

+ {page.crossLink.text} +

+ + {page.crossLink.linkLabel} + +
+
+
+ +
+
+
+

+ {page.coverage.title} +

+

+ {page.coverage.text} +

+
+
+
+ +
+
+

+ {page.cta.title} +

+

+ {page.cta.text} +

+ + {page.cta.buttonLabel} + +
+
+
+ ); +} diff --git a/src/pages/CustomSoftwarePage.tsx b/src/pages/CustomSoftwarePage.tsx new file mode 100644 index 0000000..5e355af --- /dev/null +++ b/src/pages/CustomSoftwarePage.tsx @@ -0,0 +1,180 @@ +import { Link } from "react-router-dom"; +import { useLanguage } from "../i18n/useLanguage"; +import translations from "../i18n"; +import { getLocalizedPath } from "../routes/siteRoutes"; + +export default function CustomSoftwarePage() { + const { language } = useLanguage(); + const page = translations[language].servicesPages.systems; + const homePath = getLocalizedPath("home", language); + + return ( +
+
+
+ + + {page.header.backLabel} + +

+ {page.header.eyebrow} +

+

+ {page.header.title} +

+

+ {page.header.subtitle} +

+
+
+ +
+
+

+ {page.scope.title} +

+
+ {page.scope.items.map((item) => ( +
+

+ {item.title} +

+

+ {item.text} +

+
+ ))} +
+
+
+ +
+
+

+ {page.proof.eyebrow} +

+

+ {page.proof.title} +

+

+ {page.proof.text} +

+ + {page.proof.linkLabel} + +

+ {page.proof.supporting} +

+
+
+ +
+
+

+ {page.method.title} +

+
+ {page.method.items.map((item) => ( +
+

+ {item.title} +

+

+ {item.text} +

+
+ ))} +
+
+
+ +
+
+
+ +

+ {page.crossLink.text} +

+ + {page.crossLink.linkLabel} + +
+
+

+ {page.coverage.title} +

+

+ {page.coverage.text} +

+
+
+
+ +
+
+

+ {page.cta.title} +

+

+ {page.cta.text} +

+ + {page.cta.buttonLabel} + +
+
+
+ ); +} diff --git a/src/pages/LemBoxCasePage.tsx b/src/pages/LemBoxCasePage.tsx index 0cb9c55..a32a56f 100644 --- a/src/pages/LemBoxCasePage.tsx +++ b/src/pages/LemBoxCasePage.tsx @@ -5,6 +5,7 @@ import { lemBoxPublicLinks, lemBoxPublicLinksSection, } from "../data/portfolio/cases/lemBox"; +import translations from "../i18n"; import { useLanguage } from "../i18n/useLanguage"; import { getLocalizedPath } from "../routes/siteRoutes"; import { PortfolioCoverImage } from "../Components/portfolio/PortfolioCard"; @@ -414,6 +415,14 @@ export default function LemBoxCasePage() { > {page.finalCta.buttonLabel} +
+ + {translations[language].servicesPages.hub.directory.systems.linkLabel} + +
diff --git a/src/pages/ServicesHubPage.tsx b/src/pages/ServicesHubPage.tsx new file mode 100644 index 0000000..3f9566c --- /dev/null +++ b/src/pages/ServicesHubPage.tsx @@ -0,0 +1,234 @@ +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"; + +export default function ServicesHubPage() { + const { language } = useLanguage(); + const page = translations[language].servicesPages.hub; + const homePath = getLocalizedPath("home", language); + + return ( +
+
+
+

+ {page.header.eyebrow} +

+

+ {page.header.title} +

+

+ {page.header.subtitle} +

+
+
+ +
+
+

+ {page.choose.title} +

+

+ {page.choose.intro} +

+
+ {page.choose.items.map((item) => ( +
+

+ {item.title} +

+

+ {item.text} +

+
+ ))} +
+

+ {page.choose.note} +

+
+
+ +
+
+

+ {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} + +
+
+
+
+ +
+
+

+ {page.method.title} +

+
+ {page.method.items.map((item) => ( +
+

{item.title}

+

+ {item.text} +

+
+ ))} +
+
+
+ +
+
+
+

+ {page.coverage.title} +

+

+ {page.coverage.text} +

+
+
+

+ {page.proof.title} +

+

+ {page.proof.text} +

+
+ + {page.proof.portfolioLink} + + + {page.proof.lemboxLink} + +
+
+
+
+ +
+
+

+ {page.cta.title} +

+

+ {page.cta.text} +

+ + {page.cta.buttonLabel} + +
+
+
+ ); +} diff --git a/src/routes/siteRoutes.ts b/src/routes/siteRoutes.ts index 64d1963..78275f5 100644 --- a/src/routes/siteRoutes.ts +++ b/src/routes/siteRoutes.ts @@ -4,7 +4,13 @@ import type { Language } from "../i18n/language"; export const SITE_ORIGIN = "https://www.devrodri.com"; -export type IndexablePageKey = "home" | "portfolio" | "lem-box"; +export type IndexablePageKey = + | "home" + | "portfolio" + | "lem-box" + | "services" + | "business-websites" + | "custom-software"; export type ThankYouPageKey = "thank-you"; export type PageKey = IndexablePageKey | ThankYouPageKey; export type PublicRouteKey = `${IndexablePageKey}:${Language}`; @@ -13,9 +19,15 @@ export type IndexablePublicPathname = | "/" | "/portfolio" | "/portfolio/lem-box" + | "/servicios" + | "/servicios/sitios-web-para-empresas" + | "/servicios/sistemas-a-medida" | "/en" | "/en/portfolio" - | "/en/portfolio/lem-box"; + | "/en/portfolio/lem-box" + | "/en/services" + | "/en/services/business-websites" + | "/en/services/custom-software"; export type ThankYouPathname = "/gracias" | "/en/thank-you"; export type PublicPathname = IndexablePublicPathname | ThankYouPathname; @@ -156,6 +168,72 @@ const routeDefinitions = [ }, }, }, + { + page: "services", + localePath: { es: "/servicios", en: "/en/services" }, + metadata: { + es: { + title: translations.es.servicesPages.hub.seo.title, + description: translations.es.servicesPages.hub.seo.description, + ogImagePath: "/img/social-preview.png", + ogImageAlt: "Vista previa de los servicios de devrodri", + ogType: "website", + }, + en: { + title: translations.en.servicesPages.hub.seo.title, + description: translations.en.servicesPages.hub.seo.description, + ogImagePath: "/img/social-preview.png", + ogImageAlt: "devrodri services preview", + ogType: "website", + }, + }, + }, + { + page: "business-websites", + localePath: { + es: "/servicios/sitios-web-para-empresas", + en: "/en/services/business-websites", + }, + metadata: { + es: { + title: translations.es.servicesPages.web.seo.title, + description: translations.es.servicesPages.web.seo.description, + ogImagePath: "/img/social-preview.png", + ogImageAlt: "Vista previa del servicio de sitios web para empresas", + ogType: "website", + }, + en: { + title: translations.en.servicesPages.web.seo.title, + description: translations.en.servicesPages.web.seo.description, + ogImagePath: "/img/social-preview.png", + ogImageAlt: "Business website development service preview", + ogType: "website", + }, + }, + }, + { + page: "custom-software", + localePath: { + es: "/servicios/sistemas-a-medida", + en: "/en/services/custom-software", + }, + metadata: { + es: { + title: translations.es.servicesPages.systems.seo.title, + description: translations.es.servicesPages.systems.seo.description, + ogImagePath: "/img/social-preview.png", + ogImageAlt: "Vista previa del servicio de sistemas a medida", + ogType: "website", + }, + en: { + title: translations.en.servicesPages.systems.seo.title, + description: translations.en.servicesPages.systems.seo.description, + ogImagePath: "/img/social-preview.png", + ogImageAlt: "Custom software development service preview", + ogType: "website", + }, + }, + }, ] as const satisfies readonly RouteDefinition[]; function absoluteUrl(pathname: string): string { diff --git a/src/seo/structuredData.ts b/src/seo/structuredData.ts index b13310a..d640476 100644 --- a/src/seo/structuredData.ts +++ b/src/seo/structuredData.ts @@ -189,6 +189,17 @@ export const STRUCTURED_DATA_BY_ROUTE = { "portfolio:en": null, "lem-box:es": createLemBoxStructuredData("es"), "lem-box:en": createLemBoxStructuredData("en"), + // Service routes intentionally publish no JSON-LD in this phase: Service + // schema has no documented rich-result benefit for solo-provider pages, the + // provider entity already lives on home (Person #person), and each published + // graph would add a hand-maintained CSP hash in vercel.json. Revisit only + // with visible breadcrumb navigation (BreadcrumbList) or explicit approval. + "services:es": null, + "services:en": null, + "business-websites:es": null, + "business-websites:en": null, + "custom-software:es": null, + "custom-software:en": null, } as const satisfies Record; export function getStructuredData( diff --git a/src/test/LanguageContext.test.tsx b/src/test/LanguageContext.test.tsx index fc3e648..2c853a1 100644 --- a/src/test/LanguageContext.test.tsx +++ b/src/test/LanguageContext.test.tsx @@ -61,7 +61,7 @@ describe("i18n contract", () => { const enPaths = collectTranslationPaths(translations.en); expect(Object.keys(translations)).toEqual(["es", "en"]); - expect(esPaths).toHaveLength(144); + expect(esPaths).toHaveLength(273); expect(enPaths).toEqual(esPaths); expect(JSON.parse(JSON.stringify(translations))).toEqual(translations); }); diff --git a/src/test/Navbar.test.tsx b/src/test/Navbar.test.tsx index a75e72b..06c2f1a 100644 --- a/src/test/Navbar.test.tsx +++ b/src/test/Navbar.test.tsx @@ -136,7 +136,7 @@ describe("Navbar color tokens", () => { renderNavbar(); const navigation = getRequiredElement("[data-navbar-desktop]"); - for (const label of ["Sobre mí", "Por qué elegirme", "Portfolio", "Contacto", "FAQ"]) { + for (const label of ["Sobre mí", "Por qué elegirme", "Servicios", "Portfolio", "Contacto", "FAQ"]) { expect(within(navigation).getByRole("link", { name: label })).toHaveClass("hover:text-primary"); } @@ -170,6 +170,7 @@ describe("Navbar no-JavaScript fallback", () => { navigationHrefs: [ "/#sobremi", "/#porqueelegirnos", + "/servicios", "/portfolio", "/#contacto", "/#faq", @@ -185,6 +186,7 @@ describe("Navbar no-JavaScript fallback", () => { navigationHrefs: [ "/#sobremi", "/#porqueelegirnos", + "/servicios", "/portfolio", "/#contacto", "/#faq", @@ -200,6 +202,7 @@ describe("Navbar no-JavaScript fallback", () => { navigationHrefs: [ "/#sobremi", "/#porqueelegirnos", + "/servicios", "/portfolio", "/#contacto", "/#faq", @@ -212,12 +215,67 @@ describe("Navbar no-JavaScript fallback", () => { navigationLabel: "Navegación principal", languageLabel: "Selector de idioma", }, + { + initialEntry: "/servicios", + language: "es", + navigationHrefs: [ + "/#sobremi", + "/#porqueelegirnos", + "/servicios", + "/portfolio", + "/#contacto", + "/#faq", + ], + languageHrefs: ["/servicios", "/en/services"], + currentLabel: "Español, idioma actual", + navigationLabel: "Navegación principal", + languageLabel: "Selector de idioma", + }, + { + initialEntry: "/servicios/sitios-web-para-empresas", + language: "es", + navigationHrefs: [ + "/#sobremi", + "/#porqueelegirnos", + "/servicios", + "/portfolio", + "/#contacto", + "/#faq", + ], + languageHrefs: [ + "/servicios/sitios-web-para-empresas", + "/en/services/business-websites", + ], + currentLabel: "Español, idioma actual", + navigationLabel: "Navegación principal", + languageLabel: "Selector de idioma", + }, + { + initialEntry: "/servicios/sistemas-a-medida", + language: "es", + navigationHrefs: [ + "/#sobremi", + "/#porqueelegirnos", + "/servicios", + "/portfolio", + "/#contacto", + "/#faq", + ], + languageHrefs: [ + "/servicios/sistemas-a-medida", + "/en/services/custom-software", + ], + currentLabel: "Español, idioma actual", + navigationLabel: "Navegación principal", + languageLabel: "Selector de idioma", + }, { initialEntry: "/en", language: "en", navigationHrefs: [ "/en#sobremi", "/en#porqueelegirnos", + "/en/services", "/en/portfolio", "/en#contacto", "/en#faq", @@ -233,6 +291,7 @@ describe("Navbar no-JavaScript fallback", () => { navigationHrefs: [ "/en#sobremi", "/en#porqueelegirnos", + "/en/services", "/en/portfolio", "/en#contacto", "/en#faq", @@ -248,6 +307,7 @@ describe("Navbar no-JavaScript fallback", () => { navigationHrefs: [ "/en#sobremi", "/en#porqueelegirnos", + "/en/services", "/en/portfolio", "/en#contacto", "/en#faq", @@ -260,6 +320,60 @@ describe("Navbar no-JavaScript fallback", () => { navigationLabel: "Primary navigation", languageLabel: "Language selector", }, + { + initialEntry: "/en/services", + language: "en", + navigationHrefs: [ + "/en#sobremi", + "/en#porqueelegirnos", + "/en/services", + "/en/portfolio", + "/en#contacto", + "/en#faq", + ], + languageHrefs: ["/servicios", "/en/services"], + currentLabel: "English, current language", + navigationLabel: "Primary navigation", + languageLabel: "Language selector", + }, + { + initialEntry: "/en/services/business-websites", + language: "en", + navigationHrefs: [ + "/en#sobremi", + "/en#porqueelegirnos", + "/en/services", + "/en/portfolio", + "/en#contacto", + "/en#faq", + ], + languageHrefs: [ + "/servicios/sitios-web-para-empresas", + "/en/services/business-websites", + ], + currentLabel: "English, current language", + navigationLabel: "Primary navigation", + languageLabel: "Language selector", + }, + { + initialEntry: "/en/services/custom-software", + language: "en", + navigationHrefs: [ + "/en#sobremi", + "/en#porqueelegirnos", + "/en/services", + "/en/portfolio", + "/en#contacto", + "/en#faq", + ], + languageHrefs: [ + "/servicios/sistemas-a-medida", + "/en/services/custom-software", + ], + currentLabel: "English, current language", + navigationLabel: "Primary navigation", + languageLabel: "Language selector", + }, ] as const)( "renders typed HTTP fallbacks for $initialEntry", ({ diff --git a/src/test/Routing.test.tsx b/src/test/Routing.test.tsx index b4f37f1..fb2eeec 100644 --- a/src/test/Routing.test.tsx +++ b/src/test/Routing.test.tsx @@ -114,9 +114,27 @@ describe("application routing", () => { { pathname: "/", routeKey: "home:es" }, { pathname: "/portfolio", routeKey: "portfolio:es" }, { pathname: "/portfolio/lem-box", routeKey: "lem-box:es" }, + { pathname: "/servicios", routeKey: "services:es" }, + { + pathname: "/servicios/sitios-web-para-empresas", + routeKey: "business-websites:es", + }, + { + pathname: "/servicios/sistemas-a-medida", + routeKey: "custom-software:es", + }, { pathname: "/en", routeKey: "home:en" }, { pathname: "/en/portfolio", routeKey: "portfolio:en" }, { pathname: "/en/portfolio/lem-box", routeKey: "lem-box:en" }, + { pathname: "/en/services", routeKey: "services:en" }, + { + pathname: "/en/services/business-websites", + routeKey: "business-websites:en", + }, + { + pathname: "/en/services/custom-software", + routeKey: "custom-software:en", + }, ] as const)( "keeps one client JSON-LD script at most for $pathname", async ({ pathname, routeKey }) => { @@ -157,6 +175,128 @@ describe("application routing", () => { }, ); + it.each([ + { + path: "/servicios", + heading: "Sitios web, sistemas y automatización para empresas.", + canonical: "https://www.devrodri.com/servicios", + hreflangEn: "https://www.devrodri.com/en/services", + }, + { + path: "/servicios/", + heading: "Sitios web, sistemas y automatización para empresas.", + canonical: "https://www.devrodri.com/servicios", + hreflangEn: "https://www.devrodri.com/en/services", + }, + { + path: "/servicios/sitios-web-para-empresas", + heading: "Sitios web profesionales para empresas.", + canonical: + "https://www.devrodri.com/servicios/sitios-web-para-empresas", + hreflangEn: "https://www.devrodri.com/en/services/business-websites", + }, + { + path: "/servicios/sistemas-a-medida", + heading: "Sistemas y aplicaciones a medida para empresas.", + canonical: "https://www.devrodri.com/servicios/sistemas-a-medida", + hreflangEn: "https://www.devrodri.com/en/services/custom-software", + }, + { + path: "/en/services", + heading: "Websites, custom systems, and automation for businesses.", + canonical: "https://www.devrodri.com/en/services", + hreflangEn: "https://www.devrodri.com/en/services", + }, + { + path: "/en/services/business-websites", + heading: "Professional websites for businesses.", + canonical: "https://www.devrodri.com/en/services/business-websites", + hreflangEn: "https://www.devrodri.com/en/services/business-websites", + }, + { + path: "/en/services/custom-software", + heading: "Custom software and web applications for businesses.", + canonical: "https://www.devrodri.com/en/services/custom-software", + hreflangEn: "https://www.devrodri.com/en/services/custom-software", + }, + ] as const)( + "loads the lazy services route and canonical metadata for $path", + async ({ path, heading, canonical, hreflangEn }) => { + renderApp(path); + + expect( + await screen.findByRole("heading", { level: 1, name: heading }), + ).toBeInTheDocument(); + await waitFor(() => { + expect( + document.head.querySelector('link[rel="canonical"]'), + ).toHaveAttribute("href", canonical); + expect( + document.head.querySelector('meta[name="robots"]'), + ).toHaveAttribute("content", "index, follow"); + expect( + document.head.querySelector('link[rel="alternate"][hreflang="en"]'), + ).toHaveAttribute("href", hreflangEn); + const xDefault = document.head.querySelector( + 'link[rel="alternate"][hreflang="x-default"]', + ); + const spanish = document.head.querySelector( + 'link[rel="alternate"][hreflang="es"]', + ); + expect(xDefault?.getAttribute("href")).toBe( + spanish?.getAttribute("href"), + ); + }); + }, + ); + + it("navigates from the services hub to both service pages", async () => { + const user = userEvent.setup(); + renderApp("/servicios"); + + await screen.findByRole("heading", { + level: 1, + name: "Sitios web, sistemas y automatización para empresas.", + }); + + await user.click( + screen.getByRole("link", { + name: /Ver desarrollo de sitios web para empresas/, + }), + ); + expect( + await screen.findByRole("heading", { + level: 1, + name: "Sitios web profesionales para empresas.", + }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("link", { name: /Conocer sistemas a medida/ }), + ); + expect( + await screen.findByRole("heading", { + level: 1, + name: "Sistemas y aplicaciones a medida para empresas.", + }), + ).toBeInTheDocument(); + + expect( + screen.getByRole("link", { name: /Ver el caso LEM-BOX completo/ }), + ).toHaveAttribute("href", "/portfolio/lem-box"); + }); + + it("links the LEM-BOX case study back to the custom software service", async () => { + renderApp("/portfolio/lem-box"); + + expect( + await screen.findByRole("heading", { level: 1, name: "LEM-BOX" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Conocer sistemas a medida" }), + ).toHaveAttribute("href", "/servicios/sistemas-a-medida"); + }); + it("navigates internally from home to portfolio without throwing", async () => { const user = userEvent.setup(); renderApp("/"); diff --git a/src/test/Semantics.test.tsx b/src/test/Semantics.test.tsx index 8fd963a..196d7bc 100644 --- a/src/test/Semantics.test.tsx +++ b/src/test/Semantics.test.tsx @@ -23,9 +23,21 @@ const publicRoutes = [ { path: "/", skip: "Saltar al contenido principal" }, { path: "/portfolio", skip: "Saltar al contenido principal" }, { path: "/portfolio/lem-box", skip: "Saltar al contenido principal" }, + { path: "/servicios", skip: "Saltar al contenido principal" }, + { + path: "/servicios/sitios-web-para-empresas", + skip: "Saltar al contenido principal", + }, + { + path: "/servicios/sistemas-a-medida", + skip: "Saltar al contenido principal", + }, { path: "/en", skip: "Skip to main content" }, { path: "/en/portfolio", skip: "Skip to main content" }, { path: "/en/portfolio/lem-box", skip: "Skip to main content" }, + { path: "/en/services", skip: "Skip to main content" }, + { path: "/en/services/business-websites", skip: "Skip to main content" }, + { path: "/en/services/custom-software", skip: "Skip to main content" }, { path: "/no-existe", skip: "Saltar al contenido principal" }, { path: "/en/no-existe", skip: "Skip to main content" }, ] as const; diff --git a/src/test/ServiceCopyRevision.test.ts b/src/test/ServiceCopyRevision.test.ts new file mode 100644 index 0000000..1f2514f --- /dev/null +++ b/src/test/ServiceCopyRevision.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import translations from "../i18n"; +import { portfolioCases } from "../data/portfolio"; + +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 approvedParentheticalEs = + "Si el objetivo es presencia, consultas o ventas (y no gestionar una operación), probablemente alcance con un sitio web profesional."; +const approvedParentheticalEn = + "If the goal is presence, inquiries, or sales (not managing an operation), a professional business website is probably enough."; + +const forbiddenZentraClaims = + /sitio terminado|producción completa|e-commerce ya publicado|suscripciones ya activas|clientes|ventas|métricas|fecha de lanzamiento|finished website|full production|already live|already published|subscriptions are active|launch date|customers|sales figures/i; + +describe("SEO-COM-01 copy revision contract", () => { + it("has zero em dashes in the six commercial service routes", () => { + const esServicesPages = JSON.stringify(translations.es.servicesPages); + const enServicesPages = JSON.stringify(translations.en.servicesPages); + + expect(esServicesPages).not.toMatch(/—/); + expect(enServicesPages).not.toMatch(/—/); + }); + + it("uses the exact approved parenthetical sentence in ES and EN", () => { + expect(translations.es.servicesPages.systems.crossLink.text).toBe( + approvedParentheticalEs, + ); + expect(translations.en.servicesPages.systems.crossLink.text).toBe( + approvedParentheticalEn, + ); + }); + + it("keeps the four featured website cases with ZENTRA replacing Boating", () => { + const esItems = translations.es.servicesPages.web.cases.items; + const enItems = translations.en.servicesPages.web.cases.items; + + expect(esItems.map(({ name }) => name)).toEqual([ + "Esteban Firpo · Miami Real Estate", + "Mutter Games", + "Imprenta Magenta", + "ZENTRA Scent", + ]); + expect(enItems.map(({ name }) => name)).toEqual([ + "Esteban Firpo · Miami Real Estate", + "Mutter Games", + "Imprenta Magenta", + "ZENTRA Scent", + ]); + expect(esItems.some(({ name }) => name.includes("Boating"))).toBe(false); + expect(enItems.some(({ name }) => name.includes("Boating"))).toBe(false); + }); + + it("preserves Magenta's existing factual qualifier", () => { + const esMagenta = translations.es.servicesPages.web.cases.items.find( + ({ name }) => name === "Imprenta Magenta", + ); + const enMagenta = translations.en.servicesPages.web.cases.items.find( + ({ name }) => name === "Imprenta Magenta", + ); + + expect(esMagenta?.text).toBe( + "Catálogo optimizado con formulario dinámico de cotización. MVP funcional ya activo.", + ); + expect(enMagenta?.text).toBe( + "Optimized catalog with a dynamic quote form. Functional MVP currently live.", + ); + }); + + it("marks ZENTRA as in development, backed by the approved scope, without finished-product claims", () => { + const esZentra = translations.es.servicesPages.web.cases.items.find( + ({ name }) => name === "ZENTRA Scent", + ); + const enZentra = translations.en.servicesPages.web.cases.items.find( + ({ name }) => name === "ZENTRA Scent", + ); + + expect(esZentra?.text).toBe( + "Proyecto en desarrollo: sitio web y e-commerce con suscripciones, panel administrativo y gestión de stock.", + ); + expect(enZentra?.text).toBe( + "In development: website and e-commerce with subscriptions, an admin panel, and inventory management.", + ); + + expect(esZentra?.text).toMatch(/en desarrollo/i); + expect(enZentra?.text).toMatch(/in development/i); + expect(esZentra?.text).toMatch(/e-commerce/i); + expect(esZentra?.text).toMatch(/suscripciones/i); + expect(esZentra?.text).toMatch(/panel administrativo/i); + expect(esZentra?.text).toMatch(/stock/i); + expect(enZentra?.text).toMatch(/e-commerce/i); + expect(enZentra?.text).toMatch(/subscriptions/i); + expect(enZentra?.text).toMatch(/admin panel/i); + expect(enZentra?.text).toMatch(/inventory/i); + + expect(esZentra?.text).not.toMatch(forbiddenZentraClaims); + expect(enZentra?.text).not.toMatch(forbiddenZentraClaims); + }); + + it("keeps Boating available in the Portfolio even though it left the website proof list", () => { + const boating = portfolioCases.find(({ key }) => key === "boating"); + expect(boating).toBeDefined(); + expect(boating?.content.es.title).toBe("Boating Adventures Miami"); + expect(boating?.content.en.title).toBe("Boating Adventures Miami"); + }); + + it("reuses a real internal Portfolio link for proof cases, without inventing a ZENTRA route", () => { + const businessWebsitesPageSource = fs.readFileSync( + path.join(projectRoot, "src/pages/BusinessWebsitesPage.tsx"), + "utf8", + ); + + expect(businessWebsitesPageSource).toContain( + 'getLocalizedPath("portfolio", language)', + ); + expect(businessWebsitesPageSource).not.toContain("/portfolio/zentra"); + expect(businessWebsitesPageSource).not.toContain("#zentra"); + }); +}); diff --git a/src/test/ThankYouPage.test.tsx b/src/test/ThankYouPage.test.tsx index ca725e8..dd313a3 100644 --- a/src/test/ThankYouPage.test.tsx +++ b/src/test/ThankYouPage.test.tsx @@ -121,9 +121,27 @@ describe("localized confirmation pages", () => { { pathname: "/", routeKey: "home:es" }, { pathname: "/portfolio", routeKey: "portfolio:es" }, { pathname: "/portfolio/lem-box", routeKey: "lem-box:es" }, + { pathname: "/servicios", routeKey: "services:es" }, + { + pathname: "/servicios/sitios-web-para-empresas", + routeKey: "business-websites:es", + }, + { + pathname: "/servicios/sistemas-a-medida", + routeKey: "custom-software:es", + }, { pathname: "/en", routeKey: "home:en" }, { pathname: "/en/portfolio", routeKey: "portfolio:en" }, { pathname: "/en/portfolio/lem-box", routeKey: "lem-box:en" }, + { pathname: "/en/services", routeKey: "services:en" }, + { + pathname: "/en/services/business-websites", + routeKey: "business-websites:en", + }, + { + pathname: "/en/services/custom-software", + routeKey: "custom-software:en", + }, ] as const)( "preserves structured-data resolution for the indexable route $pathname", ({ pathname, routeKey }) => { diff --git a/src/test/analytics.test.ts b/src/test/analytics.test.ts index 665efe3..33002fa 100644 --- a/src/test/analytics.test.ts +++ b/src/test/analytics.test.ts @@ -135,6 +135,31 @@ describe("analytics", () => { path: "/en/portfolio/lem-box", title: "LEM-BOX: logistics platform and own product | Rodrigo Opalo", }, + { + path: "/servicios", + title: "Servicios de desarrollo web y software a medida | Rodrigo Opalo", + }, + { + path: "/servicios/sitios-web-para-empresas", + title: "Sitios web profesionales para empresas | Rodrigo Opalo", + }, + { + path: "/servicios/sistemas-a-medida", + title: "Sistemas y aplicaciones a medida para empresas | Rodrigo Opalo", + }, + { + path: "/en/services", + title: "Web development and custom software services | Rodrigo Opalo", + }, + { + path: "/en/services/business-websites", + title: "Business website design and development | Rodrigo Opalo", + }, + { + path: "/en/services/custom-software", + title: + "Custom software and web applications for businesses | Rodrigo Opalo", + }, ] as const)( "reports the canonical title for $path without an unknown bucket", async ({ path, title }) => { diff --git a/src/test/siteRoutes.test.ts b/src/test/siteRoutes.test.ts index 417df50..878dfbd 100644 --- a/src/test/siteRoutes.test.ts +++ b/src/test/siteRoutes.test.ts @@ -53,6 +53,48 @@ const expectedRoutes = [ equivalentLocalePath: "/portfolio/lem-box", page: "lem-box", }, + { + routeKey: "services:es", + locale: "es", + pathname: "/servicios", + equivalentLocalePath: "/en/services", + page: "services", + }, + { + routeKey: "services:en", + locale: "en", + pathname: "/en/services", + equivalentLocalePath: "/servicios", + page: "services", + }, + { + routeKey: "business-websites:es", + locale: "es", + pathname: "/servicios/sitios-web-para-empresas", + equivalentLocalePath: "/en/services/business-websites", + page: "business-websites", + }, + { + routeKey: "business-websites:en", + locale: "en", + pathname: "/en/services/business-websites", + equivalentLocalePath: "/servicios/sitios-web-para-empresas", + page: "business-websites", + }, + { + routeKey: "custom-software:es", + locale: "es", + pathname: "/servicios/sistemas-a-medida", + equivalentLocalePath: "/en/services/custom-software", + page: "custom-software", + }, + { + routeKey: "custom-software:en", + locale: "en", + pathname: "/en/services/custom-software", + equivalentLocalePath: "/servicios/sistemas-a-medida", + page: "custom-software", + }, { routeKey: "thank-you:es", locale: "es", @@ -103,8 +145,36 @@ const lockedLemBoxMetadata = { }, } as const; +const lockedServicesMetadata = { + "services:es": { + title: "Servicios de desarrollo web y software a medida | Rodrigo Opalo", + canonical: "https://www.devrodri.com/servicios", + }, + "services:en": { + title: "Web development and custom software services | Rodrigo Opalo", + canonical: "https://www.devrodri.com/en/services", + }, + "business-websites:es": { + title: "Sitios web profesionales para empresas | Rodrigo Opalo", + canonical: "https://www.devrodri.com/servicios/sitios-web-para-empresas", + }, + "business-websites:en": { + title: "Business website design and development | Rodrigo Opalo", + canonical: "https://www.devrodri.com/en/services/business-websites", + }, + "custom-software:es": { + title: "Sistemas y aplicaciones a medida para empresas | Rodrigo Opalo", + canonical: "https://www.devrodri.com/servicios/sistemas-a-medida", + }, + "custom-software:en": { + title: + "Custom software and web applications for businesses | Rodrigo Opalo", + canonical: "https://www.devrodri.com/en/services/custom-software", + }, +} as const; + describe("public route registry", () => { - it("contains the six indexable routes and two localized confirmation routes", () => { + it("contains the twelve indexable routes and two localized confirmation routes", () => { expect( PUBLIC_ROUTES.map( ({ @@ -122,10 +192,10 @@ describe("public route registry", () => { }), ), ).toEqual(expectedRoutes); - expect(new Set(PUBLIC_ROUTES.map((route) => route.pathname)).size).toBe(8); - expect(new Set(PUBLIC_ROUTES.map((route) => route.routeKey)).size).toBe(8); + expect(new Set(PUBLIC_ROUTES.map((route) => route.pathname)).size).toBe(14); + expect(new Set(PUBLIC_ROUTES.map((route) => route.routeKey)).size).toBe(14); expect(PUBLIC_ROUTES.filter((route) => route.sitemap.include)).toHaveLength( - 6, + 12, ); expect(PUBLIC_ROUTES.filter((route) => !route.sitemap.include)).toHaveLength( 2, @@ -207,6 +277,22 @@ describe("public route registry", () => { } }); + it("publishes the exact approved services metadata", () => { + for (const [routeKey, expected] of Object.entries(lockedServicesMetadata)) { + const route = PUBLIC_ROUTES.find( + (candidate) => candidate.routeKey === routeKey, + ); + + expect(route).toBeDefined(); + expect(route?.metadata.title).toBe(expected.title); + expect(route?.metadata.canonical).toBe(expected.canonical); + expect(route?.metadata.description).not.toBe(""); + expect(route?.metadata.og?.title).toBe(expected.title); + expect(route?.metadata.twitter?.title).toBe(expected.title); + expect(route?.metadata.robots).toBe("index, follow"); + } + }); + it("keeps the locked LEM-BOX metadata and social image unchanged", () => { for (const [routeKey, expected] of Object.entries(lockedLemBoxMetadata)) { const route = PUBLIC_ROUTES.find( @@ -242,6 +328,22 @@ describe("public route registry", () => { expect(getEquivalentLocalePath("/en/portfolio/lem-box", "es")).toBe( "/portfolio/lem-box", ); + expect(getLocalizedPath("services", "es")).toBe("/servicios"); + expect(getLocalizedPath("services", "en")).toBe("/en/services"); + expect(getEquivalentLocalePath("/servicios", "en")).toBe("/en/services"); + expect(getEquivalentLocalePath("/en/services", "es")).toBe("/servicios"); + expect( + getEquivalentLocalePath("/servicios/sitios-web-para-empresas", "en"), + ).toBe("/en/services/business-websites"); + expect( + getEquivalentLocalePath("/en/services/business-websites", "es"), + ).toBe("/servicios/sitios-web-para-empresas"); + expect(getEquivalentLocalePath("/servicios/sistemas-a-medida", "en")).toBe( + "/en/services/custom-software", + ); + expect(getEquivalentLocalePath("/en/services/custom-software", "es")).toBe( + "/servicios/sistemas-a-medida", + ); expect(getLocalizedPath("thank-you", "es")).toBe("/gracias"); expect(getLocalizedPath("thank-you", "en")).toBe("/en/thank-you"); expect(getEquivalentLocalePath("/gracias", "en")).toBe( diff --git a/src/test/structuredData.test.ts b/src/test/structuredData.test.ts index cf0b398..2bc0445 100644 --- a/src/test/structuredData.test.ts +++ b/src/test/structuredData.test.ts @@ -41,7 +41,7 @@ function graphTypes(value: { "@graph": readonly { "@type": string }[] }) { } describe("route structured data", () => { - it("defines every public route and leaves Portfolio without JSON-LD", () => { + it("defines every public route and leaves Portfolio and services without JSON-LD", () => { expect(Object.keys(STRUCTURED_DATA_BY_ROUTE)).toEqual([ "home:es", "home:en", @@ -49,9 +49,21 @@ describe("route structured data", () => { "portfolio:en", "lem-box:es", "lem-box:en", + "services:es", + "services:en", + "business-websites:es", + "business-websites:en", + "custom-software:es", + "custom-software:en", ]); expect(getStructuredData("portfolio:es")).toBeNull(); expect(getStructuredData("portfolio:en")).toBeNull(); + expect(getStructuredData("services:es")).toBeNull(); + expect(getStructuredData("services:en")).toBeNull(); + expect(getStructuredData("business-websites:es")).toBeNull(); + expect(getStructuredData("business-websites:en")).toBeNull(); + expect(getStructuredData("custom-software:es")).toBeNull(); + expect(getStructuredData("custom-software:en")).toBeNull(); }); it.each(["es", "en"] as const)( diff --git a/vercel.json b/vercel.json index 44c1175..ed659a1 100644 --- a/vercel.json +++ b/vercel.json @@ -59,7 +59,7 @@ "rewrites": [], "routes": [ { - "src": "/en/(?!(?:portfolio(?:/lem-box)?|thank-you)/?$).+$", + "src": "/en/(?!(?:portfolio(?:/lem-box)?|services(?:/business-websites|/custom-software)?|thank-you)/?$).+$", "caseSensitive": true, "status": 404, "dest": "/en/404.html",