feat(servicos): ofertas no topo de /servicos, capa por assunto e diagramas
/servicos abre com as 3 ofertas e depois outros serviços; H1 de criação de sites e de engenharia com o nome da oferta; capa de cada serviço pelo assunto (SEV fora do próprio case cai de ~11 para 2 imagens); diagrama de fluxo em SVG nos serviços sem case; cases com o que o cliente passou a poder fazer e contato logo depois de Como ficou; FAQ num componente só; lastmod por rota; ProfessionalService com areaServed.
This commit is contained in:
1 parent
e63f3c2ebd
commit
fc44a69fb3
41 files changed
+2486
-550
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
buildCaseJsonLd,
|
||||
buildPageSocialMetadata,
|
||||
buildPersonJsonLd,
|
||||
buildProfessionalServiceJsonLd,
|
||||
buildProfilePageJsonLd,
|
||||
buildServiceJsonLd,
|
||||
buildWebSiteJsonLd,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import {
|
||||
caseStudies,
|
||||
isClientCase,
|
||||
serviceOffers,
|
||||
servicePages,
|
||||
siteName,
|
||||
siteUrl,
|
||||
@@ -27,6 +29,7 @@ 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({
|
||||
@@ -98,6 +101,47 @@ it("builds a canonical service with only the verified service area", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("describes the business as a ProfessionalService with the service area and the three offers as a catalog", () => {
|
||||
const business = buildProfessionalServiceJsonLd();
|
||||
|
||||
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([
|
||||
"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",
|
||||
]);
|
||||
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);
|
||||
}
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
@@ -214,6 +258,10 @@ it("lists the client cases and the AI page, each with a fixed modification date"
|
||||
false,
|
||||
);
|
||||
}
|
||||
// a data é a do conteúdo de cada rota, não uma data única para o mapa inteiro
|
||||
expect(
|
||||
new Set(entries.map(({ lastModified }) => String(lastModified))).size,
|
||||
).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("gives every page a complete social card, so it never inherits the home one", () => {
|
||||
|
||||
+38
@@ -3,6 +3,7 @@ import {
|
||||
type CaseStudyContent,
|
||||
isClientCase,
|
||||
type ServicePageContent,
|
||||
serviceOffers,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "./site-content";
|
||||
@@ -13,6 +14,7 @@ export const SEO_IDS = {
|
||||
website: `${siteUrl}/#website`,
|
||||
person: `${siteUrl}/#person`,
|
||||
profilePage: `${siteUrl}/curriculo#profile-page`,
|
||||
professionalService: `${siteUrl}/#professional-service`,
|
||||
} as const;
|
||||
|
||||
export function buildWebSiteJsonLd() {
|
||||
@@ -64,6 +66,42 @@ export function buildProfilePageJsonLd() {
|
||||
} as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
export function buildServiceJsonLd(service: ServicePageContent) {
|
||||
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Testes de caminhos-base da ordem de `clientCasesForListing` (lib/site-content.ts), com a lista de projetos de
|
||||
* cliente trocada por um dublê de `isClientProject`.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const LISTED = ["lavacao-047", "sc-plus", "as-servicos", "sev-modaintima"];
|
||||
|
||||
function loadWithClients(clientIds: readonly string[] | "all") {
|
||||
let loaded: typeof import("./site-content") | undefined;
|
||||
jest.isolateModules(() => {
|
||||
jest.doMock("./proof", () => ({
|
||||
...jest.requireActual("./proof"),
|
||||
isClientProject: ({ id }: { id: string }) =>
|
||||
clientIds === "all" || clientIds.includes(id),
|
||||
}));
|
||||
loaded = require("./site-content");
|
||||
});
|
||||
jest.dontMock("./proof");
|
||||
if (!loaded) throw new Error("site-content não carregou");
|
||||
return loaded;
|
||||
}
|
||||
|
||||
describe("clientCasesForListing: ordem", () => {
|
||||
it("path: only listed clients -> the fixed order, the SEV last, 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", () => {
|
||||
const { clientCasesForListing, caseStudies } = loadWithClients([
|
||||
...LISTED,
|
||||
"gcc-installer",
|
||||
]);
|
||||
if (!caseStudies.some(({ id }) => id === "gcc-installer"))
|
||||
throw new Error("Missing gcc-installer case");
|
||||
|
||||
expect(clientCasesForListing.map(({ id }) => id)).toEqual([
|
||||
...LISTED,
|
||||
"gcc-installer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("path: several clients outside the list -> they keep the order of caseStudies among themselves (stable sort)", () => {
|
||||
const { clientCasesForListing, caseStudies } = loadWithClients("all");
|
||||
|
||||
const unlisted = clientCasesForListing
|
||||
.map(({ id }) => id)
|
||||
.filter((id) => !LISTED.includes(id));
|
||||
expect(unlisted.length).toBeGreaterThan(1);
|
||||
expect(unlisted).toEqual(
|
||||
caseStudies.map(({ id }) => id).filter((id) => !LISTED.includes(id)),
|
||||
);
|
||||
expect(
|
||||
clientCasesForListing.slice(0, LISTED.length).map(({ id }) => id),
|
||||
).toEqual(LISTED);
|
||||
});
|
||||
|
||||
it("path: no client at all -> an empty list, no crash", () => {
|
||||
const { clientCasesForListing } = loadWithClients([]);
|
||||
|
||||
expect(clientCasesForListing).toEqual([]);
|
||||
});
|
||||
|
||||
it("path: the real data -> the SEV is the last client, as the mosaic and /cases expect", () => {
|
||||
jest.resetModules();
|
||||
const { clientCasesForListing } =
|
||||
jest.requireActual<typeof import("./site-content")>("./site-content");
|
||||
|
||||
expect(clientCasesForListing.at(-1)?.id).toBe("sev-modaintima");
|
||||
});
|
||||
});
|
||||
+173
-11
@@ -2,12 +2,16 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import portfolioData from "@/public/portfolio-data.json";
|
||||
import {
|
||||
buttonLabels,
|
||||
caseStudies,
|
||||
clientCases,
|
||||
clientCasesForListing,
|
||||
getRelatedCases,
|
||||
getRelatedServices,
|
||||
isClientCase,
|
||||
labCases,
|
||||
pageUpdatedAt,
|
||||
serviceOffers,
|
||||
servicePages,
|
||||
} from "./site-content";
|
||||
|
||||
@@ -89,17 +93,25 @@ 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", () => {
|
||||
expect(getService("criacao-de-sites").relatedCaseIds).toEqual(clientIds);
|
||||
it("maps each service to its client cases, with the SEV only where it is the proof: the systems service", () => {
|
||||
expect(getService("criacao-de-sites").relatedCaseIds).toEqual([
|
||||
"lavacao-047",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
]);
|
||||
expect(getService("engenharia-de-software").relatedCaseIds).toEqual([
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
]);
|
||||
expect(getService("consultoria-nextjs").relatedCaseIds).toEqual([
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
"sc-plus",
|
||||
"lavacao-047",
|
||||
]);
|
||||
expect(
|
||||
servicePages
|
||||
.filter(({ relatedCaseIds }) => relatedCaseIds.includes("sev-modaintima"))
|
||||
.map(({ slug }) => slug),
|
||||
).toEqual(["engenharia-de-software"]);
|
||||
// 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({});
|
||||
@@ -112,16 +124,163 @@ it("maps each service to its client cases", () => {
|
||||
|
||||
it("says what the Next.js service cases really prove: sites built by me, not a diagnosis", () => {
|
||||
const { caseRelevance } = getService("consultoria-nextjs");
|
||||
for (const id of ["sev-modaintima", "lavacao-047"]) {
|
||||
expect(caseRelevance[id]).toMatch(/construí em Next\.js 16/);
|
||||
expect(caseRelevance[id]).not.toMatch(/diagn|conheço por dentro/i);
|
||||
expect(caseRelevance["lavacao-047"]).toMatch(/construí em Next\.js 16/);
|
||||
expect(caseRelevance["sc-plus"]).toMatch(/Next\.js 15/);
|
||||
for (const sentence of Object.values(caseRelevance)) {
|
||||
expect(sentence).not.toMatch(/diagn|conheço por dentro/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("is honest that the desktop case is a Windows app made in Python, not in Tauri", () => {
|
||||
expect(getService("tauri-rust").caseRelevance["gcc-installer"]).toMatch(
|
||||
/Windows.*Python/,
|
||||
it("calls the desktop case a personal Windows app project, without claiming it is made in Tauri", () => {
|
||||
const sentence = getService("tauri-rust").caseRelevance["gcc-installer"];
|
||||
expect(sentence).toMatch(/projeto próprio de aplicativo para Windows/);
|
||||
expect(sentence).not.toMatch(/Tauri|Python/);
|
||||
});
|
||||
|
||||
it("opens the two offer services with the name of the offer as the h1, keeping the search title", () => {
|
||||
expect(getService("criacao-de-sites").headline).toBe(serviceOffers[0].name);
|
||||
expect(getService("criacao-de-sites").headline).toBe(
|
||||
"Site que traz pedidos pelo WhatsApp",
|
||||
);
|
||||
expect(getService("engenharia-de-software").headline).toBe(
|
||||
serviceOffers[1].name,
|
||||
);
|
||||
expect(getService("engenharia-de-software").headline).toBe(
|
||||
"Sistema para tirar a operação da planilha",
|
||||
);
|
||||
expect(getService("criacao-de-sites").metaTitle).toBe(
|
||||
"Criação de sites em Joinville e SC",
|
||||
);
|
||||
expect(getService("engenharia-de-software").metaTitle).toBe(
|
||||
"Sistemas web e aplicativos sob medida",
|
||||
);
|
||||
});
|
||||
|
||||
it("picks the cover by subject: sites -> Lavação 047, Next.js -> SC Plus, systems -> SEV on the phone, nothing for the rest", () => {
|
||||
expect(getService("criacao-de-sites").heroCase).toEqual({
|
||||
id: "lavacao-047",
|
||||
device: "desktop",
|
||||
});
|
||||
expect(getService("consultoria-nextjs").heroCase).toEqual({
|
||||
id: "sc-plus",
|
||||
device: "desktop",
|
||||
});
|
||||
expect(getService("engenharia-de-software").heroCase).toEqual({
|
||||
id: "sev-modaintima",
|
||||
device: "mobile",
|
||||
});
|
||||
for (const slug of [
|
||||
"tauri-rust",
|
||||
"integracao-hardware-software-iot",
|
||||
"arquitetura-de-software",
|
||||
]) {
|
||||
expect(getService(slug).heroCase).toBeUndefined();
|
||||
}
|
||||
// a capa é sempre um dos cases relacionados, e nenhuma capa se repete entre os serviços
|
||||
const heroIds = servicePages.flatMap(({ heroCase }) =>
|
||||
heroCase ? [heroCase.id] : [],
|
||||
);
|
||||
expect(new Set(heroIds).size).toBe(heroIds.length);
|
||||
for (const { heroCase, relatedCaseIds } of servicePages) {
|
||||
if (heroCase) expect(relatedCaseIds).toContain(heroCase.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives each service without a client cover a flow diagram instead, and never both", () => {
|
||||
expect(getService("tauri-rust").diagram).toBe("desktop");
|
||||
expect(getService("integracao-hardware-software-iot").diagram).toBe("iot");
|
||||
expect(getService("arquitetura-de-software").diagram).toBe("arquitetura");
|
||||
for (const { heroCase, diagram } of servicePages) {
|
||||
expect(Boolean(heroCase) !== Boolean(diagram)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists the client cases for the mosaic and /cases with the SEV last, never first", () => {
|
||||
expect(clientCasesForListing.map(({ id }) => id)).toEqual([
|
||||
"lavacao-047",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
"sev-modaintima",
|
||||
]);
|
||||
expect(clientCasesForListing).toHaveLength(clientCases.length);
|
||||
});
|
||||
|
||||
it("keeps one list of button labels: 'Falar sobre meu projeto' for contact and 'Ver case' for a case", () => {
|
||||
expect(buttonLabels.contact).toBe("Falar sobre meu projeto");
|
||||
expect(buttonLabels.case).toBe("Ver case");
|
||||
expect(Object.values(buttonLabels).join("|")).not.toMatch(
|
||||
/Ver o case|Ler case|Conversar|oportunidade/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reduces 'direto' and 'diretamente' in the case and service text", () => {
|
||||
const text = JSON.stringify({ caseStudies, servicePages });
|
||||
expect(text.match(/diret[oa]s?|diretamente/gi) ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("explains the three offers of the home with the same names, 3 or 4 deliverables and a place to see them", () => {
|
||||
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",
|
||||
]);
|
||||
expect(serviceOffers.map(({ href }) => href)).toEqual([
|
||||
"/servicos/criacao-de-sites",
|
||||
"/servicos/engenharia-de-software",
|
||||
"/ia",
|
||||
]);
|
||||
for (const offer of serviceOffers) {
|
||||
expect(offer.deliverables.length).toBeGreaterThanOrEqual(3);
|
||||
expect(offer.deliverables.length).toBeLessThanOrEqual(4);
|
||||
expect(
|
||||
offer.proof.caseIds.every((id) => caseStudies.some((c) => c.id === id)),
|
||||
).toBe(true);
|
||||
// sem número, preço, prazo ou depoimento inventados
|
||||
const text = [
|
||||
offer.name,
|
||||
offer.summary,
|
||||
...offer.deliverables,
|
||||
offer.proof.note ?? "",
|
||||
].join(" ");
|
||||
expect(text).not.toMatch(/R\$|\d+\s?%|\d+\s?dias|["“”]/);
|
||||
}
|
||||
expect(serviceOffers[0].proof.caseIds).toEqual([
|
||||
"lavacao-047",
|
||||
"sev-modaintima",
|
||||
]);
|
||||
expect(serviceOffers[1].proof.caseIds).toEqual(["sev-modaintima"]);
|
||||
expect(serviceOffers[1].proof.note).toMatch(/Apolo Energia/);
|
||||
expect(serviceOffers[1].proof.note).toMatch(/uso interno/);
|
||||
expect(serviceOffers[2].proof.caseIds).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(caseStudy.capabilities?.length).toBeLessThanOrEqual(3);
|
||||
expect(JSON.stringify(caseStudy.capabilities)).not.toMatch(
|
||||
/\d+\s?%|R\$|\d+\s?(clientes|pedidos|dias|vezes)/,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
caseStudies.find(({ id }) => id === "lavacao-047")?.capabilities?.[0],
|
||||
).toBe(
|
||||
"O dono muda preços, textos, fotos e o mapa sem pedir a ninguém, e a mudança aparece no site sem nova publicação.",
|
||||
);
|
||||
for (const caseStudy of labCases) {
|
||||
expect(caseStudy.capabilities).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("dates every service, case and fixed page with a plain calendar day for the sitemap", () => {
|
||||
const days = [
|
||||
...servicePages.map(({ updatedAt }) => updatedAt),
|
||||
...caseStudies.map(({ updatedAt }) => updatedAt),
|
||||
...Object.values(pageUpdatedAt),
|
||||
];
|
||||
for (const day of days) expect(day).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
// a política de privacidade informa a própria vigência: a data do mapa é a dela
|
||||
expect(pageUpdatedAt.privacidade).toBe("2026-09-13");
|
||||
});
|
||||
|
||||
it("places the services in Joinville and SC instead of only 'em Garuva'", () => {
|
||||
@@ -164,8 +323,11 @@ it("derives the exact related service set from the existing relationship", () =>
|
||||
expect(getRelatedServices("twins-lab").map(({ slug }) => slug)).toEqual([
|
||||
"integracao-hardware-software-iot",
|
||||
]);
|
||||
expect(getRelatedServices("sev-modaintima").map(({ slug }) => slug)).toEqual([
|
||||
"engenharia-de-software",
|
||||
]);
|
||||
expect(
|
||||
getRelatedServices("sev-modaintima")
|
||||
getRelatedServices("lavacao-047")
|
||||
.map(({ slug }) => slug)
|
||||
.sort(),
|
||||
).toEqual(
|
||||
|
||||
+186
-22
@@ -4,6 +4,25 @@ import { isClientProject } from "./proof";
|
||||
export const siteUrl = "https://evandro.dev.br";
|
||||
export const siteName = "Evandro.dev.br";
|
||||
|
||||
/** Aparelho cuja captura abre a página do serviço: o site no computador (moldura de navegador) ou no celular (moldura de celular). */
|
||||
export type HeroDevice = "desktop" | "mobile";
|
||||
|
||||
/** Esquema de fluxo que ocupa o topo do serviço sem case com captura gravada (app/servicos/_components/ServiceDiagram.tsx). */
|
||||
export type ServiceDiagramKind = "desktop" | "iot" | "arquitetura";
|
||||
|
||||
/**
|
||||
* Rótulos dos botões e links de ação, numa lista só: o contato diz sempre a mesma coisa, e o case também.
|
||||
* O verde-limão fica só no contato; os demais são contorno ou link de texto.
|
||||
*/
|
||||
export const buttonLabels = {
|
||||
contact: "Falar sobre meu projeto",
|
||||
case: "Ver case",
|
||||
site: "Abrir o site",
|
||||
service: "Ver o serviço",
|
||||
allCases: "Ver os cases",
|
||||
allServices: "Ver todos os serviços",
|
||||
} as const;
|
||||
|
||||
export interface ServicePageContent {
|
||||
slug: string;
|
||||
navLabel: string;
|
||||
@@ -12,6 +31,7 @@ export interface ServicePageContent {
|
||||
metaTitle: string;
|
||||
/** Descrição para busca, com até 155 caracteres. */
|
||||
metaDescription: string;
|
||||
/** Título da página (h1). Nos serviços que são uma das ofertas da home, é o nome da oferta. */
|
||||
headline: string;
|
||||
intro: string;
|
||||
answer: string;
|
||||
@@ -21,9 +41,14 @@ export interface ServicePageContent {
|
||||
examples: string[];
|
||||
technicalDetails: string[];
|
||||
stack: string[];
|
||||
ctaLabel: string;
|
||||
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
|
||||
relatedCaseIds: 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. */
|
||||
diagram?: ServiceDiagramKind;
|
||||
/** Data (AAAA-MM-DD) da última mudança no conteúdo da página: vira o `lastmod` do mapa do site. Atualize ao mudar o texto. */
|
||||
updatedAt: string;
|
||||
/** Por que cada case relacionado interessa a este serviço, em uma frase (chave: id do case). */
|
||||
caseRelevance: Record<string, string>;
|
||||
/** Experiência profissional citada junto dos cases, só em texto, sem link nem detalhe interno. */
|
||||
@@ -59,6 +84,10 @@ export interface CaseStudyContent {
|
||||
facts?: string[];
|
||||
/** O que foi feito, escrito como benefício. */
|
||||
deliveries: string[];
|
||||
/** O que o cliente passou a poder fazer (2 ou 3 itens, sem número), só com o que as entregas já sustentam. Sem itens, a seção não aparece. */
|
||||
capabilities?: string[];
|
||||
/** Data (AAAA-MM-DD) da última mudança no conteúdo da página: vira o `lastmod` do mapa do site. Atualize ao mudar o texto. */
|
||||
updatedAt: string;
|
||||
/** Resultado com dado real e autorizado. Sem dado, o campo fica de fora e a seção não aparece. */
|
||||
result?: string[];
|
||||
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos, logo depois do que foi feito). */
|
||||
@@ -77,7 +106,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
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: "Um site que explica seu negócio.",
|
||||
headline: "Site que traz pedidos pelo WhatsApp",
|
||||
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:
|
||||
@@ -109,11 +138,10 @@ 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"],
|
||||
ctaLabel: "Conversar sobre meu site",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"],
|
||||
relatedCaseIds: ["lavacao-047", "sc-plus", "as-servicos"],
|
||||
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||
updatedAt: "2026-10-14",
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Um catálogo com busca e filtros que leva o pedido da cliente direto ao WhatsApp da loja.",
|
||||
"lavacao-047":
|
||||
"Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.",
|
||||
"sc-plus":
|
||||
@@ -146,7 +174,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
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: "Sistemas e apps para o seu trabalho.",
|
||||
headline: "Sistema para tirar a operação da planilha",
|
||||
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.",
|
||||
intro:
|
||||
@@ -178,8 +206,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
"Em aplicativos mobile, a tecnologia é escolhida depois de avaliar os dispositivos e os recursos necessários, como câmera, notificações e uso sem internet.",
|
||||
],
|
||||
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
|
||||
ctaLabel: "Conversar sobre meu sistema ou app",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047"],
|
||||
heroCase: { id: "sev-modaintima", device: "mobile" },
|
||||
updatedAt: "2026-10-14",
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Um sistema com painel para cadastrar produtos, controlar o estoque por tamanho e acompanhar pedidos.",
|
||||
@@ -243,15 +272,14 @@ export const servicePages: ServicePageContent[] = [
|
||||
"Auditoria de metadados, canônicas, sitemap, dados estruturados e links internos, preservando URLs e requisitos de indexação nas mudanças.",
|
||||
],
|
||||
stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"],
|
||||
ctaLabel: "Conversar sobre meu projeto Next.js",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"],
|
||||
relatedCaseIds: ["sc-plus", "lavacao-047"],
|
||||
heroCase: { id: "sc-plus", device: "desktop" },
|
||||
updatedAt: "2026-10-14",
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Uma loja que construí em Next.js 16, com catálogo, busca, estoque e painel.",
|
||||
"lavacao-047":
|
||||
"Um site que construí em Next.js 16, em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.",
|
||||
"sc-plus":
|
||||
"Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.",
|
||||
"lavacao-047":
|
||||
"Um site que construí em Next.js 16, em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.",
|
||||
},
|
||||
faq: [
|
||||
{
|
||||
@@ -315,11 +343,12 @@ export const servicePages: ServicePageContent[] = [
|
||||
"Aplicativos desktop",
|
||||
"Integração com o sistema",
|
||||
],
|
||||
ctaLabel: "Conversar sobre meu aplicativo desktop",
|
||||
relatedCaseIds: ["gcc-installer"],
|
||||
diagram: "desktop",
|
||||
updatedAt: "2026-10-14",
|
||||
caseRelevance: {
|
||||
"gcc-installer":
|
||||
"Um aplicativo para Windows feito por mim, em Python e não em Tauri: lista as versões do compilador, instala e ajusta o PATH em uma única janela.",
|
||||
"Um projeto próprio de aplicativo para Windows: lista as versões do compilador, instala e ajusta o PATH em uma única janela.",
|
||||
},
|
||||
faq: [
|
||||
{
|
||||
@@ -378,8 +407,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
"O Twins Lab, protótipo de laboratório, simula um reator e envia o estado dele a um painel a cada dois segundos, por eventos do servidor.",
|
||||
],
|
||||
stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"],
|
||||
ctaLabel: "Avaliar minha integração",
|
||||
relatedCaseIds: ["twins-lab"],
|
||||
diagram: "iot",
|
||||
updatedAt: "2026-10-14",
|
||||
caseRelevance: {
|
||||
"twins-lab":
|
||||
"Um reator simulado envia seu estado a um painel a cada dois segundos, com o histórico guardado.",
|
||||
@@ -449,8 +479,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
"Integrações",
|
||||
"Sistemas críticos",
|
||||
],
|
||||
ctaLabel: "Conversar sobre um diagnóstico",
|
||||
relatedCaseIds: [],
|
||||
diagram: "arquitetura",
|
||||
updatedAt: "2026-10-14",
|
||||
caseRelevance: {},
|
||||
experienceNote:
|
||||
"Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
|
||||
@@ -482,7 +513,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
metaTitle: "SEV Moda Íntima: loja virtual com WhatsApp",
|
||||
metaDescription:
|
||||
"Loja virtual de moda íntima: a cliente escolhe o tamanho e envia o pedido pelo WhatsApp, e a loja controla produtos e estoque em um painel.",
|
||||
headline: "Loja virtual de moda íntima com pedido direto pelo WhatsApp",
|
||||
headline: "Loja virtual de moda íntima com pedido pelo WhatsApp",
|
||||
summary:
|
||||
"Catálogo digital e loja virtual de moda íntima. A cliente encontra o produto, escolhe o tamanho e envia o pedido pelo WhatsApp. A loja cuida de tudo em um painel próprio.",
|
||||
impact:
|
||||
@@ -499,6 +530,12 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
"Frete calculado pela distância até o endereço da cliente.",
|
||||
"Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.",
|
||||
],
|
||||
capabilities: [
|
||||
"A cliente encontra a peça, escolhe o tamanho e envia o pedido pelo WhatsApp.",
|
||||
"A loja cadastra as peças e acompanha os pedidos em um painel próprio.",
|
||||
"A loja controla o estoque de cada tamanho e sabe o que ainda tem para vender.",
|
||||
],
|
||||
updatedAt: "2026-10-14",
|
||||
stack: [
|
||||
"Next.js 16",
|
||||
"TypeScript",
|
||||
@@ -529,9 +566,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
impact:
|
||||
"O cliente descobre o preço e pede pelo WhatsApp; o dono atualiza o site sem depender de um desenvolvedor.",
|
||||
context:
|
||||
"A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente direto para o WhatsApp.",
|
||||
"A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente ao WhatsApp.",
|
||||
problem:
|
||||
"O cliente chega com perguntas diretas: quanto custa para o meu carro, onde fica e como pedir. O preço muda com o porte do veículo, e o contato é pelo WhatsApp. O dono, por sua vez, precisa mudar preços, textos e fotos quando quiser, sem esperar por um desenvolvedor.",
|
||||
"O cliente chega com perguntas simples: quanto custa para o meu carro, onde fica e como pedir. O preço muda com o porte do veículo, e o contato é pelo WhatsApp. O dono, por sua vez, precisa mudar preços, textos e fotos quando quiser, sem esperar por um desenvolvedor.",
|
||||
deliveries: [
|
||||
"Tabela de preços por porte do veículo: o cliente vê quanto paga pelo seu carro.",
|
||||
"Pedido pelo WhatsApp com mensagem pronta: um toque abre a conversa já com o serviço escolhido.",
|
||||
@@ -541,6 +578,12 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
"Informações organizadas para o Google e para assistentes de IA entenderem o que a lavação faz e onde fica.",
|
||||
"Uma animação de rolagem que leva o carro do sujo ao brilho e perguntas frequentes montadas com os dados reais do negócio.",
|
||||
],
|
||||
capabilities: [
|
||||
"O dono muda preços, textos, fotos e o mapa sem pedir a ninguém, e a mudança aparece no site sem nova publicação.",
|
||||
"O cliente vê o preço pelo porte do veículo e pede o serviço pelo WhatsApp com a mensagem já pronta.",
|
||||
"O cliente confere a galeria de trabalhos de cada serviço antes de chamar.",
|
||||
],
|
||||
updatedAt: "2026-10-14",
|
||||
stack: [
|
||||
"Next.js 16",
|
||||
"React 19",
|
||||
@@ -587,6 +630,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
"A camada de acesso à base externa cancela requisições após oito segundos e separa a busca por nome da busca por código.",
|
||||
],
|
||||
demoUrl: "https://nutriscan.evandro.dev.br",
|
||||
updatedAt: "2026-10-14",
|
||||
},
|
||||
{
|
||||
id: "twins-lab",
|
||||
@@ -617,6 +661,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
"A resposta usa text/event-stream (SSE), envia estados, dados e erros em intervalos de dois segundos e limita a conexão a dez minutos.",
|
||||
"O simulador salva dados e estado no MongoDB e notifica os clientes quando a execução ou o cenário mudam.",
|
||||
],
|
||||
updatedAt: "2026-10-14",
|
||||
},
|
||||
{
|
||||
id: "reddit-explorer",
|
||||
@@ -647,6 +692,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
"O endpoint de filtro valida as datas e o campo de ordenação (votos ou comentários) e responde com erro 400 quando faltam parâmetros ou eles são inválidos.",
|
||||
"O Astro roda com saída de servidor e o adaptador Node em modo standalone.",
|
||||
],
|
||||
updatedAt: "2026-10-14",
|
||||
},
|
||||
{
|
||||
id: "sc-plus",
|
||||
@@ -667,11 +713,17 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
"A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.",
|
||||
deliveries: [
|
||||
"Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.",
|
||||
"Contato direto na própria página.",
|
||||
"Contato na própria página.",
|
||||
"Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.",
|
||||
"Título, descrição e imagem de compartilhamento próprios, para o link aparecer bem nos buscadores e nas redes.",
|
||||
"Dados estruturados que ajudam o Google a entender de quem é a página.",
|
||||
],
|
||||
capabilities: [
|
||||
"Quem chega pela primeira vez vê a instituição, as áreas de atuação e os projetos, e tem o contato na própria página.",
|
||||
"Quem quer apoiar ou participar encontra o caminho na própria página.",
|
||||
"O link aparece com título, descrição e imagem próprios nos buscadores e nas redes.",
|
||||
],
|
||||
updatedAt: "2026-10-14",
|
||||
stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
|
||||
decisions: [
|
||||
"A página publica título, descrição, endereço canônico e imagem de compartilhamento.",
|
||||
@@ -708,6 +760,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
"Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE.",
|
||||
"Os 23 testes automatizados cobrem a lógica do núcleo, que não depende da plataforma.",
|
||||
],
|
||||
updatedAt: "2026-10-14",
|
||||
},
|
||||
{
|
||||
id: "as-servicos",
|
||||
@@ -731,6 +784,12 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
"A qualidade da cena se ajusta ao aparelho: celulares, conexões mais simples e quem prefere menos movimento recebem uma versão mais leve.",
|
||||
"Um painel para controle de projetos, desenvolvido junto com a primeira página do site.",
|
||||
],
|
||||
capabilities: [
|
||||
"A empresa conta a engenharia física e o software na mesma história, em vez de duas listas de serviços sem relação.",
|
||||
"Quem visita no celular ou com conexão mais simples recebe uma versão mais leve da cena.",
|
||||
"A empresa passou a ter um painel para controle de projetos.",
|
||||
],
|
||||
updatedAt: "2026-10-14",
|
||||
stack: ["Next.js 16", "Three.js", "TypeScript"],
|
||||
decisions: [
|
||||
"A cena é gerada em código, sem arquivo 3D pronto: o motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js.",
|
||||
@@ -756,6 +815,111 @@ export const labCases = caseStudies.filter(
|
||||
(caseStudy) => !isClientCase(caseStudy),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const clientListingOrder = [
|
||||
"lavacao-047",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
"sev-modaintima",
|
||||
];
|
||||
|
||||
function listingRank({ id }: { id: string }) {
|
||||
const rank = clientListingOrder.indexOf(id);
|
||||
return rank === -1 ? clientListingOrder.length : rank;
|
||||
}
|
||||
|
||||
export const clientCasesForListing = [...clientCases].sort(
|
||||
(a, b) => listingRank(a) - listingRank(b),
|
||||
);
|
||||
|
||||
/** 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",
|
||||
servicos: "2026-10-14",
|
||||
cases: "2026-10-14",
|
||||
ia: "2026-10-14",
|
||||
curriculo: "2026-10-14",
|
||||
// a própria página informa a vigência (13 de setembro de 2026)
|
||||
privacidade: "2026-09-13",
|
||||
} as const;
|
||||
|
||||
export interface ServiceOffer {
|
||||
id: "site" | "sistema" | "revisao";
|
||||
/** Nome da oferta, o mesmo da home. */
|
||||
name: 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. */
|
||||
deliverables: 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const serviceOffers: ServiceOffer[] = [
|
||||
{
|
||||
id: "site",
|
||||
name: "Site que traz pedidos pelo WhatsApp",
|
||||
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",
|
||||
deliverables: [
|
||||
"Tabela de preços ou catálogo, para o cliente ver o valor antes de chamar.",
|
||||
"Botão de WhatsApp com a mensagem pronta, para o pedido chegar com o serviço escolhido.",
|
||||
"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.",
|
||||
],
|
||||
proof: {
|
||||
label: "Veja funcionando",
|
||||
caseIds: ["lavacao-047", "sev-modaintima"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sistema",
|
||||
name: "Sistema para tirar a operação da planilha",
|
||||
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",
|
||||
deliverables: [
|
||||
"Catálogo e cadastro de produtos em um painel próprio.",
|
||||
"Pedidos e estoque acompanhados em um só lugar, no lugar de planilhas e mensagens.",
|
||||
"Acesso por perfil: cada pessoa vê só o que precisa.",
|
||||
"Uma primeira versão com as funções prioritárias, ampliada em etapas.",
|
||||
],
|
||||
proof: {
|
||||
label: "Veja funcionando",
|
||||
caseIds: ["sev-modaintima"],
|
||||
note: "Na Apolo Energia, desde 2022, desenvolvo 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.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "revisao",
|
||||
name: "Revisão de sistema que já existe, inclusive feito com IA",
|
||||
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.",
|
||||
href: "/ia",
|
||||
deliverables: [
|
||||
"As falhas de acesso a dados: quem consegue ver ou alterar o dado de quem.",
|
||||
"O que dá para aproveitar do que já está pronto.",
|
||||
"O que é melhor refazer, com o motivo de cada parte.",
|
||||
"Um orçamento por escrito para pôr o sistema em produção.",
|
||||
],
|
||||
proof: {
|
||||
label: "Veja como funciona",
|
||||
caseIds: [],
|
||||
note: "A página da revisão mostra o que você recebe, três episódios públicos com plataformas de apps feitos com IA e os cuidados com dados pessoais (LGPD).",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const serviceHubLinks = servicePages.map((service) => ({
|
||||
href: `/servicos/${service.slug}`,
|
||||
label: service.navLabel,
|
||||
|
||||
Reference in new issue
Block a user