Files
evandro c7d09d63d4
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
Hero e movimento
- cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py):
  marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao
  cursor e, parada no topo, passa sozinha pelos estados com legenda
- três níveis de aparelho (full, lite, static); sem WebGL fica o pôster
- título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
- faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções
- rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa
  compartilhada entre a lista de cases e o case

Ofertas
- atendimento no WhatsApp com IA e nota fiscal para a reforma tributária
  (páginas de serviço, cartões da home, formulário e aviso com data de validade)
- página /ia atualizada, com fontes
- peças 3D em loop nos cartões e topos de página (3d/pecas.py)

Design
- hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta
- um destino por rótulo: "Falar sobre meu projeto" leva ao formulário,
  "Chamar no WhatsApp" abre o WhatsApp
- logo montado em blocos, 404 redesenhado, grades sem célula vazia
- ícones do PWA e manifesto novos

Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
2026-11-10 03:48:25 -03:00

194 lines
5.7 KiB
TypeScript

import type { Metadata } from "next";
import {
type CaseStudyContent,
isClientCase,
type ServicePageContent,
serviceOffers,
siteName,
siteUrl,
} from "./site-content";
export const twitterHandle = "@evandrodevbr";
export const SEO_IDS = {
website: `${siteUrl}/#website`,
person: `${siteUrl}/#person`,
profilePage: `${siteUrl}/curriculo#profile-page`,
} as const;
export function buildWebSiteJsonLd() {
return {
"@type": "WebSite",
"@id": SEO_IDS.website,
url: siteUrl,
name: siteName,
inLanguage: "pt-BR",
publisher: { "@id": SEO_IDS.person },
} as const;
}
export function buildPersonJsonLd() {
return {
"@type": "Person",
"@id": SEO_IDS.person,
name: "Evandro Fonseca Junior",
url: siteUrl,
image: `${siteUrl}/edev.jpg`,
// cargo e áreas vêm de public/portfolio-data.json e estão visíveis em /curriculo e na seção de skills
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
knowsAbout: [
"Next.js",
"React",
"TypeScript",
"Node.js",
"Python",
"IoT",
"Computação em nuvem",
"Inteligência artificial",
],
homeLocation: {
"@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;
}
export function buildProfilePageJsonLd() {
return {
"@type": "ProfilePage",
"@id": SEO_IDS.profilePage,
url: `${siteUrl}/curriculo`,
inLanguage: "pt-BR",
isPartOf: { "@id": SEO_IDS.website },
mainEntity: { "@id": SEO_IDS.person },
} as const;
}
/**
* Á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.
*/
const serviceAreaServed = [
{ "@type": "City", name: "Garuva" },
{ "@type": "City", name: "Joinville" },
{ "@type": "AdministrativeArea", name: "Santa Catarina" },
{ "@type": "Country", name: "Brasil" },
] as const;
/**
* As cinco ofertas 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) {
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
return {
"@type": "Service",
"@id": `${pageUrl}#service`,
url: pageUrl,
name: service.navLabel,
description: service.metaDescription,
provider: { "@id": SEO_IDS.person },
areaServed: serviceAreaServed,
} 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])[],
) {
return {
"@type": "BreadcrumbList",
"@id": `${pageUrl}#breadcrumb`,
itemListElement: items.map(([name, item], index) => ({
"@type": "ListItem" as const,
position: index + 1,
name,
item,
})),
} as const;
}
/**
* Canonical, Open Graph e Twitter de uma página. Quem define `openGraph` ou `twitter` numa página substitui o do layout inteiro,
* então o cartão precisa vir completo daqui; a imagem vem do `opengraph-image` da própria rota.
*/
export function buildPageSocialMetadata({
url,
title,
description,
type,
}: {
url: string;
title: string;
description: string;
type: "website" | "article" | "profile";
}): Pick<Metadata, "alternates" | "openGraph" | "twitter"> {
return {
alternates: { canonical: url },
openGraph: {
locale: "pt_BR",
title,
description,
url,
siteName,
type,
},
twitter: {
card: "summary_large_image",
title,
description,
creator: twitterHandle,
},
};
}