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.
190 lines
6.8 KiB
TypeScript
190 lines
6.8 KiB
TypeScript
import { Geist_Mono, Space_Grotesk } from "next/font/google";
|
|
import localFont from "next/font/local";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { caseStudies, serviceHubLinks, servicePages } from "@/lib/site-content";
|
|
import RootLayout, { metadata } from "./layout";
|
|
|
|
/**
|
|
* Metadados do layout (título e sufixo, canônico) e o que ele monta em todas as páginas: o cabeçalho, o rodapé e o botão
|
|
* flutuante do WhatsApp. Cabeçalho e botão recebem os nomes dos serviços e dos cases, lidos de lib/site-content.ts no servidor.
|
|
*/
|
|
|
|
const mockHeaderProps = jest.fn();
|
|
const mockFloatingProps = jest.fn();
|
|
const mockFooterProps = jest.fn();
|
|
|
|
jest.mock("next/font/google", () => ({
|
|
Geist_Mono: jest.fn(() => ({ variable: "font-mono-var" })),
|
|
Space_Grotesk: jest.fn(() => ({ variable: "font-display-var" })),
|
|
}));
|
|
jest.mock("next/font/local", () => ({ __esModule: true, default: jest.fn() }));
|
|
jest.mock("./globals.css", () => ({}));
|
|
jest.mock("./components/cinematic/CinematicOverlays", () => ({
|
|
__esModule: true,
|
|
default: () => null,
|
|
}));
|
|
jest.mock("./components/GoogleAnalytics", () => ({
|
|
__esModule: true,
|
|
default: () => null,
|
|
}));
|
|
jest.mock("./components/Header", () => ({
|
|
Header: (props: unknown) => {
|
|
mockHeaderProps(props);
|
|
return <header data-testid="header" />;
|
|
},
|
|
}));
|
|
jest.mock("./components/Footer", () => ({
|
|
__esModule: true,
|
|
default: (props: unknown) => {
|
|
mockFooterProps(props);
|
|
return <footer data-testid="footer" />;
|
|
},
|
|
}));
|
|
jest.mock("./components/FloatingWhatsApp", () => ({
|
|
__esModule: true,
|
|
default: (props: unknown) => {
|
|
mockFloatingProps(props);
|
|
return <div data-testid="floating-whatsapp" />;
|
|
},
|
|
}));
|
|
|
|
// as fontes são pedidas ao carregar o layout (uma vez, na importação): lê-se o que foi pedido antes de qualquer
|
|
// jest.clearAllMocks
|
|
const monoFontOptions = jest.mocked(Geist_Mono).mock.calls[0]?.[0];
|
|
const displayFontOptions = jest.mocked(Space_Grotesk).mock.calls[0]?.[0];
|
|
const localFontCalls = jest.mocked(localFont).mock.calls.length;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe("metadata", () => {
|
|
it("ends every page title with ' | Evandro.dev.br' and gives the home its own default title", () => {
|
|
expect(metadata.title).toEqual({
|
|
default: "Sites e sistemas em Joinville e SC | Evandro.dev.br",
|
|
template: "%s | Evandro.dev.br",
|
|
});
|
|
});
|
|
|
|
it("keeps the home title, suffix included, within 60 characters", () => {
|
|
const title = metadata.title as { default: string };
|
|
expect(title.default.length).toBeLessThanOrEqual(60);
|
|
});
|
|
|
|
it("does not set a canonical in the layout: only pages that know their own address do", () => {
|
|
// o 404 e as páginas sem canônico próprio não podem herdar o "/" da home
|
|
expect(metadata.alternates).toBeUndefined();
|
|
});
|
|
|
|
it("describes the offer without inventing numbers or promises", () => {
|
|
// o posicionamento do hero: sites e sistemas sob medida (os casos de WhatsApp e planilha ficam nos cartões de oferta)
|
|
expect(metadata.description).toMatch(/^Sites e sistemas sob medida/);
|
|
expect(metadata.description).not.toMatch(/R\$|garantia|\d+ dias/);
|
|
});
|
|
});
|
|
|
|
describe("what the layout mounts", () => {
|
|
function markup() {
|
|
return renderToStaticMarkup(
|
|
<RootLayout>
|
|
<main>conteúdo</main>
|
|
</RootLayout>,
|
|
);
|
|
}
|
|
|
|
it("mounts the floating WhatsApp button for the whole site, outside the home", () => {
|
|
const html = markup();
|
|
|
|
expect(html).toContain('data-testid="floating-whatsapp"');
|
|
expect(html.indexOf("conteúdo")).toBeLessThan(
|
|
html.indexOf('data-testid="floating-whatsapp"'),
|
|
);
|
|
});
|
|
|
|
it("mounts the footer once for the whole site, after the page content, with the service links", () => {
|
|
const html = markup();
|
|
|
|
expect(html.match(/data-testid="footer"/g)).toHaveLength(1);
|
|
expect(html.indexOf("conteúdo")).toBeLessThan(
|
|
html.indexOf('data-testid="footer"'),
|
|
);
|
|
expect(mockFooterProps).toHaveBeenCalledWith({
|
|
serviceLinks: serviceHubLinks,
|
|
});
|
|
expect(serviceHubLinks.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("gives the footer wrapper and the content the ids the mobile menu makes inert", () => {
|
|
const html = markup();
|
|
|
|
expect(html).toMatch(
|
|
/<div id="main-content"[^>]*>(?:(?!<\/div>).)*conteúdo/,
|
|
);
|
|
expect(html).toMatch(
|
|
/<div id="site-footer" class="relative z-10"><footer data-testid="footer"/,
|
|
);
|
|
// o botão flutuante fica fora dos dois: ele tem a própria região (<aside>)
|
|
expect(html.match(/id="site-footer"/g)).toHaveLength(1);
|
|
});
|
|
|
|
it("gives the header and the button the whatsappSubject of each service and the case names, and only those", () => {
|
|
markup();
|
|
|
|
const expected = {
|
|
services: Object.fromEntries(
|
|
servicePages.map((service) => [service.slug, service.whatsappSubject]),
|
|
),
|
|
cases: Object.fromEntries(
|
|
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
|
|
),
|
|
};
|
|
expect(mockFloatingProps).toHaveBeenCalledWith({ contexts: expected });
|
|
expect(mockHeaderProps).toHaveBeenCalledWith({
|
|
whatsAppContexts: expected,
|
|
});
|
|
expect(Object.keys(expected.services).length).toBeGreaterThan(0);
|
|
// a mensagem lê "Quero conversar sobre {assunto}.": nenhum assunto de serviço começa em maiúscula
|
|
for (const subject of Object.values(expected.services)) {
|
|
expect(subject).toMatch(/^[a-zà-ú]/);
|
|
}
|
|
expect(Object.keys(expected.cases).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("keeps the smooth anchors but opens each new page at the top: the html carries data-scroll-behavior, which makes Next turn the smooth scroll off only during the route change", () => {
|
|
// o atributo é o que o Next lê para desligar a rolagem suave só durante a troca de rota (o e2e navigation.spec.ts
|
|
// confere no navegador que a página nova abre em scrollY 0, sem deslizar)
|
|
expect(markup()).toMatch(/<html[^>]* data-scroll-behavior="smooth"/);
|
|
});
|
|
|
|
it("hides the JavaScript-only parts of the page when scripts are off", () => {
|
|
expect(markup()).toContain(".js-only{display:none!important}");
|
|
});
|
|
});
|
|
|
|
describe("fonts", () => {
|
|
it("does not preload Geist Mono (only small labels use it), while the display font keeps its preload", () => {
|
|
expect(monoFontOptions).toEqual(
|
|
expect.objectContaining({ preload: false }),
|
|
);
|
|
expect(displayFontOptions).toBeDefined();
|
|
expect(displayFontOptions).not.toEqual(
|
|
expect.objectContaining({ preload: false }),
|
|
);
|
|
});
|
|
|
|
it("no longer loads the unused Monument Extended font", () => {
|
|
expect(localFontCalls).toBe(0);
|
|
expect(markupClassName()).not.toMatch(/monument/i);
|
|
});
|
|
|
|
function markupClassName() {
|
|
return (
|
|
renderToStaticMarkup(
|
|
<RootLayout>
|
|
<main />
|
|
</RootLayout>,
|
|
).match(/<html[^>]*class="([^"]*)"/)?.[1] ?? ""
|
|
);
|
|
}
|
|
});
|