feat(servicos): topo, entregáveis e cases por serviço, assunto próprio no WhatsApp e títulos curtos

This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 22:54:18 -03:00
1 parent cf360041bd
commit 2fd8b063d9
33 files changed
+1578 -300

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
/**
* Nome de cada uma das três ofertas e a frase que a mensagem de WhatsApp usa como assunto ("Quero conversar sobre {assunto}.").
* É a única fonte desses textos: a home, a página da oferta, os dados estruturados, o formulário de contato e as mensagens de
* WhatsApp importam daqui. Fica à parte de lib/site-content.ts, que reúne o texto de todas as páginas e não deve ir inteiro para
* o navegador: este módulo é pequeno, não tem dependência e não usa nada do Node, então componentes de cliente podem importá-lo.
*/
export const offerLabels = {
site: {
name: "Site que traz pedidos pelo WhatsApp",
whatsappSubject: "um site que traz pedidos pelo WhatsApp",
},
sistema: {
name: "Sistema para tirar a operação da planilha",
whatsappSubject: "um sistema para tirar a operação da planilha",
},
revisao: {
name: "Revisão de sistema que já existe",
whatsappSubject: "a revisão de um sistema que já existe",
},
} as const;
+44 -37
View File
@@ -2,9 +2,9 @@ import sitemap from "../app/sitemap";
import {
buildBreadcrumbList,
buildCaseJsonLd,
buildOfferServicesJsonLd,
buildPageSocialMetadata,
buildPersonJsonLd,
buildProfessionalServiceJsonLd,
buildProfilePageJsonLd,
buildServiceJsonLd,
buildWebSiteJsonLd,
@@ -29,7 +29,6 @@ it("uses one canonical identity across website, profile and service", () => {
website: `${siteUrl}/#website`,
person: `${siteUrl}/#person`,
profilePage: `${siteUrl}/curriculo#profile-page`,
professionalService: `${siteUrl}/#professional-service`,
});
expect(buildWebSiteJsonLd().publisher).toEqual({ "@id": SEO_IDS.person });
expect(buildProfilePageJsonLd()).toMatchObject({
@@ -74,6 +73,7 @@ it("keeps the root graph limited to verified website and person fields", () => {
image: `${siteUrl}/edev.jpg`,
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" },
// só o LinkedIn: a conta do GitHub foi suspensa (404) e o Instagram fica fora por política
sameAs: ["https://www.linkedin.com/in/evandrodevbr/"],
});
@@ -82,7 +82,7 @@ it("keeps the root graph limited to verified website and person fields", () => {
);
});
it("builds a canonical service with only the verified service area", () => {
it("builds a canonical service whose service area is the visible one: Garuva, Joinville, Santa Catarina and Brazil", () => {
const service = representativeService;
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
@@ -96,49 +96,56 @@ it("builds a canonical service with only the verified service area", () => {
areaServed: [
{ "@type": "City", name: "Garuva" },
{ "@type": "City", name: "Joinville" },
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
{ "@type": "AdministrativeArea", name: "Santa Catarina" },
{ "@type": "Country", name: "Brasil" },
],
});
// a mesma área das três ofertas da home: um valor só em todo o JSON-LD
for (const offer of buildOfferServicesJsonLd()) {
expect(offer.areaServed).toEqual(buildServiceJsonLd(service).areaServed);
}
});
it("describes the business as a ProfessionalService with the service area and the three offers as a catalog", () => {
const business = buildProfessionalServiceJsonLd();
it("describes the three offers as Service nodes tied to the person, with the service area and the page of each offer", () => {
const services = buildOfferServicesJsonLd();
expect(business).toMatchObject({
"@type": "ProfessionalService",
"@id": SEO_IDS.professionalService,
name: siteName,
url: siteUrl,
founder: { "@id": SEO_IDS.person },
});
expect(business.areaServed.map(({ name }) => name)).toEqual([
"Garuva",
"Joinville",
"Santa Catarina",
"Brasil",
]);
const catalog = business.hasOfferCatalog;
expect(catalog["@type"]).toBe("OfferCatalog");
// as mesmas três ofertas da home, com o mesmo nome e a página de cada uma
expect(
catalog.itemListElement.map(({ itemOffered }) => itemOffered.name),
).toEqual([
expect(services).toHaveLength(3);
// as mesmas três ofertas da home, com o mesmo nome e a página de cada uma (a revisão aponta para /ia)
expect(services.map(({ name }) => name)).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Revisão de sistema que já existe, inclusive feito com IA",
"Revisão de sistema que já existe",
]);
expect(
catalog.itemListElement.map(({ itemOffered }) => itemOffered.url),
).toEqual(serviceOffers.map(({ href }) => `${siteUrl}${href}`));
for (const offer of catalog.itemListElement) {
expect(offer["@type"]).toBe("Offer");
expect(offer.itemOffered["@type"]).toBe("Service");
expect(offer.itemOffered.description.length).toBeGreaterThan(30);
expect(services.map(({ url }) => url)).toEqual([
`${siteUrl}/servicos/criacao-de-sites`,
`${siteUrl}/servicos/engenharia-de-software`,
`${siteUrl}/ia`,
]);
expect(services.map(({ url }) => url)).toEqual(
serviceOffers.map(({ href }) => `${siteUrl}${href}`),
);
for (const service of services) {
expect(service["@type"]).toBe("Service");
expect(service.provider).toEqual({ "@id": SEO_IDS.person });
expect(service.areaServed.map(({ name }) => name)).toEqual([
"Garuva",
"Joinville",
"Santa Catarina",
"Brasil",
]);
expect(service.description.length).toBeGreaterThan(30);
}
// nada que o site não publique: sem preço, prazo, endereço de rua, telefone ou e-mail
expect(JSON.stringify(business)).not.toMatch(
/price|priceCurrency|availability|telephone|email|streetAddress|PostalAddress|geo|aggregateRating|review/i,
// ids próprios e estáveis, que não repetem o `#service` que a página de cada serviço emite
const ids = services.map((service) => service["@id"]);
expect(new Set(ids).size).toBe(3);
expect(ids).toEqual([
`${siteUrl}/#service-site`,
`${siteUrl}/#service-sistema`,
`${siteUrl}/#service-revisao`,
]);
// política de docs/seo-geo-operations.md: nenhum tipo de negócio local e nada que o site não publique
expect(JSON.stringify(services)).not.toMatch(
/ProfessionalService|LocalBusiness|OfferCatalog|price|priceCurrency|availability|telephone|email|streetAddress|PostalAddress|geo|aggregateRating|review/i,
);
});
+29 -41
View File
@@ -14,7 +14,6 @@ export const SEO_IDS = {
website: `${siteUrl}/#website`,
person: `${siteUrl}/#person`,
profilePage: `${siteUrl}/curriculo#profile-page`,
professionalService: `${siteUrl}/#professional-service`,
} as const;
export function buildWebSiteJsonLd() {
@@ -51,6 +50,7 @@ export function buildPersonJsonLd() {
"@type": "Place",
name: "Garuva, Santa Catarina, Brasil",
},
// perfis profissionais públicos e no ar; a conta do GitHub foi suspensa (404) e o Instagram fica fora por política
sameAs: ["https://www.linkedin.com/in/evandrodevbr/"],
} as const;
}
@@ -67,39 +67,34 @@ export function buildProfilePageJsonLd() {
}
/**
* O negócio como `ProfessionalService`: onde atende (Garuva, Joinville, Santa Catarina e o Brasil, de forma remota) e as três ofertas
* da home como catálogo. Sem endereço de rua, preço, prazo, telefone ou e-mail: o que não está publicado no site não entra nos
* dados estruturados. Só o construtor: quem o coloca na página é a home.
* Área de atendimento de todo nó `Service` (as ofertas da home e as páginas de serviço): a mesma do texto visível, "Garuva e
* Joinville (SC), atendimento remoto em todo o Brasil". Um valor só, para os dados estruturados não divergirem entre páginas.
*/
export function buildProfessionalServiceJsonLd() {
return {
"@type": "ProfessionalService",
"@id": SEO_IDS.professionalService,
name: siteName,
url: siteUrl,
description:
"Sites, sistemas web e aplicativos sob medida, e revisão de sistemas que já existem, inclusive feitos com IA. Garuva e Joinville (SC), remoto em todo o Brasil.",
founder: { "@id": SEO_IDS.person },
areaServed: [
{ "@type": "City", name: "Garuva" },
{ "@type": "City", name: "Joinville" },
{ "@type": "AdministrativeArea", name: "Santa Catarina" },
{ "@type": "Country", name: "Brasil" },
],
hasOfferCatalog: {
"@type": "OfferCatalog",
name: "Serviços de desenvolvimento de software",
itemListElement: serviceOffers.map((offer) => ({
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: offer.name,
description: offer.summary,
url: `${siteUrl}${offer.href}`,
},
})),
},
} as const;
const serviceAreaServed = [
{ "@type": "City", name: "Garuva" },
{ "@type": "City", name: "Joinville" },
{ "@type": "AdministrativeArea", name: "Santa Catarina" },
{ "@type": "Country", name: "Brasil" },
] as const;
/**
* As três ofertas da home como nós `Service`, ligados à pessoa (`provider`) e com a área de atendimento (`serviceAreaServed`).
* Nada de `ProfessionalService`, `LocalBusiness` ou catálogo de ofertas: a política
* de docs/seo-geo-operations.md os exclui sem entidade ou local comprovado. Sem endereço, preço, prazo, telefone ou e-mail: o que
* não está publicado no site não entra nos dados estruturados. Só o construtor: quem o coloca na página é a home.
* O `url` é a página da oferta (a revisão abre /ia). O `@id` é da home, para não repetir o do nó que a página do serviço emite
* com outra descrição.
*/
export function buildOfferServicesJsonLd() {
return serviceOffers.map((offer) => ({
"@type": "Service",
"@id": `${siteUrl}/#service-${offer.id}`,
name: offer.name,
description: offer.summary,
url: `${siteUrl}${offer.href}`,
provider: { "@id": SEO_IDS.person },
areaServed: serviceAreaServed,
}));
}
export function buildServiceJsonLd(service: ServicePageContent) {
@@ -112,14 +107,7 @@ export function buildServiceJsonLd(service: ServicePageContent) {
name: service.navLabel,
description: service.metaDescription,
provider: { "@id": SEO_IDS.person },
areaServed: [
{ "@type": "City", name: "Garuva" },
{ "@type": "City", name: "Joinville" },
{
"@type": "AdministrativeArea",
name: "Norte de Santa Catarina",
},
],
areaServed: serviceAreaServed,
} as const;
}
+5 -5
View File
@@ -7,7 +7,7 @@
* Os dados reais só têm os quatro clientes da lista; o caminho do cliente fora da lista só existe aqui.
*/
const LISTED = ["lavacao-047", "sc-plus", "as-servicos", "sev-modaintima"];
const LISTED = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"];
function loadWithClients(clientIds: readonly string[] | "all") {
let loaded: typeof import("./site-content") | undefined;
@@ -25,14 +25,14 @@ function loadWithClients(clientIds: readonly string[] | "all") {
}
describe("clientCasesForListing: ordem", () => {
it("path: only listed clients -> the fixed order, the SEV last, whatever order the cases come in", () => {
it("path: only listed clients -> the fixed order, the SEV first, whatever order the cases come in", () => {
const { clientCasesForListing, clientCases } = loadWithClients(LISTED);
expect(clientCasesForListing.map(({ id }) => id)).toEqual(LISTED);
expect(clientCasesForListing).toHaveLength(clientCases.length);
});
it("path: a client that is not in the order list -> goes after all the listed ones, the SEV included", () => {
it("path: a client that is not in the order list -> goes after all the listed ones", () => {
const { clientCasesForListing, caseStudies } = loadWithClients([
...LISTED,
"gcc-installer",
@@ -67,11 +67,11 @@ describe("clientCasesForListing: ordem", () => {
expect(clientCasesForListing).toEqual([]);
});
it("path: the real data -> the SEV is the last client, as the mosaic and /cases expect", () => {
it("path: the real data -> the SEV is the first client, as in the home showcase", () => {
jest.resetModules();
const { clientCasesForListing } =
jest.requireActual<typeof import("./site-content")>("./site-content");
expect(clientCasesForListing.at(-1)?.id).toBe("sev-modaintima");
expect(clientCasesForListing[0]?.id).toBe("sev-modaintima");
});
});
+92 -8
View File
@@ -6,8 +6,10 @@ import {
caseStudies,
clientCases,
clientCasesForListing,
generalFirstStep,
getRelatedCases,
getRelatedServices,
getServiceOffer,
isClientCase,
labCases,
pageUpdatedAt,
@@ -93,12 +95,18 @@ it("mentions the region and a benefit in the title and description of every serv
expect(new Set(descriptions).size).toBe(descriptions.length);
});
it("maps each service to its client cases, with the SEV only where it is the proof: the systems service", () => {
it("maps each service to its client cases, with the SEV as proof of both offers: the site and the system", () => {
// a Lavação abre a página, a SEV é o segundo trabalho e os dois sites institucionais vão para o bloco menor
expect(getService("criacao-de-sites").relatedCaseIds).toEqual([
"lavacao-047",
"sev-modaintima",
"sc-plus",
"as-servicos",
]);
expect(getService("criacao-de-sites").alsoCases).toEqual({
title: "Também faço sites institucionais",
caseIds: ["sc-plus", "as-servicos"],
});
expect(getService("engenharia-de-software").relatedCaseIds).toEqual([
"sev-modaintima",
"lavacao-047",
@@ -111,7 +119,12 @@ it("maps each service to its client cases, with the SEV only where it is the pro
servicePages
.filter(({ relatedCaseIds }) => relatedCaseIds.includes("sev-modaintima"))
.map(({ slug }) => slug),
).toEqual(["engenharia-de-software"]);
).toEqual(["criacao-de-sites", "engenharia-de-software"]);
// o bloco menor só cita cases que o serviço já relaciona
for (const { relatedCaseIds, alsoCases } of servicePages) {
for (const id of alsoCases?.caseIds ?? [])
expect(relatedCaseIds).toContain(id);
}
// nenhum case de cliente é um diagnóstico de sistema: o serviço fica só com a experiência na Apolo
expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([]);
expect(getService("arquitetura-de-software").caseRelevance).toEqual({});
@@ -195,12 +208,12 @@ it("gives each service without a client cover a flow diagram instead, and never
}
});
it("lists the client cases for the mosaic and /cases with the SEV last, never first", () => {
it("lists the client cases for the mosaic and /cases in the order of the home showcase, the SEV first", () => {
expect(clientCasesForListing.map(({ id }) => id)).toEqual([
"sev-modaintima",
"lavacao-047",
"sc-plus",
"as-servicos",
"sev-modaintima",
]);
expect(clientCasesForListing).toHaveLength(clientCases.length);
});
@@ -222,7 +235,12 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
expect(serviceOffers.map(({ name }) => name)).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Revisão de sistema que já existe, inclusive feito com IA",
"Revisão de sistema que já existe",
]);
expect(serviceOffers.map(({ whatsappSubject }) => whatsappSubject)).toEqual([
"um site que traz pedidos pelo WhatsApp",
"um sistema para tirar a operação da planilha",
"a revisão de um sistema que já existe",
]);
expect(serviceOffers.map(({ href }) => href)).toEqual([
"/servicos/criacao-de-sites",
@@ -232,6 +250,13 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
for (const offer of serviceOffers) {
expect(offer.deliverables.length).toBeGreaterThanOrEqual(3);
expect(offer.deliverables.length).toBeLessThanOrEqual(4);
expect(offer.firstStep).toMatch(
/^Primeira conversa .* proposta por escrito\.$/,
);
// a frase geral da home não contradiz a da oferta: as duas terminam igual
expect(offer.firstStep.endsWith(generalFirstStep.split(". ")[1])).toBe(
true,
);
expect(
offer.proof.caseIds.every((id) => caseStudies.some((c) => c.id === id)),
).toBe(true);
@@ -240,6 +265,7 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
offer.name,
offer.summary,
...offer.deliverables,
offer.firstStep,
offer.proof.note ?? "",
].join(" ");
expect(text).not.toMatch(/R\$|\d+\s?%|\d+\s?dias|["“”]/);
@@ -254,6 +280,48 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
expect(serviceOffers[2].proof.caseIds).toEqual([]);
});
it("uses one name per offer: the service page, the breadcrumb label and the WhatsApp subject match the offer", () => {
for (const offer of serviceOffers) {
const service = servicePages.find(({ slug }) => offer.href.endsWith(slug));
// a revisão abre /ia, que não é uma página de serviço
if (!service) {
expect(offer.id).toBe("revisao");
continue;
}
expect(service.navLabel).toBe(offer.name);
expect(service.headline).toBe(offer.name);
expect(service.whatsappSubject).toBe(offer.whatsappSubject);
expect(getServiceOffer(service.slug)).toBe(offer);
}
expect(getServiceOffer("consultoria-nextjs")).toBeUndefined();
});
it("gives every service a short name and a lowercase WhatsApp subject with its article, and no two names alike", () => {
expect(servicePages.map(({ navLabel }) => navLabel)).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Consultoria em Next.js",
"Aplicativos desktop",
"Integração de equipamentos",
"Diagnóstico de arquitetura",
]);
for (const service of servicePages) {
expect(service.whatsappSubject).toMatch(/^(um|uma|a) [a-zà-ú]/);
expect(service.whatsappSubject).not.toMatch(/[.!?]$/);
}
expect(getService("consultoria-nextjs").whatsappSubject).toBe(
"uma consultoria em Next.js",
);
const names = servicePages.map(({ navLabel }) => navLabel);
expect(new Set(names).size).toBe(names.length);
});
it("says the systems service in terms of the pain: spreadsheets and rework", () => {
expect(getService("engenharia-de-software").answer).toMatch(
/planilhas.*retrabalho/,
);
});
it("writes 2 or 3 capabilities for every client case, without numbers, and none for the laboratory", () => {
for (const caseStudy of clientCases) {
expect(caseStudy.capabilities?.length).toBeGreaterThanOrEqual(2);
@@ -323,7 +391,9 @@ it("derives the exact related service set from the existing relationship", () =>
expect(getRelatedServices("twins-lab").map(({ slug }) => slug)).toEqual([
"integracao-hardware-software-iot",
]);
// a SEV liga à oferta de site e à de sistema, nesta ordem
expect(getRelatedServices("sev-modaintima").map(({ slug }) => slug)).toEqual([
"criacao-de-sites",
"engenharia-de-software",
]);
expect(
@@ -373,7 +443,7 @@ it("writes one sentence on why each related case matters to its service", () =>
}
});
it("cites the Apolo Energia experience, from 2022, only on the IoT and architecture services", () => {
it("cites the Apolo Energia experience, from 2022, only on the systems, IoT and architecture services", () => {
const apolo = portfolioData.experiences.find(
({ id }) => id === "apolo-energia",
);
@@ -383,6 +453,7 @@ it("cites the Apolo Energia experience, from 2022, only on the IoT and architect
.filter(({ experienceNote }) => experienceNote)
.map(({ slug }) => slug);
expect(withNote).toEqual([
"engenharia-de-software",
"integracao-hardware-software-iot",
"arquitetura-de-software",
]);
@@ -390,12 +461,24 @@ it("cites the Apolo Energia experience, from 2022, only on the IoT and architect
const note = getService(slug).experienceNote ?? "";
expect(note).toMatch(/Apolo Energia/);
expect(note).toMatch(/desde 2022/i);
expect(note).toMatch(/sistemas internos/i);
expect(note).toMatch(/aplicativos/i);
expect(note).toMatch(/APIs/);
// sem link e sem detalhe de operação do cliente
expect(note).not.toMatch(/https?:|www\.|\.com/i);
}
for (const slug of [
"integracao-hardware-software-iot",
"arquitetura-de-software",
]) {
const note = getService(slug).experienceNote ?? "";
expect(note).toMatch(/sistemas internos/i);
expect(note).toMatch(/aplicativos/i);
}
// o sistema diz o que o currículo diz da Apolo: aplicações web e mobile para rotinas internas, APIs e painéis de operação
const systemsNote = getService("engenharia-de-software").experienceNote ?? "";
expect(systemsNote).toMatch(/aplicações web e mobile para rotinas internas/);
expect(systemsNote).toMatch(/painéis de operação/);
expect(apolo?.description).toMatch(/aplicações Web e Mobile/);
expect(apolo?.description).toMatch(/rotinas internas/);
});
it("keeps the problem of every client case concrete, with no invented figure or quote", () => {
@@ -449,6 +532,7 @@ it("keeps AS Serviços without a public site and states the author's participati
it("keeps site-content and the modules it imports free of Node built-ins, so client components can import it", () => {
for (const file of [
"site-content",
"offer-labels",
"portfolio-data",
"proof",
"project-health",
+59 -19
View File
@@ -1,3 +1,4 @@
import { offerLabels } from "./offer-labels";
import { portfolio } from "./portfolio-data";
import { isClientProject } from "./proof";
@@ -25,7 +26,10 @@ export const buttonLabels = {
export interface ServicePageContent {
slug: string;
/** Nome curto do serviço: o mesmo na trilha, nos títulos, nos links e nos dados estruturados. Nas ofertas da home é o nome da oferta. */
navLabel: string;
/** Assunto da conversa de WhatsApp, em minúsculas e com artigo, para a frase "Quero conversar sobre {assunto}.". */
whatsappSubject: string;
eyebrow: string;
/** Título para busca, separado do h1: até 43 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
metaTitle: string;
@@ -36,6 +40,7 @@ export interface ServicePageContent {
intro: string;
answer: string;
audience: string[];
/** "O que podemos entregar". Nas páginas que explicam uma oferta da home a seção não aparece: vale o "O que você recebe" da oferta (`serviceOffers`), uma lista de entregáveis só. */
outcomes: string[];
process: string[];
examples: string[];
@@ -43,6 +48,8 @@ export interface ServicePageContent {
stack: string[];
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
relatedCaseIds: string[];
/** Cases relacionados que saem de "Onde já apliquei isso" e vão para um bloco menor com este título, sem capa (ids de `relatedCaseIds`). */
alsoCases?: { title: string; caseIds: string[] };
/** Capa do topo: o case (um dos relacionados) e o aparelho da captura. Sem ela, o topo mostra o esquema `diagram`. */
heroCase?: { id: string; device: HeroDevice };
/** Esquema de fluxo do topo, para o serviço sem case com captura: nunca leva dado real nem logo de cliente. */
@@ -101,12 +108,13 @@ export interface CaseStudyContent {
export const servicePages: ServicePageContent[] = [
{
slug: "criacao-de-sites",
navLabel: "Sites profissionais",
navLabel: offerLabels.site.name,
whatsappSubject: offerLabels.site.whatsappSubject,
eyebrow: "Presença digital",
metaTitle: "Criação de sites em Joinville e SC",
metaDescription:
"Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
headline: "Site que traz pedidos pelo WhatsApp",
headline: offerLabels.site.name,
answer:
"Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.",
intro:
@@ -138,12 +146,19 @@ export const servicePages: ServicePageContent[] = [
"Metadados, URLs canônicas, sitemap e dados estruturados ajudam os buscadores a entender o site.",
],
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
relatedCaseIds: ["lavacao-047", "sc-plus", "as-servicos"],
// a Lavação abre a página (capa), a SEV é o segundo trabalho e os dois sites institucionais ficam no bloco menor
relatedCaseIds: ["lavacao-047", "sev-modaintima", "sc-plus", "as-servicos"],
heroCase: { id: "lavacao-047", device: "desktop" },
alsoCases: {
title: "Também faço sites institucionais",
caseIds: ["sc-plus", "as-servicos"],
},
updatedAt: "2026-10-14",
caseRelevance: {
"lavacao-047":
"Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.",
"sev-modaintima":
"Um catálogo por tamanho com pedido pelo WhatsApp, e um painel para a loja cuidar de produtos e estoque.",
"sc-plus":
"Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.",
"as-servicos":
@@ -169,14 +184,15 @@ export const servicePages: ServicePageContent[] = [
},
{
slug: "engenharia-de-software",
navLabel: "Sistemas web e aplicativos mobile",
navLabel: offerLabels.sistema.name,
whatsappSubject: offerLabels.sistema.whatsappSubject,
eyebrow: "Engenharia de software",
metaTitle: "Sistemas web e aplicativos sob medida",
metaDescription:
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
headline: "Sistema para tirar a operação da planilha",
headline: offerLabels.sistema.name,
answer:
"Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.",
"Quando a operação vive em planilhas, mensagens e retrabalho, desenvolvo um sistema web ou um aplicativo mobile feito para a sua equipe. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções e a primeira entrega útil.",
intro:
"Organize a operação com um sistema web ou aplicativo mobile sob medida. Conecte ferramentas, reduza tarefas repetidas ou melhore um sistema que já existe.",
audience: [
@@ -215,6 +231,8 @@ export const servicePages: ServicePageContent[] = [
"lavacao-047":
"Um painel em que o dono altera preços, textos e fotos, e a mudança aparece no site sem nova publicação.",
},
experienceNote:
"Desde 2022 trabalho com isso na Apolo Energia: aplicações web e mobile para rotinas internas, APIs e painéis de operação. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
faq: [
{
question: "Preciso saber qual tecnologia usar?",
@@ -235,7 +253,8 @@ export const servicePages: ServicePageContent[] = [
},
{
slug: "consultoria-nextjs",
navLabel: "Melhorias em sites e sistemas Next.js",
navLabel: "Consultoria em Next.js",
whatsappSubject: "uma consultoria em Next.js",
eyebrow: "Consultoria Next.js",
metaTitle: "Consultoria Next.js para site ou sistema",
metaDescription:
@@ -301,7 +320,8 @@ export const servicePages: ServicePageContent[] = [
},
{
slug: "tauri-rust",
navLabel: "Aplicativos desktop para Windows e Linux",
navLabel: "Aplicativos desktop",
whatsappSubject: "um aplicativo desktop",
eyebrow: "Aplicativos desktop",
metaTitle: "Aplicativos desktop para Windows e Linux",
metaDescription:
@@ -370,7 +390,8 @@ export const servicePages: ServicePageContent[] = [
},
{
slug: "integracao-hardware-software-iot",
navLabel: "Integração de equipamentos e sistemas",
navLabel: "Integração de equipamentos",
whatsappSubject: "uma integração de equipamentos",
eyebrow: "Hardware, software e IoT",
metaTitle: "Integração de equipamentos e sistemas (IoT)",
metaDescription:
@@ -436,7 +457,8 @@ export const servicePages: ServicePageContent[] = [
},
{
slug: "arquitetura-de-software",
navLabel: "Diagnóstico e arquitetura de software",
navLabel: "Diagnóstico de arquitetura",
whatsappSubject: "um diagnóstico de arquitetura",
eyebrow: "Diagnóstico e decisão",
metaTitle: "Diagnóstico e arquitetura de software",
metaDescription:
@@ -816,14 +838,14 @@ export const labCases = caseStudies.filter(
);
/**
* Ordem dos clientes na lista de /cases e no mosaico de /servicos: a SEV Moda Íntima, que já abre a página de sistemas
* e é a prova da oferta de sistema, vem por último e fica fora das três capas do mosaico. Cliente fora desta lista vai para o fim.
* Ordem dos clientes na lista de /cases e no mosaico de /servicos: a mesma da vitrine da home (SHOWCASE em
* lib/project-showcase.ts), com a SEV Moda Íntima primeiro. Cliente fora desta lista vai para o fim.
*/
const clientListingOrder = [
"sev-modaintima",
"lavacao-047",
"sc-plus",
"as-servicos",
"sev-modaintima",
];
function listingRank({ id }: { id: string }) {
@@ -848,18 +870,28 @@ export const pageUpdatedAt = {
export interface ServiceOffer {
id: "site" | "sistema" | "revisao";
/** Nome da oferta, o mesmo da home. */
/** Nome da oferta, o mesmo da home, da página da oferta, dos dados estruturados e do WhatsApp. */
name: string;
/** Assunto da conversa de WhatsApp, em minúsculas e com artigo, para a frase "Quero conversar sobre {assunto}.". */
whatsappSubject: string;
/** Uma frase sobre a oferta (descrição dos dados estruturados). */
summary: string;
/** Página que explica a oferta. */
href: string;
/** O que a pessoa recebe, em 3 ou 4 itens, só com o que os cases ou a página da oferta já sustentam. */
/** O que a pessoa recebe, em 3 ou 4 itens, só com o que os cases ou a página da oferta já sustentam. A home e a página da oferta mostram esta lista. */
deliverables: string[];
/** Primeiro passo: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). A página da oferta mostra esta frase; a home, que serve às três, mostra `generalFirstStep`. */
firstStep: string;
/** Onde ver a oferta funcionando: o texto que abre a linha, os cases (links) e uma observação opcional. */
proof: { label: string; caseIds: string[]; note?: string };
}
/** O que vem depois da primeira conversa: o mesmo em cada oferta e na frase geral da home. */
const FIRST_STEP_NEXT = "Depois, escopo e proposta por escrito.";
/** O primeiro passo da home, que vale para as três ofertas: igual ao de cada uma, só sem dizer o que se entende na conversa. */
export const generalFirstStep = `Primeira conversa para entender o seu caso. ${FIRST_STEP_NEXT}`;
/**
* As três ofertas da home, na ordem do H1, com o mesmo nome. Cada texto só diz o que os cases citados, a experiência
* profissional ou a página /ia já sustentam; sem prazo nem preço.
@@ -867,7 +899,7 @@ export interface ServiceOffer {
export const serviceOffers: ServiceOffer[] = [
{
id: "site",
name: "Site que traz pedidos pelo WhatsApp",
...offerLabels.site,
summary:
"Catálogo ou tabela de preços, mensagem pronta no WhatsApp e um painel para você editar preços, textos e fotos sozinho.",
href: "/servicos/criacao-de-sites",
@@ -877,6 +909,7 @@ export const serviceOffers: ServiceOffer[] = [
"Painel para você mudar preços, textos e fotos sem pedir a ninguém.",
"Leitura boa no celular e informações organizadas para o Google.",
],
firstStep: `Primeira conversa para entender o problema. ${FIRST_STEP_NEXT}`,
proof: {
label: "Veja funcionando",
caseIds: ["lavacao-047", "sev-modaintima"],
@@ -884,7 +917,7 @@ export const serviceOffers: ServiceOffer[] = [
},
{
id: "sistema",
name: "Sistema para tirar a operação da planilha",
...offerLabels.sistema,
summary:
"Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe.",
href: "/servicos/engenharia-de-software",
@@ -894,6 +927,7 @@ export const serviceOffers: ServiceOffer[] = [
"Acesso por perfil: cada pessoa vê só o que precisa.",
"Uma primeira versão com as funções prioritárias, ampliada em etapas.",
],
firstStep: `Primeira conversa para entender a operação. ${FIRST_STEP_NEXT}`,
proof: {
label: "Veja funcionando",
caseIds: ["sev-modaintima"],
@@ -902,9 +936,9 @@ export const serviceOffers: ServiceOffer[] = [
},
{
id: "revisao",
name: "Revisão de sistema que já existe, inclusive feito com IA",
...offerLabels.revisao,
summary:
"Avaliação do que já existe, teste das falhas de acesso a dados e separação do que dá para aproveitar do que é melhor refazer.",
"Avaliação de um sistema que já existe, inclusive feito com IA: teste das falhas de acesso a dados e separação do que dá para aproveitar do que é melhor refazer.",
href: "/ia",
deliverables: [
"As falhas de acesso a dados: quem consegue ver ou alterar o dado de quem.",
@@ -912,6 +946,7 @@ export const serviceOffers: ServiceOffer[] = [
"O que é melhor refazer, com o motivo de cada parte.",
"Um orçamento por escrito para pôr o sistema em produção.",
],
firstStep: `Primeira conversa para entender o app e o que ele precisa fazer. ${FIRST_STEP_NEXT}`,
proof: {
label: "Veja como funciona",
caseIds: [],
@@ -930,6 +965,11 @@ export function getServicePage(slug: string) {
return servicePages.find((service) => service.slug === slug);
}
/** A oferta que a página de serviço explica; os serviços que não são oferta (e a revisão, que abre /ia) não têm. */
export function getServiceOffer(slug: string) {
return serviceOffers.find(({ href }) => href === `/servicos/${slug}`);
}
/** Cases na ordem de `caseIds`, com os de cliente à frente dos de laboratório. */
export function getRelatedCases(caseIds: string[]) {
const cases = caseIds.flatMap((id) => {
+1 -1
View File
@@ -16,7 +16,7 @@ import {
*/
const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" },
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
cases: { "sev-modaintima": "SEV Moda Íntima", vazio: "" },
};
+42 -6
View File
@@ -1,3 +1,4 @@
import { servicePages } from "./site-content";
import {
AI_REVIEW_SUBJECT,
buildWhatsAppUrl,
@@ -5,6 +6,7 @@ import {
HOME_SUBJECT,
headerOpensWhatsApp,
isServiceOrCasePath,
OFFER_SUBJECTS,
RESUME_SUBJECT,
SERVICES_SUBJECT,
similarProjectSubject,
@@ -13,15 +15,39 @@ import {
} from "./whatsapp";
const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" },
// o whatsappSubject do serviço, como app/layout.tsx o monta: uma frase em minúsculas, e não o nome do serviço
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
cases: { "sev-modaintima": "SEV Moda Íntima" },
};
describe("whatsAppSubjectForPath", () => {
it("cites the service on /servicos/<slug>", () => {
expect(whatsAppSubjectForPath("/servicos/criacao-de-sites", contexts)).toBe(
"Sites profissionais",
it("cites the service on /servicos/<slug> with its whatsappSubject, a lowercase phrase, so the message reads as a sentence", () => {
const subject = whatsAppSubjectForPath(
"/servicos/criacao-de-sites",
contexts,
);
expect(subject).toBe("um site que traz pedidos pelo WhatsApp");
expect(`Quero conversar sobre ${subject}.`).not.toMatch(
/sobre Sites profissionais/,
);
});
it("has a whatsappSubject, in lowercase and with no final period, for every service page and offer of site-content", () => {
expect(servicePages.length).toBeGreaterThan(0);
for (const { slug, navLabel, whatsappSubject } of servicePages) {
// não é o nome do serviço (que tem inicial maiúscula): é uma frase que entra depois de "sobre"
expect(whatsappSubject).toMatch(/^[a-zà-ú].*[^.]$/);
expect(whatsappSubject).not.toBe(navLabel);
expect(
whatsAppSubjectForPath(`/servicos/${slug}`, {
services: { [slug]: whatsappSubject },
cases: {},
}),
).toBe(whatsappSubject);
}
for (const subject of Object.values(OFFER_SUBJECTS)) {
expect(subject).toMatch(/^[a-zà-ú].*[^.]$/);
}
});
it("cites the case on /cases/<slug>, with the phrase every page uses for a similar project", () => {
@@ -40,7 +66,17 @@ describe("whatsAppSubjectForPath", () => {
expect(whatsAppSubjectForPath("/servicos", contexts)).toBe(
SERVICES_SUBJECT,
);
expect(SERVICES_SUBJECT).toBe("Sites, sistemas e aplicativos");
expect(SERVICES_SUBJECT).toBe("sites, sistemas e aplicativos");
// nenhum assunto fixo começa em maiúscula: "Quero conversar sobre Sites, sistemas..." era o erro
for (const subject of [
AI_REVIEW_SUBJECT,
HOME_SUBJECT,
CASES_SUBJECT,
SERVICES_SUBJECT,
RESUME_SUBJECT,
]) {
expect(subject).toMatch(/^[a-zà-ú]/);
}
expect(whatsAppSubjectForPath("/curriculo", contexts)).toBe(RESUME_SUBJECT);
// sem contextos, a página-índice continua falando de si
expect(whatsAppSubjectForPath("/cases/")).toBe(CASES_SUBJECT);
@@ -55,7 +91,7 @@ describe("whatsAppSubjectForPath", () => {
it("ignores a trailing slash", () => {
expect(
whatsAppSubjectForPath("/servicos/criacao-de-sites/", contexts),
).toBe("Sites profissionais");
).toBe("um site que traz pedidos pelo WhatsApp");
});
it.each([
+19 -3
View File
@@ -1,3 +1,5 @@
import { offerLabels } from "./offer-labels";
const COUNTRY = ["5", "5"];
const AREA = ["4", "7"];
const NINE = ["9"];
@@ -17,11 +19,14 @@ export function buildWhatsAppUrl(message: string) {
}
/**
* Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx) a partir de lib/site-content.ts:
* Assuntos dos serviços e nomes dos cases, montados no servidor (app/layout.tsx) a partir de lib/site-content.ts:
* esse arquivo reúne o texto de todas as páginas e não deve ir inteiro para o navegador.
*/
export interface WhatsAppContexts {
/** slug do serviço -> nome do serviço */
/**
* slug do serviço -> `whatsappSubject` dele: uma frase em minúsculas que entra em "Quero conversar sobre {assunto}."
* (e não o nome do serviço, que daria "sobre Sites profissionais.").
*/
services: Record<string, string>;
/** id do case -> nome do projeto ou do cliente */
cases: Record<string, string>;
@@ -36,9 +41,20 @@ export const AI_REVIEW_SUBJECT = "a revisão de um aplicativo feito com IA";
*/
export const HOME_SUBJECT = "um site ou sistema para a minha empresa";
export const CASES_SUBJECT = "um projeto parecido com os cases do portfólio";
export const SERVICES_SUBJECT = "Sites, sistemas e aplicativos";
export const SERVICES_SUBJECT = "sites, sistemas e aplicativos";
export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo";
/**
* Assunto de cada oferta da home, para o contato direto de WhatsApp do formulário quando o tipo de projeto já está
* escolhido ("Quero conversar sobre um site que traz pedidos pelo WhatsApp."). Os nomes das ofertas, em minúsculas e com artigo,
* que vêm de `offerLabels` (lib/offer-labels.ts), a mesma fonte do nome da oferta e da página dela.
*/
export const OFFER_SUBJECTS = {
site: offerLabels.site.whatsappSubject,
sistema: offerLabels.sistema.whatsappSubject,
revisao: offerLabels.revisao.whatsappSubject,
} as const;
/** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). "O case" concorda com qualquer título: sem ele, saía "o Lavação 047" e "o SEV Moda Íntima". */
export function similarProjectSubject(title: string) {
return `um projeto parecido com o case ${title}`;