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.
194 lines
5.7 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|