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", () => {