feat(cases): mídia no topo, títulos de busca curtos e CreativeWork

Case com mídia ao lado do título e play acessível; problema de cada cliente reescrito com base nos dados; serviços relacionados em 2 cards; FAQ antes do próximo passo e bloco 'Onde já apliquei isso'; Apolo citada em IoT e arquitetura. metaTitle até 43 caracteres e descrições até 155 com teste; JSON-LD CreativeWork; imagens de compartilhamento abaixo de 100 KB; 404 e robots corrigidos; server-only nos módulos com node:.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 14:15:25 -03:00
1 parent c9a0b80656
commit e80f73d075
52 files changed
+2593 -439

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
// "server-only" lança fora do servidor do Next; nos testes (jsdom) o módulo não faz nada.
export {};
+74 -8
View File
@@ -1,16 +1,82 @@
import { statSync } from "node:fs";
import { render } from "@testing-library/react";
import { OgCard, readProjectOgBackground } from "./og-card";
import { OG_IMAGE_MAX_BYTES, OgCard, readProjectOgImage } from "./og-card";
import { PROJECT_VIDEO_IDS } from "./project-showcase";
describe("readProjectOgBackground", () => {
it("returns the recorded cover of a showcase project as a JPEG data URI", () => {
const uri = readProjectOgBackground("nutriscan");
expect(uri).toMatch(/^data:image\/jpeg;base64,/);
expect((uri ?? "").length).toBeGreaterThan(5_000);
jest.mock("node:fs", () => {
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
return { ...actual, statSync: jest.fn(actual.statSync) };
});
const mockedStatSync = jest.mocked(statSync);
const actualStatSync =
jest.requireActual<typeof import("node:fs")>("node:fs").statSync;
afterEach(() => {
mockedStatSync.mockImplementation(actualStatSync);
});
/** Largura e altura de um JPEG, lidas do primeiro marcador de quadro (SOF). */
function jpegSize(image: Buffer) {
let offset = 2;
while (offset < image.length) {
const marker = image[offset + 1];
const length = image.readUInt16BE(offset + 2);
if (
marker >= 0xc0 &&
marker <= 0xcf &&
marker !== 0xc4 &&
marker !== 0xc8
) {
return {
height: image.readUInt16BE(offset + 5),
width: image.readUInt16BE(offset + 7),
};
}
offset += 2 + length;
}
throw new Error("JPEG sem marcador de quadro");
}
describe("readProjectOgImage", () => {
it("returns the recorded cover of a showcase project as it is, in JPEG", () => {
const image = readProjectOgImage("nutriscan");
expect(image?.subarray(0, 2).toString("hex")).toBe("ffd8");
expect(image?.length).toBeGreaterThan(5_000);
});
it("serves every recorded cover below 150 KB and in the 1200x630 card size", () => {
expect(OG_IMAGE_MAX_BYTES).toBe(150_000);
for (const id of PROJECT_VIDEO_IDS) {
const image = readProjectOgImage(id);
expect(image).toBeDefined();
expect(image?.length ?? Infinity).toBeLessThan(150_000);
expect(jpegSize(image as Buffer)).toEqual({ width: 1200, height: 630 });
}
});
it("returns undefined for a cover that weighs the limit or more, so the plain card is used", () => {
mockedStatSync.mockReturnValue({
size: OG_IMAGE_MAX_BYTES,
} as ReturnType<typeof statSync>);
expect(readProjectOgImage("nutriscan")).toBeUndefined();
mockedStatSync.mockReturnValue({
size: OG_IMAGE_MAX_BYTES - 1,
} as ReturnType<typeof statSync>);
expect(readProjectOgImage("nutriscan")).toBeDefined();
});
it("returns undefined for projects without a recorded cover", () => {
expect(readProjectOgBackground("twins-lab")).toBeUndefined();
expect(readProjectOgBackground("../../etc/passwd")).toBeUndefined();
expect(readProjectOgImage("twins-lab")).toBeUndefined();
expect(readProjectOgImage("../../etc/passwd")).toBeUndefined();
});
it("returns undefined when the file is missing on disk", () => {
mockedStatSync.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(readProjectOgImage("nutriscan")).toBeUndefined();
});
});
+17 -5
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import "server-only";
import { readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { getOgImagePath, PROJECT_VIDEO_IDS } from "./project-showcase";
@@ -8,13 +9,24 @@ const brandMark = `data:image/png;base64,${readFileSync(
export const OG_SIZE = { width: 1200, height: 630 } as const;
export const OG_CONTENT_TYPE = "image/png";
export const OG_PHOTO_CONTENT_TYPE = "image/jpeg";
/** Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), como data URI; `undefined` se não existir. */
export function readProjectOgBackground(id: string): string | undefined {
/** Teto de uma imagem de compartilhamento: quem recebe o link baixa a imagem a cada prévia, então ela precisa ser leve. */
export const OG_IMAGE_MAX_BYTES = 150_000;
/**
* Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), usada como está na imagem de compartilhamento.
* `undefined` se não existir ou se pesar o teto ou mais: nesse caso quem chama gera o cartão simples, sem imagem de fundo.
*/
export function readProjectOgImage(id: string): Buffer | undefined {
if (!PROJECT_VIDEO_IDS.includes(id)) return undefined;
const path = join(process.cwd(), "public", getOgImagePath(id));
if (!existsSync(path)) return undefined;
return `data:image/jpeg;base64,${readFileSync(path).toString("base64")}`;
try {
if (statSync(path).size >= OG_IMAGE_MAX_BYTES) return undefined;
return readFileSync(path);
} catch {
return undefined;
}
}
export function OgCard({
+41
View File
@@ -0,0 +1,41 @@
import { portfolio } from "./portfolio-data";
describe("public/portfolio-data.json", () => {
it("writes the Innovo company name with the correct accents and case", () => {
const innovo = portfolio.experiences.find(
({ id }) => id === "innovo-tecnologia",
);
expect(innovo?.company).toBe("Innovo Tecnologia da Informação Ltda.");
expect(JSON.stringify(portfolio)).not.toMatch(/Informacao|Da Informa/);
});
it("keeps the kind of every project, which separates client work from the laboratory", () => {
expect(
portfolio.projects.map(({ id, kind }) => [id, kind] as const),
).toEqual([
["sev-modaintima", "cliente"],
["lavacao-047", "cliente"],
["nutriscan", "laboratorio"],
["twins-lab", "laboratorio"],
["reddit-explorer", "laboratorio"],
["sc-plus", "cliente"],
["gcc-installer", "laboratorio"],
["as-servicos", "cliente"],
]);
});
it("describes each project in a subtitle a business owner understands", () => {
const subtitles = Object.fromEntries(
portfolio.projects.map(({ id, subtitle }) => [id, subtitle]),
);
expect(subtitles["sc-plus"]).toBe(
"Site institucional com áreas, projetos e contato",
);
expect(subtitles["gcc-installer"]).toBe(
"Instalador do compilador GCC para Windows",
);
expect(Object.values(subtitles).join(" ")).not.toMatch(
/Next\.js|MongoDB|MinGW|Gêmeo Digital|gerada em código/,
);
});
});
+1
View File
@@ -1,3 +1,4 @@
import "server-only";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
+66 -1
View File
@@ -1,15 +1,23 @@
import sitemap from "../app/sitemap";
import {
buildBreadcrumbList,
buildCaseJsonLd,
buildPageSocialMetadata,
buildPersonJsonLd,
buildProfilePageJsonLd,
buildServiceJsonLd,
buildWebSiteJsonLd,
getCaseImageUrl,
SEO_IDS,
twitterHandle,
} from "./seo";
import { caseStudies, servicePages, siteName, siteUrl } from "./site-content";
import {
caseStudies,
isClientCase,
servicePages,
siteName,
siteUrl,
} from "./site-content";
const representativeService = servicePages[0];
if (!representativeService) throw new Error("Missing representative service");
@@ -89,6 +97,63 @@ it("builds a canonical service with only the verified service area", () => {
});
});
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
it("describes a client case as a CreativeWork made by the person for the client", () => {
const caseStudy = getCase("sev-modaintima");
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
expect(buildCaseJsonLd(caseStudy)).toEqual({
"@type": "CreativeWork",
"@id": `${pageUrl}#creative-work`,
name: "SEV Moda Íntima: Catálogo digital e loja virtual",
description: caseStudy.metaDescription,
url: pageUrl,
image: `${pageUrl}/opengraph-image/${caseStudy.id}`,
inLanguage: "pt-BR",
creator: { "@id": SEO_IDS.person },
sourceOrganization: {
"@type": "Organization",
name: "SEV Moda Íntima",
url: "https://sevmodaintima.com.br",
},
keywords:
"Next.js 16, TypeScript, PostgreSQL, Prisma, Tailwind CSS 4, Framer Motion",
});
});
it("builds a CreativeWork for every case, with the client only on client work", () => {
for (const caseStudy of caseStudies) {
const work = buildCaseJsonLd(caseStudy);
expect(work).toMatchObject({
"@type": "CreativeWork",
url: `${siteUrl}/cases/${caseStudy.id}`,
image: getCaseImageUrl(caseStudy.id),
creator: { "@id": SEO_IDS.person },
});
expect(work.name).toContain(caseStudy.title);
expect(work.description.length).toBeGreaterThan(40);
expect(work.keywords).toBe(caseStudy.stack.join(", "));
expect("sourceOrganization" in work).toBe(isClientCase(caseStudy));
}
});
it("omits the client address when a client case has no public site", () => {
const work = buildCaseJsonLd(getCase("as-servicos")) as {
url: string;
sourceOrganization?: Record<string, unknown>;
};
expect(work.sourceOrganization).toStrictEqual({
"@type": "Organization",
name: "AS Serviços",
});
expect(work.url).toBe(`${siteUrl}/cases/as-servicos`);
});
it("builds contiguous breadcrumbs from readonly tuples", () => {
const pageUrl = `${siteUrl}/servicos/x`;
const items = [
+38 -1
View File
@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { type ServicePageContent, siteName, siteUrl } from "./site-content";
import {
type CaseStudyContent,
isClientCase,
type ServicePageContent,
siteName,
siteUrl,
} from "./site-content";
export const twitterHandle = "@evandrodevbr";
@@ -78,6 +84,37 @@ export function buildServiceJsonLd(service: ServicePageContent) {
} as const;
}
/** Imagem de compartilhamento do case: a mesma que as redes recebem em `og:image` (rota opengraph-image do case). */
export function getCaseImageUrl(id: string) {
return `${siteUrl}/cases/${id}/opengraph-image/${id}`;
}
/** Trabalho do case como `CreativeWork`: autor ligado à pessoa e, nos projetos de cliente, o cliente em nome de quem foi feito. */
export function buildCaseJsonLd(caseStudy: CaseStudyContent) {
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
return {
"@type": "CreativeWork",
"@id": `${pageUrl}#creative-work`,
name: `${caseStudy.title}: ${caseStudy.subtitle}`,
description: caseStudy.metaDescription,
url: pageUrl,
image: getCaseImageUrl(caseStudy.id),
inLanguage: "pt-BR",
creator: { "@id": SEO_IDS.person },
...(isClientCase(caseStudy)
? {
sourceOrganization: {
"@type": "Organization",
name: caseStudy.title,
...(caseStudy.demoUrl ? { url: caseStudy.demoUrl } : {}),
},
}
: {}),
keywords: caseStudy.stack.join(", "),
};
}
export function buildBreadcrumbList(
pageUrl: string,
items: readonly (readonly [name: string, url: string])[],
+36
View File
@@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
const root = join(__dirname, "..");
describe("fronteira entre servidor e cliente", () => {
it.each([
"app/cases/_components/case-media.ts",
"lib/og-card.tsx",
"lib/portfolio-data.server.ts",
])("%s só pode ser importado no servidor", (file) => {
const source = readFileSync(join(root, file), "utf8");
expect(source).toMatch(/^import "server-only";$/m);
});
it("declara server-only como dependência", () => {
const manifest = JSON.parse(
readFileSync(join(root, "package.json"), "utf8"),
) as { dependencies: Record<string, string> };
expect(manifest.dependencies["server-only"]).toBeDefined();
});
it("não deixa nenhum componente de cliente importar um módulo só de servidor", () => {
const serverOnly =
/from "(?:@\/lib\/og-card|@\/lib\/portfolio-data\.server|(?:\.{1,2}\/)+(?:[\w-]+\/)*case-media)"/;
const clientFiles = [
"app/cases/_components/CasePlayer.tsx",
"app/curriculo/CvDownloadLink.tsx",
];
for (const file of clientFiles) {
const source = readFileSync(join(root, file), "utf8");
expect(source).toMatch(/^"use client";/);
expect(source).not.toMatch(serverOnly);
}
});
});
+85 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import portfolioData from "@/public/portfolio-data.json";
import {
caseStudies,
clientCases,
@@ -77,7 +78,7 @@ it("leaves the commercial offer of every service empty for the owner to fill in"
it("mentions the region and a benefit in the title and description of every service", () => {
for (const service of servicePages) {
expect(`${service.seoTitle} ${service.metaDescription}`).toMatch(
expect(`${service.metaTitle} ${service.metaDescription}`).toMatch(
/Garuva|Joinville|Santa Catarina/,
);
expect(service.metaDescription).toMatch(/remoto|Brasil/);
@@ -148,6 +149,89 @@ it("resolves every declared related case ID", () => {
expect(unresolved).toEqual([]);
});
it("only cites project ids that exist in public/portfolio-data.json", () => {
const projectIds = new Set(portfolioData.projects.map(({ id }) => id));
const cited = [
...caseStudies.map(({ id }) => id),
...servicePages.flatMap(({ relatedCaseIds }) => relatedCaseIds),
...servicePages.flatMap(({ caseRelevance }) => Object.keys(caseRelevance)),
];
expect(cited.length).toBeGreaterThan(0);
expect(cited.filter((id) => !projectIds.has(id))).toEqual([]);
});
it("writes one sentence on why each related case matters to its service", () => {
for (const service of servicePages) {
expect(Object.keys(service.caseRelevance).sort()).toEqual(
[...service.relatedCaseIds].sort(),
);
for (const sentence of Object.values(service.caseRelevance)) {
expect(sentence.length).toBeGreaterThan(30);
expect(sentence).toMatch(/[.]$/);
}
}
});
it("cites the Apolo Energia experience, from 2022, only on the IoT and architecture services", () => {
const apolo = portfolioData.experiences.find(
({ id }) => id === "apolo-energia",
);
expect(apolo?.period).toMatch(/2022/);
const withNote = servicePages
.filter(({ experienceNote }) => experienceNote)
.map(({ slug }) => slug);
expect(withNote).toEqual([
"integracao-hardware-software-iot",
"arquitetura-de-software",
]);
for (const slug of withNote) {
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);
}
});
it("keeps the problem of every client case concrete, with no invented figure or quote", () => {
for (const caseStudy of clientCases) {
expect(caseStudy.problem.length).toBeGreaterThan(200);
expect(caseStudy.problem).not.toMatch(/["“”]/);
expect(caseStudy.problem).not.toMatch(/\d+\s?%|R\$\s?\d/);
}
expect(
caseStudies.find(({ id }) => id === "sev-modaintima")?.problem,
).toMatch(/tamanho/);
expect(
caseStudies.find(({ id }) => id === "sev-modaintima")?.problem,
).toMatch(/WhatsApp/);
});
it("leaves the optional facts of every case empty until there is real data", () => {
for (const caseStudy of caseStudies) {
expect(caseStudy.facts ?? []).toEqual([]);
}
});
it("writes project subtitles without technical jargon", () => {
const jargon =
/Next\.js|React|MongoDB|MinGW|\/GCC|tkinter|\bSSE\b|Three\.js|em código|r\/artificial/i;
for (const caseStudy of caseStudies) {
expect(caseStudy.subtitle).not.toMatch(jargon);
}
for (const project of portfolioData.projects) {
expect(project.subtitle).not.toMatch(jargon);
}
expect(
portfolioData.projects.find(({ id }) => id === "sc-plus")?.subtitle,
).toBe("Site institucional com áreas, projetos e contato");
});
it("does not publish the unavailable Twins Lab demo", () => {
expect(
caseStudies.find((item) => item.id === "twins-lab")?.demoUrl,
+102 -27
View File
@@ -8,7 +8,9 @@ export interface ServicePageContent {
slug: string;
navLabel: string;
eyebrow: string;
seoTitle: string;
/** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
metaTitle: string;
/** Descrição para busca, com até 155 caracteres. */
metaDescription: string;
headline: string;
intro: string;
@@ -22,6 +24,10 @@ export interface ServicePageContent {
ctaLabel: string;
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
relatedCaseIds: 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. */
experienceNote?: string;
/** Oferta: preencha só com valores reais. O bloco "Investimento e prazo" só aparece quando algum campo existe. */
startingPrice?: string;
typicalTimeline?: string;
@@ -35,9 +41,13 @@ export interface CaseStudyContent {
title: string;
/** Tipo do projeto, em poucas palavras. */
subtitle: string;
/** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
metaTitle: string;
/** Descrição para busca, com até 155 caracteres. */
metaDescription: string;
/** Título da página, em linguagem de negócio. */
headline: string;
/** Resumo de uma ou duas frases (cards, descrição de busca e abertura da página). */
/** Resumo de uma ou duas frases (cards e abertura da página). */
summary: string;
/** O que muda para quem usa, em uma frase. */
impact: string;
@@ -45,11 +55,13 @@ export interface CaseStudyContent {
context: string;
/** O problema que o projeto resolve. */
problem: string;
/** Fatos verificáveis do projeto, como "No ar desde ...". Só aparece na página quando há algum; sem dado real, fica de fora. */
facts?: string[];
/** O que foi feito, escrito como benefício. */
deliveries: 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 no fim da página). */
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos, logo depois do que foi feito). */
stack: string[];
decisions: string[];
demoUrl?: string | null;
@@ -62,9 +74,9 @@ export const servicePages: ServicePageContent[] = [
slug: "criacao-de-sites",
navLabel: "Sites profissionais",
eyebrow: "Presença digital",
seoTitle: "Criação de Sites para Empresas em Garuva e Joinville",
metaTitle: "Criação de sites para empresas em Garuva",
metaDescription:
"Site profissional para apresentar sua empresa e levar o cliente ao seu WhatsApp. Desenvolvido em Garuva-SC, com atendimento em Joinville, região e remoto no Brasil.",
"Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Feito em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
headline: "Um site que explica seu negócio.",
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.",
@@ -99,6 +111,16 @@ export const servicePages: ServicePageContent[] = [
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
ctaLabel: "Conversar sobre meu site",
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"],
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":
"Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.",
"as-servicos":
"Um site institucional que conta duas frentes da empresa em uma só história, com cena 3D na rolagem.",
},
faq: [
{
question: "Quanto custa e o que preciso ter pronto?",
@@ -121,9 +143,9 @@ export const servicePages: ServicePageContent[] = [
slug: "engenharia-de-software",
navLabel: "Sistemas web e aplicativos mobile",
eyebrow: "Engenharia de software",
seoTitle: "Sistemas Web e Aplicativos sob Medida em Santa Catarina",
metaTitle: "Sistemas web e aplicativos sob medida",
metaDescription:
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas em um só lugar. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Desenvolvedor em Garuva-SC, atende Joinville e remoto no Brasil.",
headline: "Sistemas e apps para o seu trabalho.",
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.",
@@ -158,6 +180,12 @@ export const servicePages: ServicePageContent[] = [
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
ctaLabel: "Conversar sobre meu sistema ou app",
relatedCaseIds: ["sev-modaintima", "lavacao-047"],
caseRelevance: {
"sev-modaintima":
"Um sistema com painel para cadastrar produtos, controlar o estoque por tamanho e acompanhar pedidos.",
"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.",
},
faq: [
{
question: "Preciso saber qual tecnologia usar?",
@@ -180,9 +208,9 @@ export const servicePages: ServicePageContent[] = [
slug: "consultoria-nextjs",
navLabel: "Melhorias em sites e sistemas Next.js",
eyebrow: "Consultoria Next.js",
seoTitle: "Consultoria Next.js para Melhorar Seu Site ou Sistema",
metaTitle: "Consultoria Next.js para site ou sistema",
metaDescription:
"Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Atendimento a partir de Garuva-SC, em Joinville e remoto no Brasil.",
"Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Garuva-SC, Joinville e remoto no Brasil.",
headline: "Seu projeto Next.js pode funcionar melhor.",
answer:
"Analiso sites e sistemas feitos em Next.js para entender lentidão, falhas ou dificuldades de manutenção. Você recebe prioridades explicadas, um plano de correção e, quando contratado, a implementação das melhorias.",
@@ -217,6 +245,14 @@ export const servicePages: ServicePageContent[] = [
stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"],
ctaLabel: "Conversar sobre meu projeto Next.js",
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"],
caseRelevance: {
"sev-modaintima":
"Um projeto em Next.js 16 com catálogo, busca, estoque e painel, que conheço por dentro.",
"lavacao-047":
"Um projeto 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.",
},
faq: [
{
question: "Não sei se meu site usa Next.js. Posso conversar?",
@@ -239,9 +275,9 @@ export const servicePages: ServicePageContent[] = [
slug: "tauri-rust",
navLabel: "Aplicativos desktop para Windows e Linux",
eyebrow: "Aplicativos desktop",
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
metaTitle: "Aplicativos desktop para Windows e Linux",
metaDescription:
"Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da sua empresa. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
"Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Desenvolvedor em Garuva-SC, atendimento remoto no Brasil.",
headline: "Aplicativos para Windows e Linux.",
answer:
"Desenvolvo aplicativos desktop para trabalhar no computador, lidar com arquivos e integrar recursos locais. Windows e Linux são as plataformas atendidas; as versões e distribuições compatíveis são combinadas em cada projeto.",
@@ -281,6 +317,7 @@ export const servicePages: ServicePageContent[] = [
],
ctaLabel: "Conversar sobre meu aplicativo desktop",
relatedCaseIds: [],
caseRelevance: {},
faq: [
{
question: "O mesmo aplicativo pode funcionar em Windows e Linux?",
@@ -303,9 +340,9 @@ export const servicePages: ServicePageContent[] = [
slug: "integracao-hardware-software-iot",
navLabel: "Integração de equipamentos e sistemas",
eyebrow: "Hardware, software e IoT",
seoTitle: "Integração de Equipamentos e Sistemas (IoT) em Santa Catarina",
metaTitle: "Integração de equipamentos e sistemas (IoT)",
metaDescription:
"Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
"Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Garuva-SC, Joinville e remoto no Brasil.",
headline: "Dados dos equipamentos no seu sistema.",
answer:
"Conecto equipamentos e software para transformar leituras e eventos em informação útil para a operação. Primeiro vemos o que cada dispositivo permite acessar; a integração segue essas interfaces e os requisitos de segurança e funcionamento.",
@@ -340,6 +377,12 @@ export const servicePages: ServicePageContent[] = [
stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"],
ctaLabel: "Avaliar minha integração",
relatedCaseIds: ["twins-lab"],
caseRelevance: {
"twins-lab":
"Um reator simulado envia seu estado a um painel a cada dois segundos, com o histórico guardado.",
},
experienceNote:
"Desde 2022 trabalho com isso na Apolo Energia: sistemas internos, aplicativos, APIs e integração de dispositivos IoT para a troca de dados entre serviços e parceiros. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
faq: [
{
question: "Qualquer equipamento pode ser conectado?",
@@ -362,10 +405,9 @@ export const servicePages: ServicePageContent[] = [
slug: "arquitetura-de-software",
navLabel: "Diagnóstico e arquitetura de software",
eyebrow: "Diagnóstico e decisão",
seoTitle:
"Diagnóstico e Arquitetura de Software para Decidir com Segurança",
metaTitle: "Diagnóstico e arquitetura de software",
metaDescription:
"Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano de ação, de Garuva-SC para Joinville e todo o Brasil.",
"Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano, de Garuva-SC para todo o Brasil.",
headline: "Entenda o problema antes de mudar tudo.",
answer:
"Analiso como um sistema está organizado e por que ficou difícil de manter ou evoluir. Você recebe um diagnóstico explicado, prioridades e um plano de mudança para decidir o que corrigir, preservar ou substituir.",
@@ -406,6 +448,12 @@ export const servicePages: ServicePageContent[] = [
],
ctaLabel: "Conversar sobre um diagnóstico",
relatedCaseIds: ["sev-modaintima"],
caseRelevance: {
"sev-modaintima":
"Uma loja completa, com catálogo, pedidos, estoque e painel, em que as decisões técnicas estão explicadas no case.",
},
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.",
faq: [
{
question: "Preciso ter uma equipe técnica para contratar?",
@@ -431,6 +479,9 @@ export const caseStudies: CaseStudyContent[] = [
id: "sev-modaintima",
title: "SEV Moda Íntima",
subtitle: "Catálogo digital e loja virtual",
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",
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.",
@@ -439,7 +490,7 @@ export const caseStudies: CaseStudyContent[] = [
context:
"A SEV Moda Íntima vende moda íntima pela internet. O site funciona como vitrine e loja virtual: apresenta os produtos, recebe os pedidos pelo WhatsApp e dá à loja um painel para cuidar do catálogo.",
problem:
"Uma loja de moda íntima tem muitos produtos, em vários modelos e tamanhos. A cliente precisa achar o que procura com facilidade e fechar o pedido sem complicação. A loja precisa manter catálogo, estoque e pedidos organizados.",
"A loja vende moda íntima em várias peças e tamanhos. A cliente precisa achar o que procura entre as opções e escolher a medida certa, e o pedido segue pelo WhatsApp, com o frete calculado pela distância. A loja, por sua vez, precisa saber o que ainda tem de cada tamanho.",
deliveries: [
"Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.",
"Filtros e busca inteligente: a cliente encontra o que procura e os itens mais relevantes aparecem primeiro.",
@@ -468,6 +519,9 @@ export const caseStudies: CaseStudyContent[] = [
id: "lavacao-047",
title: "Lavação 047",
subtitle: "Site e painel para lavação automotiva",
metaTitle: "Lavação 047: site com preços e WhatsApp",
metaDescription:
"Site da Lavação 047, em Garuva-SC: preço pelo porte do veículo, pedido pelo WhatsApp e painel para o dono editar tudo sozinho.",
headline:
"Site com tabela de preços e pedido pelo WhatsApp para uma lavação em Garuva",
summary:
@@ -477,7 +531,7 @@ export const caseStudies: CaseStudyContent[] = [
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.",
problem:
"Quem procura uma lavação quer saber quanto custa para o seu veículo e como chegar. O dono, por sua vez, precisa manter preços, textos e fotos em dia sem depender de alguém a cada mudança.",
"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.",
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.",
@@ -508,6 +562,9 @@ export const caseStudies: CaseStudyContent[] = [
id: "nutriscan",
title: "NutriScan",
subtitle: "Consulta de produtos por nome e código de barras",
metaTitle: "NutriScan: consulta de alimentos",
metaDescription:
"Consulta de alimentos por nome ou código de barras, com cache próprio e a base pública Open Food Facts. Projeto próprio, no ar para teste.",
headline: "Consulta de alimentos por nome ou código de barras",
summary:
"Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando um cache próprio com a base pública Open Food Facts.",
@@ -534,7 +591,10 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "twins-lab",
title: "Twins Lab",
subtitle: "Protótipo de laboratório de gêmeo digital industrial",
subtitle: "Painel ao vivo de um reator simulado",
metaTitle: "Twins Lab: painel de reator simulado",
metaDescription:
"Protótipo de painel ao vivo que mostra a simulação de um reator industrial, com acesso por login e histórico guardado. Projeto próprio.",
headline:
"Painel ao vivo para acompanhar a simulação de um reator industrial",
summary:
@@ -561,7 +621,10 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "reddit-explorer",
title: "Reddit Posts Explorer",
subtitle: "Posts do r/artificial em MongoDB, filtrados por período",
subtitle: "Posts de um fórum de IA, filtrados por período",
metaTitle: "Reddit Posts Explorer: coleta e filtros",
metaDescription:
"Coleta os posts em alta do r/artificial sem duplicar e permite filtrar por período e ordenar por votos ou comentários. Projeto próprio.",
headline:
"Posts de um fórum de inteligência artificial, coletados e filtrados por período",
summary:
@@ -588,7 +651,10 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "sc-plus",
title: "SC Plus Aquicultura",
subtitle: "Página institucional",
subtitle: "Site institucional com áreas, projetos e contato",
metaTitle: "SC Plus Aquicultura: página institucional",
metaDescription:
"Página institucional da SC Plus Aquicultura, com áreas de atuação, projetos, apoiadores e contato, preparada para os buscadores.",
headline:
"Página institucional para a SC Plus Aquicultura ser encontrada na internet",
summary:
@@ -596,9 +662,9 @@ export const caseStudies: CaseStudyContent[] = [
impact:
"A instituição ganhou uma página própria, preparada para ser encontrada nos buscadores.",
context:
"A SC Plus Aquicultura é uma instituição do setor de aquicultura. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.",
"A SC Plus Aquicultura é uma instituição de Garuva, em Santa Catarina, que atua com educação, ciência e tecnologia sustentável. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.",
problem:
"Sem uma página própria, quem procura a SC Plus na internet não encontra uma apresentação clara do que a instituição faz. Faltava um endereço próprio, com as áreas, os projetos, os apoiadores e o contato.",
"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.",
"Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.",
@@ -616,7 +682,10 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "gcc-installer",
title: "MinGW GCC Installer",
subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows",
subtitle: "Instalador do compilador GCC para Windows",
metaTitle: "MinGW GCC Installer: instalador no Windows",
metaDescription:
"Aplicativo para Windows que lista as versões do MinGW-w64, instala o compilador GCC e ajusta o PATH, tudo em uma janela. Projeto próprio.",
headline: "Instalador do compilador GCC para Windows em uma única janela",
summary:
"Aplicativo para Windows que lista as versões do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e deixa o GCC pronto para uso, tudo em uma janela.",
@@ -644,6 +713,9 @@ export const caseStudies: CaseStudyContent[] = [
id: "as-servicos",
title: "AS Serviços",
subtitle: "Site institucional com cena 3D",
metaTitle: "AS Serviços: site com cena 3D",
metaDescription:
"Site institucional da AS Serviços com uma cena 3D que acompanha a rolagem e conta a engenharia física e o software numa só história.",
headline: "Site institucional com uma cena 3D que acompanha a rolagem",
summary:
"Site institucional da AS Serviços com uma cena 3D criada em código: uma casa e uma placa-mãe desenhadas em linhas que reagem à rolagem da página.",
@@ -652,7 +724,7 @@ export const caseStudies: CaseStudyContent[] = [
context:
"A AS Serviços reúne engenharia física e software. O site precisava mostrar as duas frentes na mesma história.",
problem:
"Como contar, em uma única página, duas frentes tão diferentes quanto a engenharia física e o software, de um jeito que prenda a atenção de quem rola a página?",
"A empresa reúne duas frentes bem diferentes: a engenharia física, de um lado, e o software, do outro. Em um site comum, as duas podem virar uma lista de serviços sem relação entre si. O desafio era mostrar as duas na mesma história, de um jeito que prenda a atenção de quem rola a página e que continue leve em celulares e conexões mais simples.",
deliveries: [
"Uma cena 3D que acompanha a rolagem: casa, instalação elétrica e placa-mãe aparecem desenhadas em linhas.",
"As duas frentes da empresa, a física e a digital, aparecem na mesma história visual.",
@@ -670,10 +742,13 @@ export const caseStudies: CaseStudyContent[] = [
},
];
const clientProjectIds = new Set(
portfolio.projects.filter(isClientProject).map(({ id }) => id),
);
/** Projetos feitos para clientes ficam à frente; os pessoais e de estudo vão para o Laboratório. */
export function isClientCase(caseStudy: { id: string }) {
const project = portfolio.projects.find(({ id }) => id === caseStudy.id);
return project ? isClientProject(project) : false;
return clientProjectIds.has(caseStudy.id);
}
export const clientCases = caseStudies.filter(isClientCase);