diff --git a/public/img/jacquie-cover.jpg b/public/img/jacquie-cover.jpg new file mode 100644 index 0000000..70cf07e Binary files /dev/null and b/public/img/jacquie-cover.jpg differ diff --git a/scripts/verify-prerender.mjs b/scripts/verify-prerender.mjs index 8980cd2..54ed2f5 100644 --- a/scripts/verify-prerender.mjs +++ b/scripts/verify-prerender.mjs @@ -39,7 +39,7 @@ const expectedRoutes = [ content: "Algunos trabajos", metadataHash: "e71cd38df788eae0835cd411fed71d20e75aec9e2b8659841559758753416cc7", - noJavaScriptMarkers: 9, + noJavaScriptMarkers: 10, }, { pathname: "/portfolio/lem-box", @@ -90,7 +90,7 @@ const expectedRoutes = [ content: "Some Work", metadataHash: "c1ac372e3b0249fa16ff041e2e97b7bde63aa062a851f287ea2aebec92d0549b", - noJavaScriptMarkers: 9, + noJavaScriptMarkers: 10, }, { pathname: "/en/portfolio/lem-box", @@ -569,7 +569,7 @@ function assertNoJavaScriptOnlyControls(html, { lang, pathname }) { button.includes('aria-expanded="false"') && button.includes(`aria-label="${detailLabelPrefix}`), ); - assert.equal(detailButtons.length, 7, `${pathname}: detail toggle count`); + assert.equal(detailButtons.length, 8, `${pathname}: detail toggle count`); assert.ok( detailButtons.every((button) => button.includes('data-nojs-hide="true"') @@ -585,12 +585,12 @@ function assertNoJavaScriptOnlyControls(html, { lang, pathname }) { html, lang === "es" ? ">Ver más" : ">View details", ), - 7, + 8, `${pathname}: localized detail toggle copy`, ); assert.equal( count(html, 'id="portfolio-case-'), - 8, + 9, `${pathname}: prerendered Portfolio cards`, ); assert.ok( @@ -601,7 +601,7 @@ function assertNoJavaScriptOnlyControls(html, { lang, pathname }) { ); assert.equal( count(html, 'data-nojs-hide="true"'), - 10, + 11, `${pathname}: approved no-JavaScript hide markers`, ); assert.equal(nativeSubmitButtons.length, 0, `${pathname}: no form submit`); @@ -1224,7 +1224,7 @@ for (const artifact of expectedNotFoundArtifacts) { } assert.equal(new Set(notFoundDocuments).size, expectedNotFoundArtifacts.length); assert.equal(noJavaScriptDocuments.length, 16); -assert.equal(prerenderedNoJavaScriptMarkerTotal, 64); +assert.equal(prerenderedNoJavaScriptMarkerTotal, 66); const expectedHtmlArtifacts = [ ...expectedRoutes.map((route) => route.file), diff --git a/src/Components/PortfolioSection.tsx b/src/Components/PortfolioSection.tsx index 2b39cd8..2fb14e5 100644 --- a/src/Components/PortfolioSection.tsx +++ b/src/Components/PortfolioSection.tsx @@ -13,6 +13,7 @@ export default function PortfolioSection() { category: portfolioCase.content[language].tags[0], key: portfolioCase.key, cover: portfolioCase.cover, + coverAlt: portfolioCase.coverAlt?.[language] ?? "", coverFit: getPortfolioCoverFit(portfolioCase.responsiveCover), responsiveCover: portfolioCase.responsiveCover, status: portfolioCase.content[language].status, @@ -106,7 +107,7 @@ export default function PortfolioSection() {
; actions: readonly PortfolioAction[]; content: Localized; home?: { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 59005e2..cfcca3e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -244,16 +244,20 @@ export const en = { }, cases: { title: "Real websites", - intro: "Some of the websites I've designed and built for businesses:", + intro: "Some of the websites I designed and developed for businesses and personal brands:", items: [ { - name: "Esteban Firpo · Miami Real Estate", - text: "Bilingual real-estate site with a project catalog and WhatsApp integration.", + name: "Jacquie Zárate · Real Estate & Investment", + text: "Trilingual real estate site with a filterable pre-construction catalog, per-language SEO, and direct WhatsApp contact.", }, { name: "Mutter Games", text: "E-commerce with a dynamic catalog and Mercado Pago checkout.", }, + { + name: "Esteban Firpo · Miami Real Estate", + text: "Bilingual real-estate site with a project catalog and WhatsApp integration.", + }, { name: "Imprenta Magenta", text: "Optimized catalog with a dynamic quote form. Functional MVP currently live.", @@ -262,6 +266,10 @@ export const en = { name: "ZENTRA Scent", text: "In development: website and e-commerce with subscriptions, an admin panel, and inventory management.", }, + { + name: "Federico Roma", + text: "Personal and professional website with biography, video courses, photography, and exclusive products.", + }, ], 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", @@ -414,6 +422,11 @@ export const en = { status: "In development", link: "Visit site in development", }, + jacquie: { + title: "Jacquie Zárate · Real Estate & Investment", + desc: "Trilingual real estate site for buying, selling, and investing in Miami, with a filterable pre-construction catalog, property pages, financing guidance, and direct WhatsApp contact.", + link: "View website", + }, esteban: { title: "Esteban Firpo · Miami Real Estate", desc: "Miami preconstruction real‑estate site. Project catalog, galleries, payment plans and ES/EN pages with WhatsApp integration and optimized SEO.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 66807a4..6eb4199 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -241,16 +241,20 @@ export const es = { }, cases: { title: "Sitios reales", - intro: "Algunos de los sitios que diseñé y desarrollé para empresas:", + intro: "Algunos de los sitios que diseñé y desarrollé para empresas y marcas personales:", items: [ { - name: "Esteban Firpo · Miami Real Estate", - text: "Sitio inmobiliario bilingüe con catálogo de proyectos e integración con WhatsApp.", + name: "Jacquie Zárate · Real Estate e Inversión", + text: "Sitio inmobiliario trilingüe con catálogo de preconstrucción filtrable, SEO por idioma y contacto directo por WhatsApp.", }, { name: "Mutter Games", text: "E-commerce con catálogo dinámico y checkout con Mercado Pago.", }, + { + name: "Esteban Firpo · Miami Real Estate", + text: "Sitio inmobiliario bilingüe con catálogo de proyectos e integración con WhatsApp.", + }, { name: "Imprenta Magenta", text: "Catálogo optimizado con formulario dinámico de cotización. MVP funcional ya activo.", @@ -259,6 +263,10 @@ export const es = { name: "ZENTRA Scent", text: "Proyecto en desarrollo: sitio web y e-commerce con suscripciones, panel administrativo y gestión de stock.", }, + { + name: "Federico Roma", + text: "Sitio web personal y profesional con biografía, cursos en video, fotografías y productos exclusivos.", + }, ], 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", @@ -410,6 +418,11 @@ export const es = { status: "En desarrollo", link: "Visitar sitio en desarrollo", }, + jacquie: { + title: "Jacquie Zárate · Real Estate e Inversión", + desc: "Sitio inmobiliario trilingüe para comprar, vender e invertir en Miami, con catálogo de preconstrucción filtrable, fichas de propiedades, guía de financiación y contacto directo por WhatsApp.", + link: "Ver sitio web", + }, esteban: { title: "Esteban Firpo · Miami Real Estate", desc: "Sitio inmobiliario de preconstrucción en Miami. Catálogo de proyectos, galerías, planes de pago y fichas ES/EN con integración a WhatsApp y SEO optimizado.", diff --git a/src/pages/PortfolioPage.tsx b/src/pages/PortfolioPage.tsx index 1be975d..53eaa45 100644 --- a/src/pages/PortfolioPage.tsx +++ b/src/pages/PortfolioPage.tsx @@ -173,6 +173,9 @@ export default function PortfolioPage() { } cover={portfolioCase.cover} desc={content.description} + {...(portfolioCase.coverAlt === undefined + ? {} + : { coverAlt: portfolioCase.coverAlt[language] })} details={ detailsExpanded && content.details !== undefined ? ( { const enPaths = collectTranslationPaths(translations.en); expect(Object.keys(translations)).toEqual(["es", "en"]); - expect(esPaths).toHaveLength(273); + expect(esPaths).toHaveLength(280); expect(enPaths).toEqual(esPaths); expect(JSON.parse(JSON.stringify(translations))).toEqual(translations); }); diff --git a/src/test/NoJsVisibility.test.tsx b/src/test/NoJsVisibility.test.tsx index f786fe2..571b8d9 100644 --- a/src/test/NoJsVisibility.test.tsx +++ b/src/test/NoJsVisibility.test.tsx @@ -69,7 +69,7 @@ const approvedFiles = { }, "src/Components/PortfolioSection.tsx": { baselineHash: - "74d84a2a8057249ed69c0f30f0069db03797ac1fd103722575edc09d9bd53ce8", + "081743ff5a84eda1878c1590392d703463c5ae994215a90e1892305a1245d080", markers: 4, }, "src/Components/SobreMiSection.tsx": { @@ -84,7 +84,7 @@ const approvedFiles = { }, "src/Components/portfolio/PortfolioCard.tsx": { baselineHash: - "88eabddb08798b1e6d638a6a7cde97c36afd61dfb32c595fce627894b333e47d", + "e8599c1cc33fafca2b6e9e2d098e335cc49f2bce93055b5e6609465cfe16794b", markers: 1, }, } as const; diff --git a/src/test/PortfolioSafety.test.ts b/src/test/PortfolioSafety.test.ts index 0abc1b7..f072e80 100644 --- a/src/test/PortfolioSafety.test.ts +++ b/src/test/PortfolioSafety.test.ts @@ -20,6 +20,7 @@ import translations from "../i18n"; interface FileSystemApi { existsSync(path: string): boolean; + readdirSync(path: string): string[]; readFileSync(path: string, encoding: "utf8"): string; readFileSync(path: string): Uint8Array; statSync(path: string): { size: number }; @@ -55,6 +56,7 @@ const projectRoot = path.join( const expectedProjectKeys = [ "lem_box", "zentra", + "jacquie", "esteban", "mutter", "magenta", @@ -62,10 +64,11 @@ const expectedProjectKeys = [ "boating", "campings_demo", ] as const; -const expectedHomeKeys = ["lem_box", "zentra", "esteban", "mutter"]; +const expectedHomeKeys = ["lem_box", "zentra", "jacquie", "mutter"]; const expectedCategories = { lem_box: "systems", zentra: "brand", + jacquie: "web", esteban: "web", mutter: "ecommerce", magenta: "web", @@ -222,11 +225,17 @@ function readPngDimensions(data: Uint8Array) { } describe("portfolio architecture invariants", () => { - it("derives eight unique keys in the exact approved order", () => { + it("derives nine unique keys in the exact approved order", () => { expect(projectKeys).toEqual(expectedProjectKeys); - expect(new Set(projectKeys).size).toBe(8); + expect(new Set(projectKeys).size).toBe(9); expect(portfolioCases.map(({ portfolioOrder }) => portfolioOrder)).toEqual( - [0, 1, 2, 3, 4, 5, 6, 7], + [0, 1, 2, 3, 4, 5, 6, 7, 8], + ); + expect(projectKeys.indexOf("jacquie")).toBe( + projectKeys.indexOf("zentra") + 1, + ); + expect(projectKeys.indexOf("esteban")).toBe( + projectKeys.indexOf("jacquie") + 1, ); expect(projectKeys).not.toContain("lem_web"); expect(projectKeys).not.toContain("lem_portal"); @@ -258,6 +267,22 @@ describe("portfolio architecture invariants", () => { expect(homePortfolioCases.map(({ home }) => home.order)).toEqual([ 0, 1, 2, 3, ]); + expect( + new Set(homePortfolioCases.map(({ home }) => home.order)).size, + ).toBe(4); + expect(homePortfolioCases.map(({ category }) => category)).toEqual([ + "systems", + "brand", + "web", + "ecommerce", + ]); + expect( + homePortfolioCases.every( + ({ content }) => content.es.tags.length > 0 && content.en.tags.length > 0, + ), + ).toBe(true); + expect(getCase("esteban").home).toBeUndefined(); + expect(projectKeys).toContain("esteban"); }); it("publishes only the approved LEM-BOX links and claims", () => { @@ -593,6 +618,16 @@ describe("portfolio architecture invariants", () => { declaredFit: undefined, effectiveFit: "contain", }, + { + key: "jacquie", + fallback: "/img/jacquie-cover.jpg", + directory: "src/assets/portfolio/jacquie", + stem: "jacquie", + dimensions: { width: 1200, height: 630 }, + widths: [480, 768, 1200], + declaredFit: "cover", + effectiveFit: "cover", + }, { key: "esteban", fallback: "/img/esteban.png", @@ -631,7 +666,7 @@ describe("portfolio architecture invariants", () => { portfolioCases .filter(({ responsiveCover }) => responsiveCover !== undefined) .map(({ key }) => key), - ).toEqual(["lem_box", "esteban", "federico", "campings_demo"]); + ).toEqual(["lem_box", "jacquie", "esteban", "federico", "campings_demo"]); for (const contract of contracts) { const portfolioCase = getCase(contract.key); @@ -891,3 +926,245 @@ describe("portfolio architecture invariants", () => { ); }); }); + +describe("Jacquie Zárate portfolio contract", () => { + const jacquie = getCase("jacquie"); + + it("publishes exactly one approved Jacquie card without a badge", () => { + expect(portfolioCases.filter(({ key }) => key === "jacquie")).toHaveLength( + 1, + ); + expect( + JSON.stringify(portfolioCases).split("jacquiezarate.com").length - 1, + ).toBe(1); + expect(jacquie.category).toBe("web"); + expect(jacquie.content.es.status).toBeUndefined(); + expect(jacquie.content.en.status).toBeUndefined(); + expect(jacquie.content.es.disclaimer).toBeUndefined(); + expect(jacquie.content.en.disclaimer).toBeUndefined(); + expect(jacquie.content.es.role).toBeUndefined(); + expect(jacquie.content.en.role).toBeUndefined(); + }); + + it("exposes one live-site action with the approved URL and localized CTA", () => { + expect(jacquie.actions).toHaveLength(1); + expect(jacquie.actions[0]?.href).toBe("https://jacquiezarate.com"); + expect(jacquie.actions[0]?.label).toEqual({ + es: "Ver sitio web", + en: "View website", + }); + expect(jacquie.actions[0]?.note).toBeUndefined(); + }); + + it("uses the approved bilingual titles, descriptions and tags", () => { + expect(jacquie.content.es.title).toBe( + "Jacquie Zárate · Real Estate e Inversión", + ); + expect(jacquie.content.en.title).toBe( + "Jacquie Zárate · Real Estate & Investment", + ); + expect(jacquie.content.es.description).toBe( + "Sitio inmobiliario trilingüe para comprar, vender e invertir en Miami, con catálogo de preconstrucción filtrable, fichas de propiedades, guía de financiación y contacto directo por WhatsApp.", + ); + expect(jacquie.content.en.description).toBe( + "Trilingual real estate site for buying, selling, and investing in Miami, with a filterable pre-construction catalog, property pages, financing guidance, and direct WhatsApp contact.", + ); + expect(jacquie.content.es.tags).toEqual([ + "Real Estate", + "Trilingüe", + "Next.js", + ]); + expect(jacquie.content.en.tags).toEqual([ + "Real Estate", + "Trilingual", + "Next.js", + ]); + expect(jacquie.home?.summary).toEqual({ + es: "Sitio inmobiliario trilingüe con catálogo filtrable, SEO por idioma y contacto directo por WhatsApp.", + en: "Trilingual real estate site with a filterable catalog, per-language SEO, and direct WhatsApp contact.", + }); + }); + + it("expands through details instead of a case study or a new route", () => { + expect(jacquie.caseStudy).toBeUndefined(); + expect(jacquie.content.es.details).toBeDefined(); + expect(jacquie.content.en.details).toBeDefined(); + expect(jacquie.content.es.details?.stack).toEqual([ + "Next.js 15", + "React 19", + "TypeScript", + "Tailwind CSS", + "next-intl", + "ImageKit", + "Vercel", + ]); + expect(jacquie.content.en.details?.stack).toEqual( + jacquie.content.es.details?.stack, + ); + for (const language of ["es", "en"] as const) { + const details = jacquie.content[language].details; + expect(details?.challenges).toHaveLength(1); + expect(details?.solution).toHaveLength(1); + expect(details?.impact).toHaveLength(1); + expect(details?.integrations.length).toBeGreaterThan(0); + expect(details?.summary.length).toBeGreaterThan(0); + } + expect(JSON.stringify(jacquie)).not.toMatch( + /"(?:caseStudy|slug|path)":/, + ); + expect( + portfolioCases + .filter((portfolioCase) => portfolioCase.caseStudy !== undefined) + .map(({ key }) => key), + ).toEqual(["lem_box"]); + }); + + it("keeps the published copy free of unsupported claims", () => { + const serialized = JSON.stringify(jacquie); + + expect(serialized).not.toMatch( + /\bCMS\b|panel administrativo|admin panel|base de datos|database|catálogo dinámico|dynamic catalog|tiempo real|real[- ]time|\bMLS\b|\bCRM\b|API de datos|data API/i, + ); + expect(serialized).not.toMatch( + /automatizacion|automation|autenticaci|authentication|generación automática de leads|automatic lead generation/i, + ); + expect(serialized).not.toMatch( + /\bsistema\b|\bsystem\b|\bplataforma\b|\bplatform\b|métricas|metrics|conversiones|conversions|\bventas\b|\bsales\b|ranking/i, + ); + expect(serialized).not.toMatch( + /Lighthouse|First Load|WCAG|analytics|CI con tests|automated tests/i, + ); + expect(serialized).not.toMatch( + /comparaci[óo]n de proyectos|project comparison|calculadora|calculator|property management|gestión de propiedades/i, + ); + expect(serialized).not.toMatch( + /branding|fotograf|photograph|traducciones|translations|copywriting/i, + ); + expect(serialized).not.toMatch(/\d+\s*(?:proyectos|propiedades|projects|properties)/i); + expect(serialized).not.toMatch(/Esteban|Firpo|estebanfirpo/i); + }); + + it("derives every Jacquie asset from the approved Open Graph master", () => { + // Byte contract for the cover set generated from the published social image + // (jacquie-zarate-og-2026.jpg, 1200x630). Reverting to the discarded + // /es/proyectos filters capture changes these digests and fails here. + const openGraphDerivedAssets = { + "public/img/jacquie-cover.jpg": + "c0266bb186b237fd9422154bccc259968c4ab0ae910343a95c1eb8d91e246304", + "src/assets/portfolio/jacquie/jacquie-480.avif": + "9462416577ff55bcd88561cf3b53d883443acd38b06c13ee8272a47d242609ba", + "src/assets/portfolio/jacquie/jacquie-768.avif": + "3ad5f8d5e3184d16f27898c01eae1d8fc01921f16c7e035bad4938cb14642abb", + "src/assets/portfolio/jacquie/jacquie-1200.avif": + "3f7566693669822e4dcb578367b8d628acefaec4704423ddfd1b226895b05e74", + "src/assets/portfolio/jacquie/jacquie-480.webp": + "4c540a71b6a2f87ea0ba9cead84a39f8e90eba471fad8d49d9bd6b31ab90cfa9", + "src/assets/portfolio/jacquie/jacquie-768.webp": + "549061aacae96393e461bb18b53fff4678950a152910ed7e7d89b7d26ac5cdd7", + "src/assets/portfolio/jacquie/jacquie-1200.webp": + "50cad8ac54f9c4240e76a8d27adf4bae08b111af1d63d4ebe837a507f71cac5c", + } as const; + // Digests of the superseded /es/proyectos capture, kept as a negative guard. + const discardedCaptureDigests = new Set([ + "bd13bf14973209c4fd9594307242498d6052c812497d2d6c44d0ffd673c0b424", + "7d7acd362a22933c587b7e44b44ba2f19350b733a5ef09a61f048a3b0decd78f", + "2838ad42087144541395497dd91526d4569b6db5f2ea650d76d1422f043b7eb2", + "971ca044ffea5e8d30d387deb7093debd996f3e3d80ee8e319563a9d9ebda454", + "edcd0855b62ba802c2b4b321cd8b88d7ee78fa0251ef88b17fa24df4f396724c", + "a8c8de84caa40d6767ea5df8bdfcbdac94a78baaa1d12fb24b901e44ac9f4daf", + "7dbc9426da30e1e7bee791095afb104e7073410fb4af2caef95dda846cd88d38", + ]); + + for (const [file, expectedDigest] of Object.entries( + openGraphDerivedAssets, + )) { + const assetPath = path.join(projectRoot, file); + expect(fs.existsSync(assetPath)).toBe(true); + const digest = crypto + .createHash("sha256") + .update(fs.readFileSync(assetPath)) + .digest("hex"); + expect(digest, file).toBe(expectedDigest); + expect(discardedCaptureDigests.has(digest), file).toBe(false); + } + + // No orphans left behind by the discarded cover. + expect( + fs.readdirSync(path.join(projectRoot, "src/assets/portfolio/jacquie")).sort(), + ).toEqual([ + "jacquie-1200.avif", + "jacquie-1200.webp", + "jacquie-480.avif", + "jacquie-480.webp", + "jacquie-768.avif", + "jacquie-768.webp", + ]); + expect( + fs + .readdirSync(path.join(projectRoot, "public/img")) + .filter((file) => file.startsWith("jacquie")), + ).toEqual(["jacquie-cover.jpg"]); + }); + + it("ships the full responsive cover set with a localized alt", () => { + const responsiveCover = jacquie.responsiveCover; + if (responsiveCover === undefined) { + throw new Error("Missing Jacquie responsive cover"); + } + + expect(jacquie.cover).toBe("/img/jacquie-cover.jpg"); + expect(responsiveCover.fit).toBe("cover"); + expect(getPortfolioCoverFit(responsiveCover)).toBe("cover"); + expect({ + width: responsiveCover.width, + height: responsiveCover.height, + }).toEqual({ width: 1200, height: 630 }); + + const coverPath = path.join(projectRoot, "public/img/jacquie-cover.jpg"); + expect(fs.existsSync(coverPath)).toBe(true); + expect(readJpegDimensions(fs.readFileSync(coverPath))).toEqual({ + width: 1200, + height: 630, + }); + expect(fs.statSync(coverPath).size).toBeLessThanOrEqual(300_000); + + const seenSources = new Set(); + for (const format of ["avif", "webp"] as const) { + const candidates = responsiveCover.sources[format]; + expect(candidates.map(({ width }) => width)).toEqual([480, 768, 1200]); + + for (const candidate of candidates) { + const fileName = `jacquie-${candidate.width}.${format}`; + expect(candidate.src).toContain(fileName); + expect(seenSources.has(candidate.src)).toBe(false); + seenSources.add(candidate.src); + + const assetPath = path.join( + projectRoot, + "src/assets/portfolio/jacquie", + fileName, + ); + expect(fs.existsSync(assetPath)).toBe(true); + expect(fs.statSync(assetPath).size).toBeGreaterThan(0); + } + } + expect(seenSources.size).toBe(6); + + expect(jacquie.coverAlt).toEqual({ + es: "Portada del sitio de Jacquie Zárate, con su retrato editorial junto al monograma JZ, su nombre y la referencia Realtor en Florida.", + en: "Jacquie Zárate website cover, featuring her editorial portrait next to the JZ monogram, her name, and the Realtor in Florida credential.", + }); + // The alt describes the Open Graph cover, not the discarded filters capture. + for (const alt of Object.values(jacquie.coverAlt ?? {})) { + expect(alt).not.toMatch( + /panel de búsqueda|filtros|catálogo de preconstrucción y el|search and filters|catalog headline/i, + ); + expect(alt).toMatch(/retrato editorial|editorial portrait/); + } + expect( + portfolioCases + .filter(({ coverAlt }) => coverAlt !== undefined) + .map(({ key }) => key), + ).toEqual(["jacquie"]); + }); +}); diff --git a/src/test/Routing.test.tsx b/src/test/Routing.test.tsx index a2e20d6..eeee4e1 100644 --- a/src/test/Routing.test.tsx +++ b/src/test/Routing.test.tsx @@ -17,6 +17,13 @@ type TestRouteEntry = const expandablePortfolioCases = [ { key: "zentra", title: { es: "ZENTRA", en: "ZENTRA" } }, + { + key: "jacquie", + title: { + es: "Jacquie Zárate · Real Estate e Inversión", + en: "Jacquie Zárate · Real Estate & Investment", + }, + }, { key: "esteban", title: { @@ -415,28 +422,28 @@ describe("application routing", () => { (source) => source.getAttribute("type"), ), ).toEqual(["image/avif", "image/webp"]); - const estebanHomeLink = screen.getByRole("link", { - name: "Ver este caso en el portfolio: Esteban Firpo · Miami Real Estate", + const jacquieHomeLink = screen.getByRole("link", { + name: "Ver este caso en el portfolio: Jacquie Zárate · Real Estate e Inversión", }); - const estebanHomePicture = estebanHomeLink.querySelector("picture"); - const estebanHomeImage = estebanHomePicture?.querySelector("img"); - expect(estebanHomePicture).not.toBeNull(); - expect(estebanHomePicture?.querySelectorAll("img")).toHaveLength(1); - expect(estebanHomeImage).toHaveAttribute("src", "/img/esteban.png"); - expect(estebanHomeImage).toHaveAttribute("width", "1200"); - expect(estebanHomeImage).toHaveAttribute("height", "630"); - expect(estebanHomeImage).toHaveAttribute("loading", "lazy"); - expect(estebanHomeImage).toHaveAttribute("decoding", "async"); - expect(estebanHomeImage).not.toHaveAttribute("fetchpriority"); - expect(estebanHomeImage).toHaveClass("object-cover"); - const estebanHomeSources = Array.from( - estebanHomePicture?.querySelectorAll("source") ?? [], + const jacquieHomePicture = jacquieHomeLink.querySelector("picture"); + const jacquieHomeImage = jacquieHomePicture?.querySelector("img"); + expect(jacquieHomePicture).not.toBeNull(); + expect(jacquieHomePicture?.querySelectorAll("img")).toHaveLength(1); + expect(jacquieHomeImage).toHaveAttribute("src", "/img/jacquie-cover.jpg"); + expect(jacquieHomeImage).toHaveAttribute("width", "1200"); + expect(jacquieHomeImage).toHaveAttribute("height", "630"); + expect(jacquieHomeImage).toHaveAttribute("loading", "lazy"); + expect(jacquieHomeImage).toHaveAttribute("decoding", "async"); + expect(jacquieHomeImage).not.toHaveAttribute("fetchpriority"); + expect(jacquieHomeImage).toHaveClass("object-cover"); + const jacquieHomeSources = Array.from( + jacquieHomePicture?.querySelectorAll("source") ?? [], ); - expect(estebanHomeSources.map((source) => source.type)).toEqual([ + expect(jacquieHomeSources.map((source) => source.type)).toEqual([ "image/avif", "image/webp", ]); - for (const source of estebanHomeSources) { + for (const source of jacquieHomeSources) { expect(responsiveWidths(source)).toEqual([480, 768, 1200]); } expect( @@ -448,9 +455,14 @@ describe("application routing", () => { .map((link) => link.getAttribute("aria-label")), ).toEqual([ "Ver este caso en el portfolio: ZENTRA", - "Ver este caso en el portfolio: Esteban Firpo · Miami Real Estate", + "Ver este caso en el portfolio: Jacquie Zárate · Real Estate e Inversión", "Ver este caso en el portfolio: Mutter Games", ]); + expect( + screen.queryByRole("link", { + name: "Ver este caso en el portfolio: Esteban Firpo · Miami Real Estate", + }), + ).not.toBeInTheDocument(); }); it("filters the central portfolio catalog", async () => { @@ -504,7 +516,7 @@ describe("application routing", () => { "button[data-nojs-hide][aria-expanded]", ), ); - expect(detailToggles).toHaveLength(7); + expect(detailToggles).toHaveLength(8); for (const button of detailToggles) { expect(button).toHaveAttribute("data-nojs-hide", "true"); expect(button).toHaveAttribute("aria-expanded", "false"); @@ -515,8 +527,8 @@ describe("application routing", () => { const cards = Array.from( portfolio.querySelectorAll('[id^="portfolio-case-"]'), ); - expect(cards).toHaveLength(8); - expect(portfolio.querySelectorAll("[data-nojs-hide]")).toHaveLength(8); + expect(cards).toHaveLength(9); + expect(portfolio.querySelectorAll("[data-nojs-hide]")).toHaveLength(9); for (const card of cards) { expect(card).not.toHaveAttribute("data-nojs-hide"); } @@ -595,7 +607,7 @@ describe("application routing", () => { } return image; }); - expect(portfolioImages).toHaveLength(8); + expect(portfolioImages).toHaveLength(9); expect(portfolioImages[0]).toHaveAttribute("loading", "eager"); expect(portfolioImages[0]).toHaveAttribute("fetchpriority", "high"); expect(portfolioImages[0]).toHaveClass("object-contain"); @@ -609,6 +621,12 @@ describe("application routing", () => { } const responsiveContracts = [ + { + key: "jacquie", + fallback: "/img/jacquie-cover.jpg", + dimensions: [1200, 630], + widths: [480, 768, 1200], + }, { key: "esteban", fallback: "/img/esteban.png", @@ -657,7 +675,7 @@ describe("application routing", () => { expect( document.querySelectorAll('[id^="portfolio-case-"] picture'), - ).toHaveLength(4); + ).toHaveLength(5); }); it.each([ @@ -674,7 +692,7 @@ describe("application routing", () => { path: "/en/portfolio", }, ] as const)( - "keeps all seven $language Portfolio controls aligned with rendered panels", + "keeps all eight $language Portfolio controls aligned with rendered panels", async ({ collapseLabel, expandLabel, language, path }) => { const user = userEvent.setup(); renderApp(path); diff --git a/src/test/Semantics.test.tsx b/src/test/Semantics.test.tsx index 5faa7af..828537c 100644 --- a/src/test/Semantics.test.tsx +++ b/src/test/Semantics.test.tsx @@ -431,7 +431,7 @@ describe("headings, controls and image semantics", () => { homeRender.unmount(); renderApp("/portfolio"); const caseHeadings = await screen.findAllByRole("heading", { level: 2 }); - expect(caseHeadings).toHaveLength(8); + expect(caseHeadings).toHaveLength(9); const zentraHeading = screen.getByRole("heading", { level: 2, name: "ZENTRA" }); const zentraCard = zentraHeading.closest("[id^='portfolio-case-']"); if (!(zentraCard instanceof HTMLElement)) throw new Error("Missing ZENTRA card"); @@ -470,12 +470,12 @@ describe("headings, controls and image semantics", () => { } }); - it("keeps unique Portfolio names, visible copy, focus and all eight cases", async () => { + it("keeps unique Portfolio names, visible copy, focus and all nine cases", async () => { const user = userEvent.setup(); renderApp("/portfolio"); await screen.findByRole("heading", { level: 1, name: "Algunos trabajos" }); - expect(document.querySelectorAll("[id^='portfolio-case-']")).toHaveLength(8); + expect(document.querySelectorAll("[id^='portfolio-case-']")).toHaveLength(9); expect( screen.getByRole("link", { name: "Ver sitio web: Esteban Firpo · Miami Real Estate", diff --git a/src/test/ServiceCopyRevision.test.ts b/src/test/ServiceCopyRevision.test.ts index 1f2514f..5232142 100644 --- a/src/test/ServiceCopyRevision.test.ts +++ b/src/test/ServiceCopyRevision.test.ts @@ -47,24 +47,112 @@ describe("SEO-COM-01 copy revision contract", () => { ); }); - it("keeps the four featured website cases with ZENTRA replacing Boating", () => { + it("lists exactly six website proofs in the approved order", () => { const esItems = translations.es.servicesPages.web.cases.items; const enItems = translations.en.servicesPages.web.cases.items; + expect(esItems).toHaveLength(6); + expect(enItems).toHaveLength(6); expect(esItems.map(({ name }) => name)).toEqual([ - "Esteban Firpo · Miami Real Estate", + "Jacquie Zárate · Real Estate e Inversión", "Mutter Games", + "Esteban Firpo · Miami Real Estate", "Imprenta Magenta", "ZENTRA Scent", + "Federico Roma", ]); expect(enItems.map(({ name }) => name)).toEqual([ - "Esteban Firpo · Miami Real Estate", + "Jacquie Zárate · Real Estate & Investment", "Mutter Games", + "Esteban Firpo · Miami Real Estate", "Imprenta Magenta", "ZENTRA Scent", + "Federico Roma", ]); - expect(esItems.some(({ name }) => name.includes("Boating"))).toBe(false); - expect(enItems.some(({ name }) => name.includes("Boating"))).toBe(false); + + for (const items of [esItems, enItems]) { + expect(new Set(items.map(({ name }) => name)).size).toBe(6); + expect(items.filter(({ name }) => name.includes("Jacquie"))).toHaveLength( + 1, + ); + expect(items.some(({ name }) => name.includes("Esteban"))).toBe(true); + expect(items.some(({ name }) => name.includes("Federico"))).toBe(true); + expect(items.some(({ name }) => name.includes("Boating"))).toBe(false); + expect(items.every(({ text }) => text.trim().length > 0)).toBe(true); + } + }); + + it("uses the revised intro while keeping the approved heading", () => { + expect(translations.es.servicesPages.web.cases.title).toBe("Sitios reales"); + expect(translations.en.servicesPages.web.cases.title).toBe("Real websites"); + expect(translations.es.servicesPages.web.cases.intro).toBe( + "Algunos de los sitios que diseñé y desarrollé para empresas y marcas personales:", + ); + expect(translations.en.servicesPages.web.cases.intro).toBe( + "Some of the websites I designed and developed for businesses and personal brands:", + ); + }); + + it("reuses the existing copy for every non-Jacquie website proof", () => { + const esItems = translations.es.servicesPages.web.cases.items; + const enItems = translations.en.servicesPages.web.cases.items; + const esText = (name: string) => + esItems.find((item) => item.name === name)?.text; + const enText = (name: string) => + enItems.find((item) => item.name === name)?.text; + + expect(esText("Jacquie Zárate · Real Estate e Inversión")).toBe( + "Sitio inmobiliario trilingüe con catálogo de preconstrucción filtrable, SEO por idioma y contacto directo por WhatsApp.", + ); + expect(enText("Jacquie Zárate · Real Estate & Investment")).toBe( + "Trilingual real estate site with a filterable pre-construction catalog, per-language SEO, and direct WhatsApp contact.", + ); + expect(esText("Mutter Games")).toBe( + "E-commerce con catálogo dinámico y checkout con Mercado Pago.", + ); + expect(enText("Mutter Games")).toBe( + "E-commerce with a dynamic catalog and Mercado Pago checkout.", + ); + expect(esText("Esteban Firpo · Miami Real Estate")).toBe( + "Sitio inmobiliario bilingüe con catálogo de proyectos e integración con WhatsApp.", + ); + expect(enText("Esteban Firpo · Miami Real Estate")).toBe( + "Bilingual real-estate site with a project catalog and WhatsApp integration.", + ); + expect(esText("ZENTRA Scent")).toBe( + "Proyecto en desarrollo: sitio web y e-commerce con suscripciones, panel administrativo y gestión de stock.", + ); + expect(enText("ZENTRA Scent")).toBe( + "In development: website and e-commerce with subscriptions, an admin panel, and inventory management.", + ); + }); + + it("describes Federico only with his canonical Portfolio content", () => { + const esFederico = translations.es.servicesPages.web.cases.items.find( + ({ name }) => name === "Federico Roma", + ); + const enFederico = translations.en.servicesPages.web.cases.items.find( + ({ name }) => name === "Federico Roma", + ); + + expect(esFederico?.text).toBe( + "Sitio web personal y profesional con biografía, cursos en video, fotografías y productos exclusivos.", + ); + expect(enFederico?.text).toBe( + "Personal and professional website with biography, video courses, photography, and exclusive products.", + ); + // Every claim is already carried by the canonical Portfolio entry. + for (const [summary, canonical] of [ + [esFederico?.text ?? "", translations.es.portfolio.federico.desc], + [enFederico?.text ?? "", translations.en.portfolio.federico.desc], + ] as const) { + expect(canonical).toMatch(/biograf|biography/i); + expect(canonical).toMatch(/cursos en video|video courses/i); + expect(canonical).toMatch(/productos exclusivos|exclusive products/i); + expect(summary).not.toMatch( + /\d|campe[óo]n|champion|ranking|ventas|sales|conversi/i, + ); + } }); it("preserves Magenta's existing factual qualifier", () => {