diff --git a/app/cases/[slug]/opengraph-image.paths.test.tsx b/app/cases/[slug]/opengraph-image.paths.test.tsx index e5c690c..483ef23 100644 --- a/app/cases/[slug]/opengraph-image.paths.test.tsx +++ b/app/cases/[slug]/opengraph-image.paths.test.tsx @@ -132,7 +132,7 @@ describe("opengraph-image Image", () => { expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id)); expect( all.filter(({ contentType }) => contentType === "image/jpeg"), - ).toHaveLength(5); + ).toHaveLength(6); }); it("path: unknown slug -> notFound() and no image", async () => { diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx index 6ae92cb..135e2eb 100644 --- a/app/cases/[slug]/page.test.tsx +++ b/app/cases/[slug]/page.test.tsx @@ -20,6 +20,7 @@ const clientCaseIds = [ "lavacao-047", "sc-plus", "as-servicos", + "julia-beauty", ]; const labCaseIds = [ "nutriscan", @@ -86,15 +87,21 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) { } } -it("exposes the eight public cases, the two new client cases among them", async () => { +it("exposes the nine public cases, the new client cases among them", async () => { const staticSlugs = generateStaticParams().map(({ slug }) => slug); - expect(staticSlugs).toHaveLength(8); - expect(new Set(staticSlugs).size).toBe(8); + expect(staticSlugs).toHaveLength(9); + expect(new Set(staticSlugs).size).toBe(9); expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort()); expect(staticSlugs).toContain("sev-modaintima"); expect(staticSlugs).toContain("lavacao-047"); + expect(staticSlugs).toContain("julia-beauty"); - for (const id of ["sev-modaintima", "lavacao-047", "as-servicos"]) { + for (const id of [ + "sev-modaintima", + "lavacao-047", + "as-servicos", + "julia-beauty", + ]) { const caseStudy = getCase(id); const url = `${siteUrl}/cases/${caseStudy.id}`; const { metaTitle: title, metaDescription: description } = caseStudy; @@ -680,6 +687,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc "lavacao-047", "sc-plus", "as-servicos", + "julia-beauty", ]); const clientLinks = listedIds.map((id) => { const card = container diff --git a/app/cases/page.paths.test.tsx b/app/cases/page.paths.test.tsx index 6d0db9c..2349379 100644 --- a/app/cases/page.paths.test.tsx +++ b/app/cases/page.paths.test.tsx @@ -77,7 +77,7 @@ describe("CasesPage: cases de cliente", () => { ).toBeNull(); }); - it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus and AS Serviços", () => { + it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => { const { container } = render(); expect( @@ -94,6 +94,7 @@ describe("CasesPage: cases de cliente", () => { "Lavação 047", "SC Plus Aquicultura", "AS Serviços", + "Julia Beauty", ]); // um botão de contato só na página: o do fecho expect( diff --git a/app/components/showcase/ProjectLightbox.test.tsx b/app/components/showcase/ProjectLightbox.test.tsx index e1d63db..28ccd43 100644 --- a/app/components/showcase/ProjectLightbox.test.tsx +++ b/app/components/showcase/ProjectLightbox.test.tsx @@ -132,7 +132,7 @@ describe("ProjectLightbox", () => { expect( within(dialog).getByRole("button", { name: "Fechar" }), ).toHaveFocus(); - expect(within(dialog).getByText("02 / 05")).toBeInTheDocument(); + expect(within(dialog).getByText("02 / 06")).toBeInTheDocument(); }); it("shows the desktop video in a browser frame by default and the phone version on request", () => { @@ -490,7 +490,7 @@ describe("ProjectLightbox: revisão", () => { it("announces which project is on screen", () => { const dialog = setup("nutriscan"); expect(dialog.querySelector("[aria-live='polite']")).toHaveTextContent( - "NutriScan, projeto 3 de 5", + "NutriScan, projeto 3 de 6", ); }); diff --git a/app/components/showcase/ProjectReel.paths.test.tsx b/app/components/showcase/ProjectReel.paths.test.tsx index 983226e..648bea6 100644 --- a/app/components/showcase/ProjectReel.paths.test.tsx +++ b/app/components/showcase/ProjectReel.paths.test.tsx @@ -84,6 +84,7 @@ const WEIGHTS = [ "nutriscan", "sc-plus", "as-servicos", + "julia-beauty", ].map((id) => getShowcaseConfig(id)?.weight ?? 1); const originalRect = Element.prototype.getBoundingClientRect; @@ -272,7 +273,7 @@ describe("ProjectReel: qual versão entra", () => { expect(reelEl()).toBeNull(); const list = listEl() as HTMLElement; expect(list).not.toBeNull(); - expect(list.querySelectorAll("li[data-device='desktop']")).toHaveLength(5); + expect(list.querySelectorAll("li[data-device='desktop']")).toHaveLength(6); expect(list.querySelector(".ds-phone")).toBeNull(); expect(list).not.toHaveAttribute("data-pending"); }); @@ -285,7 +286,7 @@ describe("ProjectReel: qual versão entra", () => { expect(reelEl()).toBeNull(); expect( (listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"), - ).toHaveLength(5); + ).toHaveLength(6); }); it("path: phone width and reduced motion at once -> still the list", () => { @@ -330,13 +331,13 @@ describe("ProjectReel: qual versão entra", () => { renderReel(); expect( (listEl() as HTMLElement).querySelectorAll("li[data-device='desktop']"), - ).toHaveLength(5); + ).toHaveLength(6); changeMedia(MOBILE_QUERY, true); expect( (listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"), - ).toHaveLength(5); + ).toHaveLength(6); }); }); @@ -780,7 +781,7 @@ describe("ProjectReel: rolagem suave até um projeto", () => { ([options]) => options.top as number, ); const finalTop = tops[tops.length - 1]; - // o destino da Lavação (2º projeto) fica bem antes do da AS Serviços (último) + // o destino da Lavação (2º projeto) fica bem antes do da AS Serviços (5º) expect(finalTop).toBeLessThan(reelTopFor(4)); }); diff --git a/app/components/showcase/ProjectReel.test.tsx b/app/components/showcase/ProjectReel.test.tsx index 1bcf1fc..e40f800 100644 --- a/app/components/showcase/ProjectReel.test.tsx +++ b/app/components/showcase/ProjectReel.test.tsx @@ -56,6 +56,7 @@ const weights = [ "nutriscan", "sc-plus", "as-servicos", + "julia-beauty", ].map((id) => getShowcaseConfig(id)?.weight ?? 1); const originalRect = Element.prototype.getBoundingClientRect; @@ -210,7 +211,7 @@ describe("ProjectReel", () => { ).toBeNull(); expect(document.querySelector(".ds-reel")).toBeNull(); const list = screen.getByRole("region", { name: "Projetos de clientes" }); - expect(within(list).getAllByRole("heading", { level: 3 })).toHaveLength(5); + expect(within(list).getAllByRole("heading", { level: 3 })).toHaveLength(6); expect(document.querySelector("video")).toBeNull(); }); @@ -231,7 +232,7 @@ describe("ProjectReel", () => { ); expect(posters.length).toBeGreaterThan(0); for (const poster of posters) expect(poster).toContain("mobile.webp"); - expect(list.querySelectorAll(".ds-phone")).toHaveLength(5); + expect(list.querySelectorAll(".ds-phone")).toHaveLength(6); expect(list.querySelector(".ds-bframe")).toBeNull(); }); @@ -258,6 +259,7 @@ describe("ProjectReel", () => { "NutriScan", "SC Plus Aquicultura", "AS Serviços", + "Julia Beauty", ]) expect(within(nav).getByRole("button", { name })).toBeInTheDocument(); expect( @@ -272,7 +274,7 @@ describe("ProjectReel", () => { it("hides the layers that are not on screen from assistive technology and from the keyboard", () => { renderReel(); const layers = [...document.querySelectorAll(".ds-reel-layer")]; - expect(layers).toHaveLength(5); + expect(layers).toHaveLength(6); for (const layer of layers.slice(1)) { expect(layer).toHaveAttribute("aria-hidden", "true"); expect(layer).toHaveAttribute("inert"); @@ -338,7 +340,7 @@ describe("ProjectReel", () => { expect(first.currentTime).toBe(0); scrollTo(1); - expect(caption()).toHaveTextContent("AS Serviços"); + expect(caption()).toHaveTextContent("Julia Beauty"); const last = activeLayer().querySelector( ".ds-reel-screen video", ) as HTMLVideoElement; @@ -381,7 +383,7 @@ describe("ProjectReel", () => { }); loaded = sources().filter(Boolean); expect( - loaded.some((src) => src?.includes("as-servicos/scrub-desktop.mp4")), + loaded.some((src) => src?.includes("julia-beauty/scrub-desktop.mp4")), ).toBe(true); expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false); }); @@ -558,8 +560,8 @@ describe("ProjectReel", () => { ).toHaveClass("ds-link-muted"); expect(document.querySelector(".ds-reel .ds-link-accent")).toBeNull(); - // sem demoUrl não há "Abrir site" - scrollTo(1); + // sem demoUrl não há "Abrir site" (AS Serviços, 5º projeto) + scrollTo(reelOffset(weights, 4, 0.5)); expect(caption()).toHaveTextContent("AS Serviços"); expect( within(caption()).queryByRole("link", { name: /Abrir site/ }), @@ -635,7 +637,7 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => { expect(document.querySelectorAll(".ds-reel-title")).toHaveLength(1); expect(document.querySelectorAll(".ds-reel-chapter-count")).toHaveLength(1); expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1); - expect(document.querySelectorAll(".ds-reel-layer")).toHaveLength(5); + expect(document.querySelectorAll(".ds-reel-layer")).toHaveLength(6); for (const layer of document.querySelectorAll(".ds-reel-layer")) { expect(layer.closest(".ds-reel-caption")).toBeNull(); expect(layer.querySelector(".ds-reel-caption")).toBeNull(); @@ -782,8 +784,8 @@ describe("ProjectReel: teclado do índice (roving tabindex)", () => { it("makes the whole index ONE tab stop (the current project), however many projects and chapters it lists", () => { renderReel(); const buttons = within(index()).getAllByRole("button"); - // 5 projetos e os 2 trechos do projeto em cena: 7 botões, uma parada - expect(buttons).toHaveLength(5 + 2); + // 6 projetos e os 2 trechos do projeto em cena: 8 botões, uma parada + expect(buttons).toHaveLength(6 + 2); expect(stops()).toHaveLength(1); expect(stops()[0]).toHaveAttribute("aria-current", "true"); expect(stops()[0]).toHaveAccessibleName("SEV Moda Íntima"); @@ -895,7 +897,7 @@ describe("ProjectReel: teclado do índice (roving tabindex)", () => { fireEvent.keyDown(document.activeElement as Element, { key: "End" }); expect(document.activeElement).toHaveAccessibleName(/Abrir site/); - scrollTo(1); // AS Serviços: sem site + scrollTo(reelOffset(weights, 4, 0.5)); // AS Serviços: sem site act(() => { jest.advanceTimersByTime(CAPTION_OUT_MS + 40); }); diff --git a/app/components/showcase/ShowcaseProvider.test.tsx b/app/components/showcase/ShowcaseProvider.test.tsx index b14247a..2956616 100644 --- a/app/components/showcase/ShowcaseProvider.test.tsx +++ b/app/components/showcase/ShowcaseProvider.test.tsx @@ -60,6 +60,7 @@ describe("buildShowcaseItems", () => { "nutriscan", "sc-plus", "as-servicos", + "julia-beauty", ]); expect( buildShowcaseItems(projects.filter((p) => p.id === "twins-lab")), diff --git a/e2e/client-strip.spec.ts b/e2e/client-strip.spec.ts index 62d0357..7183f23 100644 --- a/e2e/client-strip.spec.ts +++ b/e2e/client-strip.spec.ts @@ -49,7 +49,7 @@ async function expectDotsOnlyBetweenItemsOfOneLine(page: Page, where: string) { expect(strip, `${where}: a faixa 'Já entregue para:' no hero`).not.toBeNull(); if (!strip) return 0; const { items, listLeft, listRight, overflowX } = strip; - // 4 clientes + os anos de experiência + // 5 clientes + os anos de experiência expect(items.length, `${where}: itens da faixa`).toBeGreaterThanOrEqual( clientProjectNames().length, ); diff --git a/e2e/keyboard.spec.ts b/e2e/keyboard.spec.ts index c065389..568a936 100644 --- a/e2e/keyboard.spec.ts +++ b/e2e/keyboard.spec.ts @@ -21,10 +21,10 @@ const CTA_LABEL = "Falar sobre meu projeto"; const SHORTCUT_MAX_TABS_TO_CTA = 20; const SHORTCUT_MAX_TABS_AFTER_ENTER = 4; /** - * Limite do caminho linear: 36 toques. Inventário das paradas legítimas, do topo da home ao campo de nome (35 hoje): + * Limite do caminho linear: 36 toques. Inventário das paradas legítimas, do topo da home ao campo de nome (36 hoje): * 1 link "Pular para o conteúdo"; * cabeçalho: logo, 5 links (Cases, Serviços, Como trabalho, Sobre, IA) e "Falar sobre meu projeto" (7); - * hero: "Falar sobre meu projeto", "Ver projetos entregues", os 4 clientes da faixa, LinkedIn e e-mail (8); + * hero: "Falar sobre meu projeto", "Ver projetos entregues", os 5 clientes da faixa, LinkedIn e e-mail (9); * vitrine de projetos: o seletor, o link do case do projeto aberto e "Ver os cases" (3); * ofertas: 3 botões e 3 links de case/página, mais "Ver todos os serviços" (7); * "Ver trajetória completa" (1) e as 5 perguntas da FAQ (5); diff --git a/e2e/no-js.spec.ts b/e2e/no-js.spec.ts index d06a5b0..26ce9cd 100644 --- a/e2e/no-js.spec.ts +++ b/e2e/no-js.spec.ts @@ -38,11 +38,11 @@ async function expectReadable(locator: Locator, what: string) { .toBeGreaterThan(MIN_OPACITY); } -test("sem JavaScript a home mostra o contato e os nomes dos 4 clientes, legíveis", async ({ +test("sem JavaScript a home mostra o contato e os nomes dos 5 clientes, legíveis", async ({ page, }) => { const clients = clientProjectNames(); - expect(clients, "clientes em public/portfolio-data.json").toHaveLength(4); + expect(clients, "clientes em public/portfolio-data.json").toHaveLength(5); const response = await page.goto("/", { waitUntil: "load" }); expect(response?.status()).toBe(200); diff --git a/lib/portfolio-data.content.test.ts b/lib/portfolio-data.content.test.ts index f7c6c93..01f9709 100644 --- a/lib/portfolio-data.content.test.ts +++ b/lib/portfolio-data.content.test.ts @@ -21,6 +21,7 @@ describe("public/portfolio-data.json", () => { ["sc-plus", "cliente"], ["gcc-installer", "laboratorio"], ["as-servicos", "cliente"], + ["julia-beauty", "cliente"], ]); }); diff --git a/lib/project-showcase.test.ts b/lib/project-showcase.test.ts index cfbd51d..161eddc 100644 --- a/lib/project-showcase.test.ts +++ b/lib/project-showcase.test.ts @@ -20,7 +20,13 @@ import { const MAX_BYTES = 3 * 1024 * 1024; const publicFile = (path: string) => join(process.cwd(), "public", path); /** Os projetos de cliente que a home entrega à vitrine. */ -const HOME_IDS = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"]; +const HOME_IDS = [ + "sev-modaintima", + "lavacao-047", + "sc-plus", + "as-servicos", + "julia-beauty", +]; describe("showcase length", () => { it.each(SHOWCASE)("$id has at most two chapters", (config) => { @@ -35,7 +41,7 @@ describe("showcase length", () => { ); }); - it("keeps the whole Projects section within about three and a half screens", () => { + it("keeps the whole Projects section within about four screens", () => { // o casco da vitrine tem (pin: 1 tela menos o cabeçalho) + (soma dos pesos) telas; o título e o botão de Projetos // somam ~468 px (0,52 tela de 900 px) fora dela, e o cabeçalho fixo tem 68 px const SCREEN = 900; @@ -48,7 +54,7 @@ describe("showcase length", () => { true, ); const section = SCREEN - HEADER + total * SCREEN + OUTSIDE; - expect(section / SCREEN).toBeLessThanOrEqual(3.5); + expect(section / SCREEN).toBeLessThanOrEqual(4); expect(total).toBeGreaterThan(1.5); }); diff --git a/lib/project-showcase.ts b/lib/project-showcase.ts index df94965..d501502 100644 --- a/lib/project-showcase.ts +++ b/lib/project-showcase.ts @@ -38,9 +38,9 @@ export const MAX_CHAPTERS = 2; * dos próprios vídeos, no máximo MAX_CHAPTERS por projeto, com os tempos originais: a abertura e o trecho principal, * o par que deixa menos segundos de vídeo sob o rótulo do outro (o último trecho vai até o fim do vídeo). * - * O peso é o mesmo em todos, meia altura de tela por projeto, um quarto de tela por capítulo: os quatro projetos de - * cliente da home (sev-modaintima, lavacao-047, sc-plus e as-servicos) prendem 2 telas de rolagem e a seção Projetos - * inteira, com o título e o botão, fica em cerca de 3,4 telas (eram 4,9). Dentro do projeto a rolagem também é + * O peso é o mesmo em todos, meia altura de tela por projeto, um quarto de tela por capítulo: os cinco projetos de + * cliente da home (sev-modaintima, lavacao-047, sc-plus, as-servicos e julia-beauty) prendem 2,5 telas de rolagem e + * a seção Projetos inteira, com o título e o botão, fica em cerca de 3,9 telas. Dentro do projeto a rolagem também é * repartida por igual entre os capítulos (ver `videoFractionAtScroll`), qualquer que seja a duração deles no vídeo. */ export const SHOWCASE: readonly ShowcaseConfig[] = [ @@ -95,6 +95,16 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [ { label: "Contato", desktop: 2.4, mobile: 4.4 }, ], }, + { + id: "julia-beauty", + accent: "#c6f19d", + durations: { desktop: 23.1, mobile: 24.97 }, + weight: 0.5, + chapters: [ + { label: "Abertura", desktop: 0, mobile: 0 }, + { label: "Simulador de efeito", desktop: 2.5, mobile: 3.5 }, + ], + }, ]; export const PROJECT_VIDEO_IDS = SHOWCASE.map((config) => config.id); diff --git a/lib/seo.test.ts b/lib/seo.test.ts index 1895cea..7e4dfb2 100644 --- a/lib/seo.test.ts +++ b/lib/seo.test.ts @@ -246,7 +246,7 @@ it("keeps profile data timeless and sitemap output deterministic", () => { expect(first.map(({ url }) => url)).toEqual(expectedUrls); expect(second).toEqual(first); - expect(first).toHaveLength(20); + expect(first).toHaveLength(21); expect( first.filter(({ url }) => url === `${siteUrl}/privacidade`), ).toHaveLength(1); diff --git a/lib/site-content.listing.paths.test.ts b/lib/site-content.listing.paths.test.ts index ce4b5f4..3a4c0db 100644 --- a/lib/site-content.listing.paths.test.ts +++ b/lib/site-content.listing.paths.test.ts @@ -4,10 +4,16 @@ * * listingRank (CC 2): o id está em `clientListingOrder` (a posição dele) | não está (o fim, depois de todos). O * `sort` é estável: os que não estão na lista ficam na ordem em que `caseStudies` os traz. - * Os dados reais só têm os quatro clientes da lista; o caminho do cliente fora da lista só existe aqui. + * Os dados reais só têm os cinco clientes da lista; o caminho do cliente fora da lista só existe aqui. */ -const LISTED = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"]; +const LISTED = [ + "sev-modaintima", + "lavacao-047", + "sc-plus", + "as-servicos", + "julia-beauty", +]; function loadWithClients(clientIds: readonly string[] | "all") { let loaded: typeof import("./site-content") | undefined; diff --git a/lib/site-content.test.ts b/lib/site-content.test.ts index ea04858..9fb49e7 100644 --- a/lib/site-content.test.ts +++ b/lib/site-content.test.ts @@ -17,7 +17,13 @@ import { servicePages, } from "./site-content"; -const clientIds = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"]; +const clientIds = [ + "sev-modaintima", + "lavacao-047", + "sc-plus", + "as-servicos", + "julia-beauty", +]; const labIds = ["nutriscan", "twins-lab", "reddit-explorer", "gcc-installer"]; function getService(slug: string) { @@ -29,7 +35,7 @@ function getService(slug: string) { it("separates client work from the laboratory using the kind in the portfolio data", () => { expect(clientCases.map(({ id }) => id)).toEqual(clientIds); expect(labCases.map(({ id }) => id)).toEqual(labIds); - expect(caseStudies).toHaveLength(8); + expect(caseStudies).toHaveLength(9); expect( caseStudies.every( (item) => isClientCase(item) === clientIds.includes(item.id), @@ -214,6 +220,7 @@ it("lists the client cases for the mosaic and /cases in the order of the home sh "lavacao-047", "sc-plus", "as-servicos", + "julia-beauty", ]); expect(clientCasesForListing).toHaveLength(clientCases.length); }); diff --git a/lib/site-content.ts b/lib/site-content.ts index dfd7be6..31e0bda 100644 --- a/lib/site-content.ts +++ b/lib/site-content.ts @@ -821,6 +821,45 @@ export const caseStudies: CaseStudyContent[] = [ demoUrl: null, role: "Desenvolvimento da primeira landing page do site, com animações 3D, e de um painel para controle de projetos.", }, + { + id: "julia-beauty", + title: "Julia Beauty", + subtitle: "Site de estúdio de extensão de cílios", + metaTitle: "Julia Beauty: site de extensão de cílios", + metaDescription: + "Site do estúdio de extensão de cílios Julia Beauty, em Camboriú (SC): simulador de efeito no olho, SEO local e agendamento pelo WhatsApp.", + headline: + "Site com simulador de efeito e agendamento pelo WhatsApp para um estúdio de cílios em Camboriú", + summary: + "Site da Julia Beauty, o estúdio de extensão de cílios da lash designer Julia, em Camboriú-SC. A cliente testa o efeito no formato do próprio olho, conhece as técnicas e os cuidados e agenda pelo WhatsApp.", + impact: + "A cliente testa o efeito antes de marcar e chama o estúdio no WhatsApp com a mensagem já pronta.", + context: + "A Julia Beauty é o estúdio de extensão de cílios da lash designer Julia, em Camboriú, Santa Catarina. Ela atende uma cliente por vez, com hora marcada, e agenda as sessões pelo WhatsApp. O site apresenta as técnicas, os cuidados, o endereço e o horário, e leva a cliente à conversa.", + problem: + "Quem procura extensão de cílios chega com dúvidas antes de marcar: qual efeito combina com o meu olho, qual técnica escolher, como é a sessão e que cuidados vêm depois. O estúdio atende uma cliente por vez e marca tudo pelo WhatsApp, e precisa ser encontrado por quem busca o serviço em Camboriú. O site tinha de responder a essas perguntas, abrir rápido no celular e levar a cliente ao agendamento.", + deliveries: [ + "Simulador de efeito: a cliente escolhe o formato do olho e o efeito e vê o desenho dos fios, com o tamanho de cada um em milímetros.", + "Agendamento pelo WhatsApp com mensagem pronta, com endereço, horário e link para o mapa na própria página.", + "Um painel de fotos que troca de imagem conforme a cliente rola a página: o ritual, a Julia, as técnicas e os cuidados.", + "As etapas da sessão, a tabela de técnicas e as perguntas frequentes, para a cliente chegar sabendo como tudo funciona.", + "Informações organizadas para o Google e para assistentes de IA entenderem o que o estúdio faz e onde fica.", + ], + capabilities: [ + "A cliente testa o efeito no formato do próprio olho antes de marcar a sessão.", + "A cliente chama o estúdio no WhatsApp com a mensagem pronta, já sabendo endereço, horário e cuidados.", + "Buscadores e assistentes de IA leem endereço, horário, técnicas e perguntas frequentes na própria página.", + ], + updatedAt: "2026-10-24", + stack: ["HTML", "CSS", "JavaScript", "JSON-LD", "Vercel", "Gitea Actions"], + decisions: [ + "HTML, CSS e JavaScript puros, sem build e sem dependências: a página inteira fica em um arquivo, com o estilo e as interações à parte.", + "Os dados estruturados (JSON-LD) descrevem o estúdio como BeautySalon, com endereço, horário e técnicas, e as perguntas frequentes como FAQPage; o site publica também Open Graph, sitemap, robots.txt e llms.txt.", + "Cada push dispara o Gitea Actions, que publica na Vercel: a branch principal vai para produção e as outras geram uma prévia.", + "Medido antes da publicação, o Lighthouse no celular deu 100 em desempenho, acessibilidade, boas práticas e SEO.", + ], + demoUrl: "https://juliabeauty.com.br", + }, ]; const clientProjectIds = new Set( @@ -846,6 +885,7 @@ const clientListingOrder = [ "lavacao-047", "sc-plus", "as-servicos", + "julia-beauty", ]; function listingRank({ id }: { id: string }) { @@ -859,9 +899,9 @@ export const clientCasesForListing = [...clientCases].sort( /** Data (AAAA-MM-DD) da última mudança no conteúdo de cada página fixa: vira o `lastmod` do mapa do site. */ export const pageUpdatedAt = { - home: "2026-10-14", + home: "2026-10-24", servicos: "2026-10-14", - cases: "2026-10-14", + cases: "2026-10-24", ia: "2026-10-14", curriculo: "2026-10-14", // a própria página informa a vigência (13 de setembro de 2026) diff --git a/public/portfolio-data.json b/public/portfolio-data.json index 7667dce..caa2cee 100644 --- a/public/portfolio-data.json +++ b/public/portfolio-data.json @@ -301,6 +301,40 @@ "complexity": 5, "teamSize": "Em equipe" } + }, + { + "id": "julia-beauty", + "kind": "cliente", + "title": "Julia Beauty", + "subtitle": "Site de estúdio de extensão de cílios", + "description": "Site estático do estúdio de extensão de cílios da lash designer Julia, em Camboriú (SC), feito em HTML, CSS e JavaScript sem build: simulador de efeito no olho, painel de fotos que troca com a rolagem e agendamento pelo WhatsApp.", + "longDescription": "Página única com SEO local: Open Graph, dados estruturados JSON-LD (BeautySalon e FAQPage), sitemap, robots.txt e llms.txt. O deploy na Vercel roda pelo Gitea Actions a cada push. Medido antes da publicação, o Lighthouse no celular deu 100 em desempenho, acessibilidade, boas práticas e SEO.", + "technologies": [ + "HTML", + "CSS", + "JavaScript", + "JSON-LD", + "Vercel", + "Gitea Actions" + ], + "features": [ + "Simulador de efeito no olho", + "Painel de fotos que troca com a rolagem", + "Agendamento pelo WhatsApp com mensagem pronta", + "SEO local com JSON-LD, sitemap e llms.txt", + "Deploy na Vercel por Gitea Actions" + ], + "category": "site", + "level": "pleno", + "status": "Em Produção", + "demoUrl": "https://juliabeauty.com.br", + "githubUrl": "", + "badges": ["Site estático", "Em Produção"], + "featured": true, + "metrics": { + "complexity": 3, + "teamSize": "Individual" + } } ], "skills": { diff --git a/public/videos/julia-beauty/desktop.mp4 b/public/videos/julia-beauty/desktop.mp4 new file mode 100644 index 0000000..b336a0f Binary files /dev/null and b/public/videos/julia-beauty/desktop.mp4 differ diff --git a/public/videos/julia-beauty/desktop.webm b/public/videos/julia-beauty/desktop.webm new file mode 100644 index 0000000..8f25abf Binary files /dev/null and b/public/videos/julia-beauty/desktop.webm differ diff --git a/public/videos/julia-beauty/desktop.webp b/public/videos/julia-beauty/desktop.webp new file mode 100644 index 0000000..b4f8ec2 Binary files /dev/null and b/public/videos/julia-beauty/desktop.webp differ diff --git a/public/videos/julia-beauty/mobile.mp4 b/public/videos/julia-beauty/mobile.mp4 new file mode 100644 index 0000000..d773d55 Binary files /dev/null and b/public/videos/julia-beauty/mobile.mp4 differ diff --git a/public/videos/julia-beauty/mobile.webm b/public/videos/julia-beauty/mobile.webm new file mode 100644 index 0000000..47f82ab Binary files /dev/null and b/public/videos/julia-beauty/mobile.webm differ diff --git a/public/videos/julia-beauty/mobile.webp b/public/videos/julia-beauty/mobile.webp new file mode 100644 index 0000000..33d0f88 Binary files /dev/null and b/public/videos/julia-beauty/mobile.webp differ diff --git a/public/videos/julia-beauty/og.jpg b/public/videos/julia-beauty/og.jpg new file mode 100644 index 0000000..e5532f5 Binary files /dev/null and b/public/videos/julia-beauty/og.jpg differ diff --git a/public/videos/julia-beauty/scrub-desktop.mp4 b/public/videos/julia-beauty/scrub-desktop.mp4 new file mode 100644 index 0000000..3e566b4 Binary files /dev/null and b/public/videos/julia-beauty/scrub-desktop.mp4 differ