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.
307 lines
9.9 KiB
TypeScript
307 lines
9.9 KiB
TypeScript
import sitemap from "../app/sitemap";
|
|
import {
|
|
buildBreadcrumbList,
|
|
buildCaseJsonLd,
|
|
buildOfferServicesJsonLd,
|
|
buildPageSocialMetadata,
|
|
buildPersonJsonLd,
|
|
buildProfilePageJsonLd,
|
|
buildServiceJsonLd,
|
|
buildWebSiteJsonLd,
|
|
getCaseImageUrl,
|
|
SEO_IDS,
|
|
twitterHandle,
|
|
} from "./seo";
|
|
import {
|
|
caseStudies,
|
|
isClientCase,
|
|
serviceOffers,
|
|
servicePages,
|
|
siteName,
|
|
siteUrl,
|
|
} from "./site-content";
|
|
|
|
const representativeService = servicePages[0];
|
|
if (!representativeService) throw new Error("Missing representative service");
|
|
|
|
it("uses one canonical identity across website, profile and service", () => {
|
|
expect(SEO_IDS).toEqual({
|
|
website: `${siteUrl}/#website`,
|
|
person: `${siteUrl}/#person`,
|
|
profilePage: `${siteUrl}/curriculo#profile-page`,
|
|
});
|
|
expect(buildWebSiteJsonLd().publisher).toEqual({ "@id": SEO_IDS.person });
|
|
expect(buildProfilePageJsonLd()).toMatchObject({
|
|
"@id": SEO_IDS.profilePage,
|
|
isPartOf: { "@id": SEO_IDS.website },
|
|
mainEntity: { "@id": SEO_IDS.person },
|
|
});
|
|
expect(buildServiceJsonLd(representativeService).provider).toEqual({
|
|
"@id": SEO_IDS.person,
|
|
});
|
|
});
|
|
|
|
it("keeps the root graph limited to verified website and person fields", () => {
|
|
const website = buildWebSiteJsonLd();
|
|
const person = buildPersonJsonLd();
|
|
|
|
expect(Object.keys(website).sort()).toEqual(
|
|
["@id", "@type", "inLanguage", "name", "publisher", "url"].sort(),
|
|
);
|
|
expect(Object.keys(person).sort()).toEqual(
|
|
[
|
|
"@id",
|
|
"@type",
|
|
"homeLocation",
|
|
"image",
|
|
"jobTitle",
|
|
"knowsAbout",
|
|
"name",
|
|
"sameAs",
|
|
"url",
|
|
].sort(),
|
|
);
|
|
expect(website).toMatchObject({
|
|
"@type": "WebSite",
|
|
"@id": SEO_IDS.website,
|
|
url: siteUrl,
|
|
name: siteName,
|
|
});
|
|
expect(person).toMatchObject({
|
|
"@type": "Person",
|
|
"@id": SEO_IDS.person,
|
|
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/"],
|
|
});
|
|
|
|
expect(JSON.stringify([website, person])).not.toMatch(
|
|
/ProfessionalService|LocalBusiness|GeoCoordinates|PostalAddress|Instagram|telephone|address|geo|priceRange|OfferCatalog|hasOfferCatalog|offers/i,
|
|
);
|
|
});
|
|
|
|
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}`;
|
|
|
|
expect(buildServiceJsonLd(service)).toEqual({
|
|
"@type": "Service",
|
|
"@id": `${pageUrl}#service`,
|
|
url: pageUrl,
|
|
name: service.navLabel,
|
|
description: service.metaDescription,
|
|
provider: { "@id": SEO_IDS.person },
|
|
areaServed: [
|
|
{ "@type": "City", name: "Garuva" },
|
|
{ "@type": "City", name: "Joinville" },
|
|
{ "@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 five offers as Service nodes tied to the person, with the service area and the page of each offer", () => {
|
|
const services = buildOfferServicesJsonLd();
|
|
|
|
expect(services).toHaveLength(5);
|
|
// as mesmas cinco ofertas, 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",
|
|
"Atendimento no WhatsApp com IA sob controle",
|
|
"Revisão de sistema que já existe",
|
|
"Nota fiscal pronta para a reforma tributária",
|
|
]);
|
|
expect(services.map(({ url }) => url)).toEqual([
|
|
`${siteUrl}/servicos/criacao-de-sites`,
|
|
`${siteUrl}/servicos/engenharia-de-software`,
|
|
`${siteUrl}/servicos/atendimento-whatsapp-ia`,
|
|
`${siteUrl}/ia`,
|
|
`${siteUrl}/servicos/nota-fiscal-reforma-tributaria`,
|
|
]);
|
|
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);
|
|
}
|
|
// 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(5);
|
|
expect(ids).toEqual([
|
|
`${siteUrl}/#service-site`,
|
|
`${siteUrl}/#service-sistema`,
|
|
`${siteUrl}/#service-whatsapp`,
|
|
`${siteUrl}/#service-revisao`,
|
|
`${siteUrl}/#service-fiscal`,
|
|
]);
|
|
// 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,
|
|
);
|
|
});
|
|
|
|
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 = [
|
|
["Início", siteUrl],
|
|
["Serviços", `${siteUrl}/servicos`],
|
|
["X", pageUrl],
|
|
] as const;
|
|
const breadcrumb = buildBreadcrumbList(pageUrl, items);
|
|
|
|
expect(breadcrumb["@id"]).toBe(`${pageUrl}#breadcrumb`);
|
|
expect(breadcrumb.itemListElement).toEqual([
|
|
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
|
{
|
|
"@type": "ListItem",
|
|
position: 2,
|
|
name: "Serviços",
|
|
item: `${siteUrl}/servicos`,
|
|
},
|
|
{ "@type": "ListItem", position: 3, name: "X", item: pageUrl },
|
|
]);
|
|
});
|
|
|
|
it("keeps profile data timeless and sitemap output deterministic", () => {
|
|
expect(buildProfilePageJsonLd()).not.toHaveProperty("dateModified");
|
|
|
|
const expectedUrls = [
|
|
siteUrl,
|
|
`${siteUrl}/curriculo`,
|
|
`${siteUrl}/privacidade`,
|
|
`${siteUrl}/servicos`,
|
|
`${siteUrl}/cases`,
|
|
`${siteUrl}/ia`,
|
|
...servicePages.map(({ slug }) => `${siteUrl}/servicos/${slug}`),
|
|
...caseStudies.map(({ id }) => `${siteUrl}/cases/${id}`),
|
|
];
|
|
const first = sitemap();
|
|
const second = sitemap();
|
|
|
|
expect(first.map(({ url }) => url)).toEqual(expectedUrls);
|
|
expect(second).toEqual(first);
|
|
expect(first).toHaveLength(23);
|
|
expect(
|
|
first.filter(({ url }) => url === `${siteUrl}/privacidade`),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("lists the client cases and the AI page, each with a fixed modification date", () => {
|
|
const entries = sitemap();
|
|
const urls = entries.map(({ url }) => url);
|
|
|
|
expect(urls).toContain(`${siteUrl}/cases/sev-modaintima`);
|
|
expect(urls).toContain(`${siteUrl}/cases/lavacao-047`);
|
|
expect(urls).toContain(`${siteUrl}/ia`);
|
|
for (const entry of entries) {
|
|
expect(entry.lastModified).toBeInstanceOf(Date);
|
|
expect(Number.isNaN(new Date(entry.lastModified ?? "").getTime())).toBe(
|
|
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", () => {
|
|
const url = `${siteUrl}/cases`;
|
|
expect(
|
|
buildPageSocialMetadata({
|
|
url,
|
|
title: "Título",
|
|
description: "Descrição",
|
|
type: "website",
|
|
}),
|
|
).toEqual({
|
|
alternates: { canonical: url },
|
|
openGraph: {
|
|
locale: "pt_BR",
|
|
title: "Título",
|
|
description: "Descrição",
|
|
url,
|
|
siteName,
|
|
type: "website",
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title: "Título",
|
|
description: "Descrição",
|
|
creator: twitterHandle,
|
|
},
|
|
});
|
|
});
|