Compare commits

..
Author SHA1 Message Date
Evandro Fonseca Junior 93351892b9 test(ci): prove portfolio runner isolation
release-isolation-portfolio / privileged (push) Successful in 3s
release-isolation-portfolio / socket-bind (push) Successful in 2s
release-isolation-portfolio / baseline (push) Successful in 3s
2026-11-12 20:21:44 -03:00
481 changed files with 59 additions and 61938 deletions

No files matched your search

-11
View File
@@ -1,11 +0,0 @@
# Texto sempre com LF no repositório e na cópia de trabalho (o Windows com core.autocrlf=true gerava CRLF e o Biome acusava)
* text=auto eol=lf
# Binários: nunca converter quebras de linha
*.mp4 binary
*.webm binary
*.webp binary
*.jpg binary
*.png binary
*.ico binary
*.pdf binary
-107
View File
@@ -1,107 +0,0 @@
# CI e deploy num arquivo só: o Gitea lê .gitea/workflows e ignora .github/workflows quando esta pasta existe.
# - verify: lint, tipos, testes e build. O deploy só roda se ele passar.
# - audit: alertas de segurança das dependências (fica vermelho sozinho, sem travar o deploy).
# - e2e: testes de ponta a ponta na build de produção (informativo).
# - deploy: Vercel CLI. Branch padrão -> produção; demais branches -> preview; pull requests não publicam.
# Token: secret VERCEL_TOKEN de nível de usuário no Gitea. Projeto na Vercel = nome do repo
# (ou a variável VERCEL_PROJECT do repo). O build roda na Vercel; o runner só envia o código.
name: CI
"on":
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --runInBand
- run: pnpm build
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm audit --prod --audit-level=high
- run: pnpm audit --dev --audit-level=high
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm build
- name: Subir o servidor de produção
run: pnpm start -p 3001 &
- name: Esperar a porta 3001
run: |
for attempt in $(seq 1 60); do
if curl --silent --fail --output /dev/null http://localhost:3001/; then
exit 0
fi
sleep 2
done
echo "O servidor não respondeu na porta 3001 em 120 s" >&2
exit 1
- name: Testes de ponta a ponta
run: pnpm test:e2e
env:
BASE_URL: http://localhost:3001
- name: Publicar test-results/
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: e2e-test-results
path: test-results/
if-no-files-found: ignore
retention-days: 7
deploy:
needs: verify
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_SCOPE: asemlk54-1928s-projects
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
# versão fixa do CLI: um pacote sem versão num pipeline de produção é risco de cadeia de suprimentos
- run: npm install --global [email protected]
- name: Deploy
run: |
PROJECT=$(echo "${{ vars.VERCEL_PROJECT || github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
if [ "${{ github.ref_name }}" = "${{ github.event.repository.default_branch }}" ]; then
PROD=--prod
else
PROD=
fi
vercel link --yes --project "$PROJECT" --scope "$VERCEL_SCOPE" --token "$VERCEL_TOKEN"
vercel deploy $PROD --token "$VERCEL_TOKEN"
+59
View File
@@ -0,0 +1,59 @@
name: release-isolation-portfolio
'on':
push:
branches:
- ops/release-isolation-20261112
jobs:
baseline:
runs-on: diag-portfolio-runner
timeout-minutes: 5
steps:
- name: Provar isolamento efetivo
run: "node <<'JS'\nconst fs = require('node:fs');\nfor (const p of ['/var/run/docker.sock',\
\ '/run/docker.sock']) {\n if (fs.existsSync(p)) throw Error('Docker socket\
\ exposto: ' + p);\n}\nif (process.env.DOCKER_HOST) throw Error('DOCKER_HOST\
\ exposto');\nconst status = fs.readFileSync('/proc/self/status', 'utf8');\n\
const cap = BigInt('0x' + status.match(/^CapEff:\\s*([0-9a-f]+)/m)[1]);\n\
const forbidden = [16n, 17n, 19n, 21n].reduce((m,b) => m | (1n << b), 0n);\n\
if (cap & forbidden) throw Error('Capacidades privilegiadas expostas');\n\
console.log('ISOLATION_OK socket=absent privileged=false runner=' + process.env.RUNNER_NAME);\n\
JS\n"
privileged:
runs-on: diag-portfolio-runner
timeout-minutes: 5
container:
image: docker.gitea.com/runner-images:ubuntu-latest
options: --privileged
steps:
- name: Recusar elevação solicitada pelo workflow
run: 'node <<''JS''
const fs = require(''node:fs'');
if (fs.existsSync(''/var/run/docker.sock'')) throw Error(''Docker socket exposto'');
const s = fs.readFileSync(''/proc/self/status'', ''utf8'');
const cap = BigInt(''0x'' + s.match(/^CapEff:\s*([0-9a-f]+)/m)[1]);
const forbidden = [16n, 17n, 19n, 21n].reduce((m,b) => m | (1n << b), 0n);
if (cap & forbidden) throw Error(''Workflow conseguiu --privileged'');
console.log(''PRIVILEGED_DENIED runner='' + process.env.RUNNER_NAME);
JS
'
socket-bind:
runs-on: diag-portfolio-runner
timeout-minutes: 5
container:
image: docker.gitea.com/runner-images:ubuntu-latest
options: --volume=/var/run/docker.sock:/var/run/docker.sock
steps:
- name: Recusar bind solicitado pelo workflow
run: 'node -e "if(require(''node:fs'').existsSync(''/var/run/docker.sock''))throw
Error(''Socket bind permitido'');console.log(''SOCKET_BIND_DENIED'')"
'
-66
View File
@@ -1,66 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
/test-results/
/playwright-report/
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# local agent/editor state
/.augment/
/.kiro/
.opencode/
.claude/settings.local.json
.superpowers/
/.worktrees/
/.impeccable/
/.serena/
# generated analysis artifacts
/graphify-out/
*.graphify.json
*.analysis.json
*.tmp.json
*.debug.json
.omo/
.playwright-mcp/
/3d/out/
/3d/__pycache__/
-21
View File
@@ -1,21 +0,0 @@
node_modules/
.next/
dist/
build/
out/
coverage/
.git/
.pnpm-store/
.cache/
*.log
.env
.env.*
__pycache__/
*.pyc
.venv/
venv/
target/
*.exe
*.dll
*.so
*.dylib
-1
View File
@@ -1 +0,0 @@
{}
-619
View File
@@ -1,619 +0,0 @@
"""Kit de blocos do hero: um bloco chanfrado e quatro estados com o mesmo N.
Rodar da raiz do repositório: blender -b --python 3d/blocos.py
Gera public/3d/bloco.glb, public/3d/blocos.json, os dois matcaps, o pôster do hero e os dos estados (Cycles) e, em
3d/out/, o .blend e as prévias de cada estado.
Coordenadas do JSON: Y para cima, peça de frente para +Z, centrada na origem.
"""
import ctypes
import gzip
import math
import os
import sys
import bmesh
import bpy
import numpy as np
from bpy_extras.object_utils import world_to_camera_view
from mathutils import Vector
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUBLICO = os.path.join(RAIZ, "public", "3d")
SAIDA = os.path.join(RAIZ, "3d", "out")
GLB = os.path.join(PUBLICO, "bloco.glb")
JSON = os.path.join(PUBLICO, "blocos.json")
ESTADOS = ["marca", "tela", "integracoes", "dados"]
PASSO = 7 # números por bloco: x, y, z, sx, sy, sz, tom
CAIXA = (16, 11, 4)
# ---------------------------------------------------------------- marca
# public/brand/mark-dark.svg (viewBox 210 220 830 800)
POLIGONO = [
(245, 757), (321, 757), (321, 681), (399, 681), (399, 606), (476, 606),
(476, 757), (399, 757), (399, 878), (523, 878), (523, 757), (654, 757),
(654, 913), (594, 913), (594, 982), (245, 982),
]
# a forma branca em retângulos exatos (u0, v0, u1, v1), sem sobreposição
BRANCOS = [
(399, 606, 476, 757),
(321, 681, 399, 757),
(245, 757, 399, 982),
(523, 757, 594, 878),
(594, 757, 654, 913),
(399, 878, 594, 982),
]
LIMAS = [
(511, 512, 667, 688),
(667, 358, 821, 512),
(844, 255, 947, 358),
(906, 432, 1007, 533),
]
LADO_SVG = 38
ESCALA = 10 / 727 # altura da marca (255..982) vira 10
CU, CV = (245 + 1007) / 2, (255 + 982) / 2 # centro da marca no SVG
def svg_para_mundo(u, v):
return (u - CU) * ESCALA, -(v - CV) * ESCALA
def estado_marca():
sz = LADO_SVG * ESCALA
blocos = []
for tom, retangulos in ((0, BRANCOS), (1, LIMAS)):
for u0, v0, u1, v1 in retangulos:
nx = max(1, round((u1 - u0) / LADO_SVG))
ny = max(1, round((v1 - v0) / LADO_SVG))
bu, bv = (u1 - u0) / nx, (v1 - v0) / ny
for i in range(nx):
for j in range(ny):
x, y = svg_para_mundo(u0 + (i + 0.5) * bu, v0 + (j + 0.5) * bv)
for z in (-sz / 2, sz / 2):
blocos.append((x, y, z, bu * ESCALA, bv * ESCALA, sz, tom))
return blocos
# ------------------------------------------- tela, integrações, dados
# Desenhados numa grade inteira (coluna i, linha j com j = 0 no topo, camada k
# para +Z). O passo em mundo sai do tamanho da grade; a densidade de cada
# desenho foi escolhida para ficar entre 85% e 100% de N.
class Grade:
def __init__(self):
self.celulas = {}
def bloco(self, i, j, k=0, tom=0):
# lima prevalece quando dois elementos caem na mesma célula
chave = (round(i), round(j), k)
self.celulas[chave] = max(tom, self.celulas.get(chave, 0))
def cheio(self, i0, j0, i1, j1, k=0, tom=0):
for i in range(i0, i1 + 1):
for j in range(j0, j1 + 1):
self.bloco(i, j, k, tom)
def contorno(self, i0, j0, i1, j1, k=0, tom=0):
for i in range(i0, i1 + 1):
self.bloco(i, j0, k, tom)
self.bloco(i, j1, k, tom)
for j in range(j0 + 1, j1):
self.bloco(i0, j, k, tom)
self.bloco(i1, j, k, tom)
def linha(self, a, b, k=0, tom=0):
n = math.ceil(max(abs(b[0] - a[0]), abs(b[1] - a[1]))) * 2 + 1
for t in range(n + 1):
f = t / n
self.bloco(
math.floor(a[0] + (b[0] - a[0]) * f + 0.5),
math.floor(a[1] + (b[1] - a[1]) * f + 0.5),
k,
tom,
)
def disco(self, ci, cj, r, k=0, tom=0):
n = math.floor(r)
for di in range(-n, n + 1):
for dj in range(-n, n + 1):
if di * di + dj * dj <= r * r:
self.bloco(ci + di, cj + dj, k, tom)
def blocos(self):
cel = self.celulas
ii = [c[0] for c in cel]
jj = [c[1] for c in cel]
kk = [c[2] for c in cel]
largura = max(ii) - min(ii) + 1
altura = max(jj) - min(jj) + 1
camadas = max(kk) - min(kk) + 1
# maior passo que cabe na caixa, com folga
p = min(14.5 / largura, 9.8 / altura, 3.8 / camadas)
mi, mj, mk = (max(ii) + min(ii)) / 2, (max(jj) + min(jj)) / 2, (max(kk) + min(kk)) / 2
return [
((i - mi) * p, -(j - mj) * p, (k - mk) * p, p, p, p, tom)
for (i, j, k), tom in sorted(cel.items())
]
def estado_tela():
# referência: drawScreen em app/components/cinematic/HeroFieldScene.tsx
g = Grade()
C, R = 26, 18
g.contorno(0, 0, C - 1, R - 1) # moldura
for i in (2, 4, 6):
g.bloco(i, 1) # três pontos
g.cheio(1, 2, C - 2, 2) # barra do navegador
g.cheio(2, 4, 9, 5) # título
g.cheio(2, 7, 12, 7) # texto
g.cheio(2, 9, 9, 9)
for k in (0, 1):
g.cheio(2, 11, 7, 11, k, tom=1) # botão, saltado para a frente
g.contorno(15, 4, C - 3, 11) # imagem
g.linha((16, 10), (19, 7))
g.linha((19, 7), (21, 9))
g.bloco(21, 6)
for i0 in (2, 10, 18):
g.contorno(i0, 13, i0 + 5, 15) # cartões
return g.blocos()
def estado_integracoes():
# referência: drawIntegrations em HeroFieldScene.tsx
g = Grade()
A, B = 11, 7.5 # semieixos da elipse dos nós
r_nucleo, r_no = 2.6, 1.6
for k in (0, 1):
g.disco(0, 0, r_nucleo, k, tom=1)
nos = []
for n in range(6):
a = n / 6 * math.tau + math.pi / 6
nos.append((round(math.cos(a) * A), round(math.sin(a) * B)))
for n, (x, y) in enumerate(nos):
for k in (0, 1):
g.disco(x, y, r_no, k)
g.bloco(x, y, 1, tom=1)
d = math.hypot(x, y)
ux, uy = x / d, y / d
g.linha((ux * (r_nucleo + 0.5), uy * (r_nucleo + 0.5)), (x - ux * (r_no + 0.5), y - uy * (r_no + 0.5)))
nx, ny = nos[(n + 1) % 6]
dx, dy = nx - x, ny - y
d = math.hypot(dx, dy)
g.linha((x + dx / d * (r_no + 0.5), y + dy / d * (r_no + 0.5)), (nx - dx / d * (r_no + 0.5), ny - dy / d * (r_no + 0.5)))
return g.blocos()
def estado_dados():
# referência: drawData em HeroFieldScene.tsx; a coluna da direita vira barras em +Z
g = Grade()
C, R = 24, 15
g.contorno(0, 0, C - 1, R - 1)
g.cheio(1, 1, C - 2, 2, tom=1) # cabeçalho
colunas = (7, 13, 18)
for c in colunas:
g.cheio(c, 3, c, R - 2)
valores = ((5, 3, 2), (3, 4, 3), (4, 2, 1), (2, 4, 3), (5, 3, 2))
alturas = (2, 4, 1, 5, 3)
for linha, (v, h) in enumerate(zip(valores, alturas)):
j = 4 + linha * 2
for inicio, comprimento in zip((2, 9, 15), v):
g.cheio(inicio, j, inicio + comprimento - 1, j)
for k in range(h + 1):
g.cheio(20, j, 21, j, k, tom=int(k == h)) # topo da barra em lima
return g.blocos()
# ------------------------------------------------ montagem dos estados
def chave(b):
return (round(b[0], 3), round(b[1], 3), round(b[2], 3))
def completar(blocos, n):
"""Ordena por (x, y, z) e põe os que faltam para n ocultos sobre visíveis espalhados."""
visiveis = sorted(blocos, key=chave)
v, h = len(visiveis), n - len(visiveis)
if h < 0:
falhar(f"estado com {v} blocos, mais que N={n}")
ocultos = []
for m in range(h):
x, y, z, _, _, _, tom = visiveis[(m * 2 + 1) * v // (h * 2)]
ocultos.append((x, y, z, 0, 0, 0, tom))
# oculto fica logo depois do visível em que está, mesma chave espacial
return sorted(visiveis + ocultos, key=lambda b: (chave(b), b[3] == 0))
def fmt(v):
s = f"{v:.3f}"
return "0.000" if s == "-0.000" else s
# ------------------------------------------------------------- Blender
def srgb(h):
def lin(c):
c /= 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
return (*(lin(int(h[i : i + 2], 16)) for i in (0, 2, 4)), 1.0)
def criar_bloco():
malha = bpy.data.meshes.new("bloco")
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1)
bm.to_mesh(malha)
bm.free()
obj = bpy.data.objects.new("bloco", malha)
bpy.context.scene.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
chanfro = obj.modifiers.new("chanfro", "BEVEL")
chanfro.width = 0.08
chanfro.segments = 2
malha.shade_smooth()
pesos = obj.modifiers.new("normais", "WEIGHTED_NORMAL")
pesos.keep_sharp = True
for m in ("chanfro", "normais"):
bpy.ops.object.modifier_apply(modifier=m)
return obj
def exportar_glb(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.ops.export_scene.gltf(
filepath=GLB,
export_format="GLB",
use_selection=True,
export_yup=True,
export_apply=True,
export_normals=True,
export_texcoords=False,
export_tangents=False,
export_materials="NONE",
export_vertex_color="NONE",
export_attributes=False,
export_extras=False,
export_animations=False,
export_draco_mesh_compression_enable=False,
)
def montar_cena(estados, malha):
cena = bpy.context.scene
materiais = criar_materiais()
malha.materials.append(materiais[0])
colecoes = {}
for nome, blocos in zip(ESTADOS, estados):
col = bpy.data.collections.new(nome)
cena.collection.children.link(col)
colecoes[nome] = col
for n, (x, y, z, sx, sy, sz, tom) in enumerate(blocos):
obj = bpy.data.objects.new(f"{nome}.{n:03d}", malha)
col.objects.link(obj)
# arquivo Y-up/+Z frente -> Blender Z-up/-Y frente
obj.location = (x, -z, y)
obj.scale = (sx, sz, sy)
obj.material_slots[0].link = "OBJECT"
obj.material_slots[0].material = materiais[tom]
obj.hide_render = obj.hide_viewport = sx == 0
return colecoes, materiais
def renderizar(colecoes):
cena = bpy.context.scene
cena.render.engine = "BLENDER_WORKBENCH"
cena.render.resolution_x, cena.render.resolution_y = 800, 600
cena.render.resolution_percentage = 100
cena.render.image_settings.file_format = "PNG"
cena.view_settings.view_transform = "Standard"
cena.display.shading.light = "STUDIO"
cena.display.shading.color_type = "MATERIAL"
cena.display.shading.show_cavity = True
cena.world = bpy.data.worlds.new("fundo")
cena.world.color = (0.008, 0.008, 0.01)
cam = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
cena.collection.objects.link(cam)
cam.location = (-6.0, -25.0, 3.0) # pela esquerda: barras de dados saem para fora da tabela
cam.rotation_euler = (-cam.location.normalized()).to_track_quat("-Z", "Y").to_euler()
cena.camera = cam
for nome in ESTADOS:
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != nome
cena.render.filepath = os.path.join(SAIDA, f"previa-{nome}.png")
bpy.ops.render.render(write_still=True)
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != "marca"
# ------------------------------------------------------- render final
# Porcelana fosca com um verniz fino e esmalte lima com emissão leve, numa luz de estúdio de três pontos.
# Os matcaps (uma esfera de cada material, vista de frente) levam essa mesma luz para o tempo real.
def criar_materiais():
def material(nome, cor, **entradas):
mat = bpy.data.materials.new(nome)
mat.diffuse_color = srgb(cor) # cor da prévia (Workbench)
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = srgb(cor)
for entrada, valor in entradas.items():
bsdf.inputs[entrada].default_value = valor
return mat
return {
0: material("porcelana", "f4f3ef", **{"Roughness": 0.5, "Coat Weight": 0.12, "Coat Roughness": 0.3}),
1: material(
"lima",
"d5fa00",
**{
"Roughness": 0.28,
"Coat Weight": 0.3,
"Coat Roughness": 0.12,
"Emission Color": srgb("d5fa00"),
"Emission Strength": 0.06,
},
),
}
def olhar_para_a_origem(obj):
obj.rotation_euler = (-Vector(obj.location).normalized()).to_track_quat("-Z", "Y").to_euler()
def enquadrar(camera, objetos, direcao, ocupa):
"""Põe a câmera na direção dada, olhando a origem, na distância em que os objetos ocupam essa fração do quadro."""
cena = bpy.context.scene
cantos = [obj.matrix_world @ Vector(c) for obj in objetos for c in obj.bound_box]
distancia = 40
for _ in range(3): # com lente longa o tamanho na tela é quase 1 / distância: três passos convergem
camera.location = Vector(direcao).normalized() * distancia
olhar_para_a_origem(camera)
bpy.context.view_layer.update()
vistos = [world_to_camera_view(cena, camera, p) for p in cantos]
distancia *= 2 * max(max(abs(p.x - 0.5), abs(p.y - 0.5)) for p in vistos) / ocupa
def luz(nome, posicao, lado, potencia, cor=(1, 1, 1)):
dados = bpy.data.lights.new(nome, "AREA")
dados.shape = "SQUARE"
dados.size = lado
dados.energy = potencia
dados.color = cor
obj = bpy.data.objects.new(nome, dados)
bpy.context.scene.collection.objects.link(obj)
obj.location = posicao
olhar_para_a_origem(obj)
def ligar_cycles(cena):
"""Cycles na GPU com OptiX quando a máquina tem; senão segue na CPU."""
cena.render.engine = "CYCLES"
cena.cycles.use_denoising = True
prefs = bpy.context.preferences.addons["cycles"].preferences
try:
prefs.compute_device_type = "OPTIX"
except TypeError:
return
prefs.get_devices_for_type("OPTIX")
for aparelho in prefs.devices:
aparelho.use = aparelho.type == "OPTIX"
if any(aparelho.use for aparelho in prefs.devices):
cena.cycles.device = "GPU"
cena.cycles.denoiser = "OPTIX"
def sair(cena, caminho, lado, formato, modo, amostras, transparente):
cena.render.resolution_x, cena.render.resolution_y = lado
cena.render.film_transparent = transparente
cena.render.image_settings.file_format = formato
cena.render.image_settings.color_mode = modo
cena.render.image_settings.quality = 84
cena.cycles.samples = amostras
cena.render.filepath = caminho
bpy.ops.render.render(write_still=True)
def luz_de_estudio(cena):
fundo = cena.world.node_tree.nodes["Background"]
fundo.inputs["Color"].default_value = (0.02, 0.022, 0.02, 1)
fundo.inputs["Strength"].default_value = 1.0
# três tons por bloco: topo claro (principal, quase a pino), frente média (preenchimento baixo e frontal) e a lateral
# que a câmera vê na sombra; o contraluz recorta a silhueta por trás
luz("principal", (6, -10, 20), 14, 7000)
luz("preenchimento", (-10, -18, 3), 18, 2000, (0.92, 0.96, 1.0))
luz("contraluz", (-8, 12, 10), 10, 2000, (0.85, 1.0, 0.75))
def render_final(colecoes, materiais):
cena = bpy.context.scene
ligar_cycles(cena)
luz_de_estudio(cena)
def so(nome):
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != nome
# prévias finais dos quatro estados, na câmera das prévias rápidas
for nome in ESTADOS:
so(nome)
sair(cena, os.path.join(SAIDA, f"final-{nome}.png"), (800, 600), "PNG", "RGB", 64, False)
camera = bpy.data.objects.new("camera-poster", bpy.data.cameras.new("camera-poster"))
cena.collection.objects.link(camera)
camera.data.lens = 85
cena.camera = camera
# pôsteres dos estados: quase de frente como as prévias, mas de lente longa e com a peça em uns 85% do quadro
cena.render.resolution_x, cena.render.resolution_y = 1200, 900 # o enquadramento usa a proporção do quadro
for nome in ESTADOS[1:]:
so(nome)
enquadrar(camera, [obj for obj in colecoes[nome].objects if not obj.hide_render], (-6, -25, 3), 0.85)
sair(cena, os.path.join(PUBLICO, f"estado-{nome}.webp"), (1200, 900), "WEBP", "RGBA", 256, True)
# pôster: a marca em três quartos, fundo transparente
camera.location = Vector((-0.42, -0.86, 0.2)).normalized() * 36
olhar_para_a_origem(camera)
so("marca")
sair(cena, os.path.join(PUBLICO, "hero-poster.webp"), (1200, 1200), "WEBP", "RGBA", 256, True)
# matcaps: uma esfera de cada material, de frente, em vista ortográfica
so(None)
malha = bpy.data.meshes.new("esfera")
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=96, v_segments=48, radius=1)
bm.to_mesh(malha)
bm.free()
malha.shade_smooth()
esfera = bpy.data.objects.new("esfera", malha)
cena.collection.objects.link(esfera)
frente = bpy.data.objects.new("camera-matcap", bpy.data.cameras.new("camera-matcap"))
cena.collection.objects.link(frente)
frente.data.type = "ORTHO"
frente.data.ortho_scale = 2.0
frente.location = (0, -12, 0)
olhar_para_a_origem(frente)
cena.camera = frente
malha.materials.append(None)
# a esfera de frente pega mais luz de preenchimento que as faces do pôster: a porcelana sai 0,3 ponto de exposição
# abaixo, para a cena em tempo real ter o mesmo branco do pôster (a lima já bate)
for tom, nome, exposicao in ((0, "porcelana", -0.3), (1, "lima", 0.0)):
malha.materials[0] = materiais[tom]
cena.view_settings.exposure = exposicao
sair(cena, os.path.join(PUBLICO, f"matcap-{nome}.webp"), (256, 256), "WEBP", "RGB", 128, False)
cena.view_settings.exposure = 0.0
bpy.data.objects.remove(esfera)
bpy.data.objects.remove(frente)
cena.camera = camera
so("marca")
# ------------------------------------------------------- autoconferência
def falhar(msg):
print(f"FALHOU: {msg}", flush=True)
sys.exit(1)
def conferir(tris):
with open(JSON, "rb") as f:
bruto = f.read()
import json
doc = json.loads(bruto)
n = doc["n"]
dados = np.array(doc["dados"], dtype=float)
if dados.size != len(ESTADOS) * n * PASSO:
falhar(f"dados com {dados.size} números, esperado {len(ESTADOS) * n * PASSO} (mesmo N em todos os estados)")
dados = dados.reshape(len(ESTADOS), n, PASSO)
if n > 320:
falhar(f"N={n} > 320")
if n * tris > 60000:
falhar(f"N x triângulos = {n * tris} > 60000")
if os.path.getsize(GLB) > 40 * 1024:
falhar(f"bloco.glb com {os.path.getsize(GLB)} bytes > 40 KB")
estados = [(f"estado-{nome}.webp", 90) for nome in ESTADOS[1:]]
for nome, limite in (("hero-poster.webp", 90), *estados, ("matcap-porcelana.webp", 30), ("matcap-lima.webp", 30)):
tamanho = os.path.getsize(os.path.join(PUBLICO, nome))
if tamanho > limite * 1024:
falhar(f"{nome} com {tamanho} bytes > {limite} KB")
if len(gzip.compress(bruto)) > 40 * 1024:
falhar("blocos.json em gzip > 40 KB")
meia = np.array(CAIXA) / 2 + 1e-3
for nome, e in zip(ESTADOS, dados):
vis = e[e[:, 3] > 0]
ocultos = n - len(vis)
if ocultos > 0.15 * n:
falhar(f"{nome}: {ocultos} ocultos > 15% de {n}")
if np.any(np.abs(vis[:, :3]) + vis[:, 3:6] / 2 > meia):
falhar(f"{nome}: sai da caixa {CAIXA}")
if nome != "marca":
if np.any(np.abs(vis[:, 3] - vis[:, 4]) > 1e-3) or np.any(np.abs(vis[:, 3] - vis[:, 5]) > 1e-3):
falhar(f"{nome}: blocos não cúbicos")
if vis[:, 6].sum() > 0.25 * len(vis):
falhar(f"{nome}: lima em {int(vis[:, 6].sum())} de {len(vis)} visíveis > 25%")
# marca: em cada camada, os blocos lima ladrilham os quatro retângulos com tolerância
# de 0.5 unidade do SVG: cada bloco dentro de um retângulo, sem sobreposição e com a
# área de cada retângulo preenchida (folga de 0.5 em todo o perímetro).
tol = 0.5
marca = dados[0]
area_poligono = abs(sum(
a[0] * b[1] - b[0] * a[1] for a, b in zip(POLIGONO, POLIGONO[1:] + POLIGONO[:1])
)) / 2
for z in np.unique(marca[:, 2]):
camada = marca[marca[:, 2] == z]
u = camada[:, 0] / ESCALA + CU
v = -camada[:, 1] / ESCALA + CV
lu = camada[:, 3] / ESCALA
lv = camada[:, 4] / ESCALA
caixas = [(u[b] - lu[b] / 2, v[b] - lv[b] / 2, u[b] + lu[b] / 2, v[b] + lv[b] / 2)
for b in np.flatnonzero(camada[:, 6] == 1)]
areas = [0.0] * len(LIMAS)
for c in caixas:
dentro = [r for r, (u0, v0, u1, v1) in enumerate(LIMAS)
if c[0] >= u0 - tol and c[1] >= v0 - tol and c[2] <= u1 + tol and c[3] <= v1 + tol]
if not dentro:
falhar(f"marca z={z}: bloco lima {c} fora dos retângulos lima")
areas[dentro[0]] += (c[2] - c[0]) * (c[3] - c[1])
for (u0, v0, u1, v1), area in zip(LIMAS, areas):
if abs(area - (u1 - u0) * (v1 - v0)) > tol * 2 * (u1 - u0 + v1 - v0):
falhar(f"marca z={z}: retângulo lima {(u0, v0, u1, v1)} coberto com área {area:.0f}")
for a in range(len(caixas)):
for b in range(a):
p, q = caixas[a], caixas[b]
if min(p[2], q[2]) - max(p[0], q[0]) > tol and min(p[3], q[3]) - max(p[1], q[1]) > tol:
falhar(f"marca z={z}: blocos lima sobrepostos {p} {q}")
brancos = camada[:, 6] == 0
area = float((lu[brancos] * lv[brancos]).sum())
if abs(area - area_poligono) > 0.01 * area_poligono:
falhar(f"marca z={z}: área branca {area:.0f} != polígono {area_poligono:.0f}")
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
os.makedirs(PUBLICO, exist_ok=True)
os.makedirs(SAIDA, exist_ok=True)
marca = estado_marca()
n = len(marca)
estados = [completar(b, n) for b in (marca, estado_tela(), estado_integracoes(), estado_dados())]
numeros = ",".join(fmt(v) for e in estados for b in e for v in b)
lista = ",".join(f'"{e}"' for e in ESTADOS)
with open(JSON, "w") as f:
f.write(f'{{"n":{n},"passo":{PASSO},"estados":[{lista}],"dados":[{numeros}]}}')
bloco = criar_bloco()
bloco.data.calc_loop_triangles()
tris = len(bloco.data.loop_triangles)
exportar_glb(bloco)
bpy.context.scene.collection.objects.unlink(bloco) # a malha fica só nas instâncias
colecoes, materiais = montar_cena(estados, bloco.data)
bpy.data.objects.remove(bloco)
renderizar(colecoes)
render_final(colecoes, materiais)
bpy.context.preferences.filepaths.save_version = 0 # sem blocos.blend1
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(SAIDA, "blocos.blend"))
conferir(tris)
for nome, e in zip(ESTADOS, estados):
vis = [b for b in e if b[3] > 0]
print(f"{nome}: {len(vis)} visíveis, {sum(b[6] for b in vis)} lima", flush=True)
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
print(f"OK blocos={n} tris={n * tris}", flush=True)
sys.exit(0) # sai já, para o OK ser a última linha
if __name__ == "__main__":
main()
-341
View File
@@ -1,341 +0,0 @@
"""Peças pré-renderizadas dos cartões e do 404: o kit de blocos.py animado em loop de 3 s.
Rodar da raiz do repositório: blender -b --python 3d/pecas.py
Renderiza os quadros em 3d/out/pecas/<nome>/ e o ffmpeg do sistema gera em public/3d/
peca-<nome>.webm (VP9), .mp4 (H.264) e .webp (pôster). Mesmos bloco, materiais e luz do pôster do hero.
"""
import ctypes
import math
import os
import subprocess
import sys
import bpy
from mathutils import Matrix, Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from blocos import ( # noqa: E402
LIMAS,
PUBLICO,
SAIDA,
criar_bloco,
criar_materiais,
estado_marca,
falhar,
ligar_cycles,
luz_de_estudio,
sair,
svg_para_mundo,
)
QUADROS = 72 # 3 s a 24 por segundo; o quadro 72 seria de novo o 0
LADO = (640, 400)
TINTA = "0x0a0a0a" # --ink
AMOSTRAS = 64
LIMITE_VIDEO, LIMITE_POSTER = 250_000, 40_000
def encaixe(f, inicio, duracao):
"""0 antes, 1 assentado: chega rápido e assenta com ease-out cúbico, sem passar do ponto."""
t = min(max((f - inicio) / duracao, 0), 1)
return 1 - (1 - t) ** 3
# ---------------------------------------------------------------- peças
# Cada peça recebe novo(posição, escala, tom) e devolve pose(f), que põe os blocos no quadro f.
# Coordenadas do Blender: X para a direita, -Y para a câmera, Z para cima; bloco de lado 1.
def sistema(novo):
# planilha deitada de 7 x 5; a linha do fundo, em lima, é o cabeçalho
for c in range(7):
for r in range(5):
novo((c - 3, 2 - r, 0), 1, int(r == 0))
# as barras moram dentro das células das colunas 1 a 5, logo à frente do cabeçalho, e sobem telescópicas
barras = [[novo((k - 2, 1, 0), 0.98, int(j == h - 1)) for j in range(h)] for k, h in enumerate((2, 3, 5, 4, 6))]
def pose(f):
for k, pilha in enumerate(barras):
sobe = encaixe(f, 6 + 4 * k, 8) - encaixe(f, 42 + 4 * k, 8)
for j, obj in enumerate(pilha):
obj.location.z = (1 + j) * sobe
return pose
def whatsapp(novo):
# celular em pé de 9 x 16 (x = c - 4, z = 7.5 - r), de cantos arredondados; o miolo vazio é a tela
for c in range(9):
for r in range(16):
borda = (c in (0, 8) or r in (0, 15)) and not (c in (0, 8) and r in (0, 15))
canto_de_dentro = c in (1, 7) and r in (1, 14)
if borda or canto_de_dentro or r == 2: # r = 2 fecha a barra de cima
novo((c - 4, 0, 7.5 - r), 1, 0)
novo((-2, 0, 6.5), 1, 1) # foto do contato
for i in range(4):
novo((-0.8 + 0.7 * i, 0, 6.5), 0.7, 0) # nome, nos blocos menores dos balões, para ler como texto
# balões de 6 x 3 blocos menores, alternando os lados; o canto de baixo do lado do balão fica reto (é o rabo)
p = 0.7
def balao(k, esquerda, tom):
borda = -3.15 if esquerda else 3.15 # parede da tela menos meio bloco de folga
sentido = 1 if esquerda else -1
topo = 2.95 - 3.15 * k
blocos = []
for i in range(6):
for j in range(3):
if i in (0, 5) and j in (0, 2) and (i, j) != (0, 2):
continue
alvo = Vector((borda + sentido * (i + 0.5) * p, 0, topo - j * p))
blocos.append((novo(alvo, p, tom), alvo))
return blocos, Vector((borda, 0, topo - 2.5 * p))
baloes = [balao(0, True, 0), balao(1, False, 1), balao(2, True, 0)]
pontos = [novo((-2.25 + d, 0, 2.95 - 6.3 - p), 0.55, 0) for d in range(3)] # "digitando", no lugar do terceiro
def pose(f):
some = min(max((f - 60) / 8, 0), 1) ** 3 # somem juntos, encolhendo para o rabo
for (blocos, rabo), chega in zip(baloes, (2, 14, 44)):
s = encaixe(f, chega, 8) * (1 - some)
for obj, alvo in blocos:
obj.location = rabo + (alvo - rabo) * s
obj.scale = (p * max(s, 0.001),) * 3
obj.hide_render = s <= 0
aparece = encaixe(f, 26, 4) * (1 - encaixe(f, 41, 3))
for d, obj in enumerate(pontos):
pulso = max(0.0, math.sin(math.tau * (f - 28) / 8 - d * math.tau / 6)) # um depois do outro
s = aparece * 0.55 * (1 + 0.45 * pulso)
obj.scale = (max(s, 0.001),) * 3
obj.hide_render = s <= 0
return pose
def quatrocentos(novo):
# o quadrado lima mais alto se solta para a frente (passa diante do outro) e cai; volta de cima direto no lugar
x0, y1 = svg_para_mundo(*LIMAS[2][:2])
x1, y0 = svg_para_mundo(*LIMAS[2][2:])
solto = []
for x, y, z, sx, sy, sz, tom in estado_marca():
obj = novo((x, -z, y), (sx, sz, sy), tom)
if tom and x0 < x < x1 and y0 < y < y1:
solto.append((obj, Vector((x, -z, y))))
centro = sum((alvo for _, alvo in solto), Vector()) / len(solto)
def pose(f):
if f < 32:
queda = min(max((f - 12) / 12, 0), 1) ** 2 # ease-in: a gravidade acelera
d = Vector((0, -1.4 * encaixe(f, 8, 4), -16 * queda))
giro = Matrix.Rotation(0.6 * queda, 3, "Y") # tomba de lado enquanto cai
else:
d = Vector((0, 0, 12 * (1 - encaixe(f, 40, 8))))
giro = Matrix.Identity(3)
for obj, alvo in solto:
obj.location = centro + giro @ (alvo - centro) + d
obj.rotation_euler = giro.to_euler()
return pose
def selo_conferido(novo, x0, z0, y, passo):
"""Selo lima de 7 x 7 com o V de conferido vazado; (x0, z0) é o centro do bloco de cima à esquerda."""
desenho = ("....#", "...##", "#.##.", "###..", ".#...")
vazado = {(1 + i, 1 + j) for j, linha in enumerate(desenho) for i, x in enumerate(linha) if x == "#"}
blocos = []
for c in range(7):
for r in range(7):
if (c, r) not in vazado:
alvo = Vector((x0 + c * passo, y, z0 - r * passo))
blocos.append((novo(alvo, passo, 1), alvo))
return blocos
def fiscal(novo):
# nota em pé de 11 x 16; as quatro linhas de texto são vãos na folha, a partir da coluna 1
linhas = {2: 7, 4: 9, 6: 6, 8: 8}
for c in range(11):
for r in range(16):
if not 1 <= c <= linhas.get(r, 0):
novo((c - 5, 0, 7.5 - r), 1, 0)
selo = selo_conferido(novo, -1, -1.5, -1, 1) # à frente do canto de baixo à direita; a folha aparece pelo V
de_fora = Vector((22, -6, 4)) # chega pela direita, vindo da frente
para_cima = Vector((6, -4, 20))
def pose(f):
sai = min(max((f - 46) / 10, 0), 1) ** 3 # solta devagar e sai acelerando
d = de_fora * (1 - encaixe(f, 6, 10)) + para_cima * sai
for obj, alvo in selo:
obj.location = alvo + d
return pose
REVISAO_CAMERA = (-0.25, -0.95, 0.15)
def revisao(novo):
# janela de sistema de 13 x 13 (x = c - 6, z = 6 - r): moldura, três pontos, barra e quatro linhas de conteúdo
linhas = {4: 8, 6: 10, 8: 6, 10: 9}
# três blocos das linhas, da esquerda para a direita, saltados para a frente e girados: (deslocamento, giro)
fora = {
(3, 6): ((-0.4, -2.4, 1.1), (0.6, 0.3, 0.7)),
(5, 4): ((0.5, -2.0, -0.9), (-0.5, 0.4, -0.6)),
(7, 10): ((-0.3, -2.6, 0.9), (0.4, -0.6, 0.5)),
}
soltos = []
for c in range(13):
for r in range(13):
if c in (0, 12) or r in (0, 2, 12) or (r == 1 and c in (2, 4, 6)) or 2 <= c <= linhas.get(r, 0):
obj = novo(Vector((c - 6, 0, 6 - r)), 1, 0)
if (c, r) in fora:
desloc, giro = fora[(c, r)]
soltos.append((obj, obj.location.copy(), Vector(desloc), Vector(giro)))
# lupa: aro octogonal em volta de um vão de 3 x 3 e cabo na diagonal, para baixo e à direita
lupa = []
for dx in range(-2, 3):
for dz in range(-2, 3):
if 1.5 < math.hypot(dx, dz) <= 2.6:
lupa.append((novo((0, 0, 0), 1, 1), Vector((dx, 0, dz))))
for k in range(3):
d = (3.0 + 0.95 * k) / math.sqrt(2)
cabo = novo((0, 0, 0), 1, 1)
cabo.rotation_euler = (0, math.pi / 4, 0)
lupa.append((cabo, Vector((d, 0, -d))))
lima = lupa[0][0].material_slots[0].material
porcelana = soltos[0][0].material_slots[0].material
# a lupa anda 3,5 à frente da janela, na linha de visão de cada bloco solto: o bloco aparece no meio do aro
rumo = Vector(REVISAO_CAMERA).normalized()
def na_frente(p):
return Vector(p) + rumo * (3.5 / -rumo.y)
a, b, c = (alvo for _, alvo, _, _ in soltos)
caminho = [(0, na_frente((-18, 0, 1))), (9, na_frente(a)), (13, na_frente(a)), (21, na_frente(b)),
(25, na_frente(b)), (33, na_frente(c)), (37, na_frente(c)), (47, na_frente((28, 0, -3)))] # fmt: skip
chegadas = (9, 21, 33)
selo = selo_conferido(novo, 2.3, -3.2, -1, 0.6) # menor, por dentro do canto de baixo à direita
de_fora = Vector((0, 0, -11)) # chega e sai reto por baixo, sem invadir a direita do quadro
def lupa_em(f):
for (f0, p0), (f1, p1) in zip(caminho, caminho[1:]):
if f <= f1:
t = min(max((f - f0) / (f1 - f0), 0), 1)
return p0.lerp(p1, t * t * (3 - 2 * t))
return caminho[-1][1]
def pose(f):
centro = lupa_em(f)
for obj, desvio in lupa:
obj.location = centro + desvio
for i, ((obj, alvo, desloc, giro), chega) in enumerate(zip(soltos, chegadas)):
s = encaixe(f, chega, 6) - encaixe(f, 62 + 2 * i, 5) # no fim voltam a saltar, para o loop emendar
obj.location = alvo + desloc * (1 - s)
obj.rotation_euler = giro * (1 - s)
obj.material_slots[0].material = lima if chega <= f < chega + 10 else porcelana
d = de_fora * (1 - encaixe(f, 44, 8) + min(max((f - 60) / 6, 0), 1) ** 3)
for obj, alvo in selo:
obj.location = alvo + d
return pose
# nome, peça, direção da câmera, distância, alvo, quadro do pôster (peça montada)
PECAS = [
("sistema", sistema, (-0.5, -0.8, 0.6), 44, (0, 0.5, 2.3), 36),
("whatsapp", whatsapp, (-0.3, -0.93, 0.18), 64, (-3.8, 0, 0), 56),
("404", quatrocentos, (-0.42, -0.86, 0.2), 46, (0, 0, 0), 0),
("fiscal", fiscal, (-0.42, -0.86, 0.2), 72, (0, 0, 0), 36),
("revisao", revisao, REVISAO_CAMERA, 68, (6.3, 0, -0.6), 20),
]
# ------------------------------------------------------------- ffmpeg
def ffmpeg(*args):
fundo = ["-f", "lavfi", "-i", f"color=c={TINTA}:s={LADO[0]}x{LADO[1]}:r=24"]
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *fundo, *args], check=True)
def codificar(pasta, nome, poster):
destino = os.path.join(PUBLICO, f"peca-{nome}")
# quadros com alfa sobre a tinta opaca, em yuv420p BT.709 (o padrão dos navegadores)
compor = "[0][1]overlay=shortest=1:format=rgb,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p"
entrada = ["-framerate", "24", "-i", os.path.join(pasta, "%04d.png"), "-filter_complex", compor, "-an"]
cor = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
ffmpeg(*entrada, *cor, "-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-row-mt", "1", "-cpu-used", "1",
f"{destino}.webm") # fmt: skip
ffmpeg(*entrada, *cor, "-c:v", "libx264", "-preset", "slow", "-crf", "22", "-movflags", "+faststart",
f"{destino}.mp4") # fmt: skip
ffmpeg("-i", poster, "-filter_complex", "[0][1]overlay", "-frames:v", "1", "-c:v", "libwebp", "-quality", "80",
f"{destino}.webp") # fmt: skip
for ext, limite in (("webm", LIMITE_VIDEO), ("mp4", LIMITE_VIDEO), ("webp", LIMITE_POSTER)):
tamanho = os.path.getsize(f"{destino}.{ext}")
print(f"peca-{nome}.{ext}: {tamanho} bytes", flush=True)
if tamanho > limite:
falhar(f"peca-{nome}.{ext} tem {tamanho} bytes, limite {limite}")
# ------------------------------------------------------------- Blender
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
cena = bpy.context.scene
cena.view_settings.view_transform = "Standard"
cena.world = bpy.data.worlds.new("fundo")
cena.render.use_persistent_data = True
ligar_cycles(cena)
luz_de_estudio(cena)
bloco = criar_bloco()
cena.collection.objects.unlink(bloco) # a malha fica só nas instâncias
malha = bloco.data
bpy.data.objects.remove(bloco)
materiais = criar_materiais()
malha.materials.append(materiais[0])
camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
cena.collection.objects.link(camera)
camera.data.lens = 85
cena.camera = camera
colecoes = {}
for nome, peca, direcao, distancia, alvo, quadro_poster in PECAS:
col = bpy.data.collections.new(nome)
cena.collection.children.link(col)
colecoes[nome] = col
def novo(posicao, escala, tom, col=col):
obj = bpy.data.objects.new(f"{col.name}.{len(col.objects):03d}", malha)
col.objects.link(obj)
obj.location = posicao
obj.scale = escala if isinstance(escala, tuple) else (escala,) * 3
obj.material_slots[0].link = "OBJECT"
obj.material_slots[0].material = materiais[tom]
return obj
pose = peca(novo)
for outro, c in colecoes.items():
c.hide_render = outro != nome
direcao = Vector(direcao).normalized()
camera.location = Vector(alvo) + direcao * distancia
camera.rotation_euler = (-direcao).to_track_quat("-Z", "Y").to_euler()
pasta = os.path.join(SAIDA, "pecas", nome)
os.makedirs(pasta, exist_ok=True)
for f in range(QUADROS):
pose(f)
sair(cena, os.path.join(pasta, f"{f:04d}.png"), LADO, "PNG", "RGBA", AMOSTRAS, True)
codificar(pasta, nome, os.path.join(pasta, f"{quadro_poster:04d}.png"))
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
print(f"OK pecas={len(PECAS)}", flush=True)
sys.exit(0) # sai já, para o OK ser a última linha
main()
-9
View File
@@ -1,9 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-11
View File
@@ -1,11 +0,0 @@
# Credits / Third-party assets
## Project screenshots
- `public/assets/prints/*.png` are captures taken with headless Chrome from
production deployments owned by the site author (evandro.dev.br), used as
on-screen content inside the notebook browser surface.
## WhatsApp icon
- The WhatsApp silhouette in `app/components/Contact.tsx` comes from
[Simple Icons](https://github.com/simple-icons/simple-icons/blob/develop/icons/whatsapp.svg).
It identifies the contact channel; it does not imply endorsement by WhatsApp.
-83
View File
@@ -1,83 +0,0 @@
# DESIGN.md
Registro das decisões de desenho do evandro.dev.br. O código (`app/globals.css`, componentes) é a fonte dos valores; este arquivo diz o porquê.
## Conceito
```
Ideia: os blocos da marca são as peças com que se monta o site ou o sistema de cada cliente
Metáfora: kit de blocos de encaixe, como uma maquete
Assinatura: no hero, os blocos da marca se soltam e se remontam com a rolagem em tela, integrações e dados
Emoção: nos três primeiros segundos, "isto foi montado peça por peça"
Verbo: encaixar: chega rápido, assenta curto, sem quique
Quadro: a marca em porcelana com os quatro cubos verdes ao lado do título
```
Âncora: o símbolo é feito de blocos (`public/brand/mark-dark.svg`); o conceito não serve para outra marca.
## Paleta
| Papel | Token | Valor |
|---|---|---|
| Fundo escuro, tinta | `--ink` | `#0a0a0a` |
| Fundo claro | `--paper` | `#f4f2ed` |
| Fundo claro, segundo plano | `--paper-dark` | `#eceae4` |
| Acento | `--slash` | `#ccff00` |
| Texto secundário | `--meta` | `#4a4a4a` |
| Controles que abrem o WhatsApp | `--whatsapp` | `#25d366` |
O acento aparece em pouca área: um trecho do título, um botão, os cubos da marca. Verde sobre preto é a identidade da marca desde 2026-09, não uma escolha de tendência.
## Tipografia
- Display e corpo: Space Grotesk (`--font-display`).
- Rótulos, números e código: Geist Mono (`--font-mono`), 13 px, sem caixa alta, `letter-spacing` de 0,02em.
## Forma
Cantos retos por padrão (blocos, molduras, botões do hero). Raio aparece em poucos lugares: formulário de contato, botão do WhatsApp e indicadores circulares.
## Movimento
Só CSS e Three.js direto. Sem biblioteca de animação (GSAP, Motion e React Three Fiber saíram em 2026-10-14 por desempenho).
| Uso | Duração | Curva |
|---|---|---|
| Entrada de componente | 280 ms | `--ease-out` |
| Saída de componente | 180 ms | `--ease-in` |
| Entrada do título e do hero | 850 a 1100 ms | `--ease-out` |
| Troca de página | 600 ms; a capa compartilhada viaja em 500 ms | cortina em degraus de 8 colunas; `--ease-out` na capa |
| Detalhes de bloco (cursor, cortina) | conforme o caso | `steps()` |
Regras:
- Animam só `transform` (e `translate`, `scale`), `opacity`, `clip-path` e `mask`. Única exceção: a altura das perguntas frequentes ao abrir e fechar.
- O texto está no HTML desde o primeiro quadro; a animação nunca esconde conteúdo de quem não a recebe.
- Coisa grande se move devagar, detalhe se move rápido; escalonamento entre 20 e 80 ms.
- Com `prefers-reduced-motion: reduce` não há cena, rolagem suave nem animação; o conteúdo aparece inteiro.
## Resposta ao ponteiro e ao foco
Um tratamento por papel, sempre entrando pela esquerda em 280 ms com `--ease-out`; com movimento reduzido a troca é instantânea.
| Papel | Tratamento |
|---|---|
| Botão verde | um bloco cor de papel cobre o botão e o texto continua escuro; sobre fundo claro o bloco é escuro e o texto inverte |
| Botão de contorno | o mesmo bloco: translúcido no fundo escuro, escuro no fundo claro (o texto inverte) |
| Link de texto e item do menu | uma barra de 2 px cresce sob o texto |
| Item da vitrine | uma barra verde entra pela esquerda |
| Tocar vídeo | o círculo cresce e fica verde |
| Logo | os quatro quadrados verdes sobem em sequência e assentam |
"Falar sobre meu projeto" sempre leva ao formulário. Controle que abre o WhatsApp se chama "Chamar no WhatsApp".
## Cena 3D
Fonte em `3d/blocos.py` (Blender), saída em `public/3d/`. Um bloco chanfrado, repetido, com quatro estados: `marca`, `tela`, `integracoes`, `dados`.
| Nível (`sceneTier()` em `lib/device.ts`) | Quem recebe | O que recebe |
|---|---|---|
| `full` | computador com GPU, mais de 4 núcleos e mais de 4 GB | cena em tempo real guiada pela rolagem |
| `lite` | celular com 6 núcleos ou mais e 4 GB ou mais | cena em tempo real leve, que troca de estado sozinha |
| `static` | o resto, movimento reduzido, economia de dados, ou quem não segura 28 ms por quadro | pôster renderizado no Blender |
O pôster é o primeiro quadro de todos os níveis e o LCP continua sendo o título.
-53
View File
@@ -1,53 +0,0 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
- **Principal:** dono ou gerente de pequena ou média empresa de Joinville e região (comércio, serviços, indústria pequena). Não é técnico, chega pelo Google ou por indicação, decide sozinho e prefere falar pelo WhatsApp. Quer um site que traga pedidos ou um sistema para a operação.
- **Secundário:** recrutadores e empresas de tecnologia avaliando o Evandro como desenvolvedor. O site também precisa mostrar a eles o nível técnico do trabalho.
## Product Purpose
Site de Evandro Fonseca Junior, engenheiro da computação de Garuva, SC, que vende sites e sistemas sob medida. O site existe para levar o visitante a uma conversa pelo WhatsApp sobre o projeto dele. Sucesso é conversa iniciada com o tipo de projeto já identificado.
## Positioning
"Você fala com quem desenvolve." A mesma pessoa entende o negócio, decide a estrutura e programa. Cuida de tela, dados e equipamentos (integração com hardware e IoT, experiência na Apolo Energia desde 2022). Usa IA como ferramenta e responde pelo resultado.
## Operating Context
- A conversa começa pelo WhatsApp, com a mensagem já montada pelo formulário; nada é enviado automaticamente.
- Ofertas atuais: site que traz pedidos pelo WhatsApp, sistema para tirar a operação da planilha, revisão de sistema feito com IA (`/ia`).
- Páginas de serviço em `/servicos` e casos em `/cases`.
## Capabilities and Constraints
- Sem preço e sem prazo no site: isso se combina na conversa.
- O título é o primeiro conteúdo a pintar (LCP); o site funciona sem JavaScript e com movimento reduzido.
- 3D e vídeo só onde o aparelho aguenta; quem não aguenta recebe a versão estática.
- Métricas de navegação só com consentimento.
## Brand Commitments
- Nome: Evandro.dev.br. Símbolo de blocos (`public/brand/mark-dark.svg`), verde `#ccff00` sobre preto.
- Voz: português direto, frase curta, sem jargão, sem promessa que não dá para provar.
## Evidence on Hand
- Clientes reais: SEV Moda Íntima, Lavação 047, SC Plus Aquicultura, AS Serviços, Julia Beauty, com vídeos e capturas em `public/videos/`.
- 5 anos de experiência, calculados a partir de `public/portfolio-data.json`.
- Casos de falha em sistemas feitos com IA, com fonte, em `app/ia/page.tsx`.
- Não há depoimento com número, prêmio nem métrica de resultado de cliente: não inventar.
## Product Principles
1. Cada afirmação tem fonte ou prova à vista; o que não foi provado é dito como não provado.
2. Mostrar trabalho real antes de falar de tecnologia.
3. O visitante leigo entende em três segundos o que é oferecido e como falar.
4. Desempenho e acessibilidade vêm antes do efeito.
5. A IA aparece como ferramenta sob responsabilidade de um engenheiro, nunca como inimiga de quem contrata.
-169
View File
@@ -1,169 +0,0 @@
<div align="center">
<img src="assets/readme/hero.svg" width="100%" alt="evandro.dev.br: o portfólio de Evandro Fonseca Junior, com o índice dos nove cases publicados e o estado de cada um">
![Next.js 16.3.8](https://img.shields.io/badge/Next.js-16.3.8-ccff00?style=flat-square&labelColor=0a0a0a)
![React 19.1.0](https://img.shields.io/badge/React-19.1.0-ccff00?style=flat-square&labelColor=0a0a0a)
![Node 22.x](https://img.shields.io/badge/Node-22.x-ccff00?style=flat-square&labelColor=0a0a0a)
![Status: em produção](https://img.shields.io/badge/status-em_produ%C3%A7%C3%A3o-ccff00?style=flat-square&labelColor=0a0a0a)
[Site](https://evandro.dev.br) · [Cases](https://evandro.dev.br/cases) · [Currículo](https://evandro.dev.br/curriculo) · [LinkedIn](https://www.linkedin.com/in/evandrodevbr/) · [Issues](https://git.evandro.dev.br/evandro/portifolio-evandrodevbr/issues)
</div>
| SEV Moda Íntima | NutriScan |
|---|---|
| <img src="./public/assets/prints/print-sev.png" width="380" alt="Site da SEV Moda Íntima"> | <img src="./public/assets/prints/print-nutriscan.png" width="380" alt="NutriScan"> |
| **SC Plus Aquicultura** | **AS Serviços** |
| <img src="./public/assets/prints/print-scplus.png" width="380" alt="Site da SC Plus Aquicultura"> | <img src="./public/assets/prints/print-asservicos.png" width="380" alt="Site da AS Serviços"> |
Capturas feitas com Chrome headless a partir de deploys de produção do próprio autor. Origem e créditos (inclusive do ícone do WhatsApp) em [`CREDITS.md`](CREDITS.md).
## O que é
O código do site [evandro.dev.br](https://evandro.dev.br), o portfólio de Evandro Fonseca Junior, engenheiro da computação em Garuva-SC. Pequenos negócios costumam vender e atender por mensagem: o cliente pergunta o preço um a um, o estoque vive em planilha e qualquer mudança no site depende de um desenvolvedor. O site mostra projetos entregues, serviços, currículo e um botão de WhatsApp para começar a conversa.
- Apresenta três ofertas: criação de sites, sistemas web sob medida e revisão de apps feitos com IA (`/ia`).
- Publica nove cases com stack, entregas e decisões técnicas, cada um em `/cases/<id>`.
- Detalha seis serviços em `/servicos/*`, com escopo e etapas visíveis e a parte técnica em `<details>`.
- Serve o currículo em HTML e o PDF em `/Profile.pdf`.
- Gera `sitemap.xml`, `robots.txt`, manifest, imagens Open Graph e dados estruturados JSON-LD.
### Cases publicados
Fonte: `public/portfolio-data.json` e `lib/site-content.ts` (os mesmos nove ids).
| Case | O que é | Estado | Demo |
|---|---|---|---|
| [`julia-beauty`](https://evandro.dev.br/cases/julia-beauty) | Julia Beauty: site de estúdio de extensão de cílios em Camboriú, com simulador de efeito e SEO local | Em produção | [juliabeauty.com.br](https://juliabeauty.com.br) |
| [`sev-modaintima`](https://evandro.dev.br/cases/sev-modaintima) | SEV Moda Íntima: catálogo digital e loja virtual, pedido pelo WhatsApp e painel de estoque | Em produção | [sevmodaintima.com.br](https://sevmodaintima.com.br) |
| [`lavacao-047`](https://evandro.dev.br/cases/lavacao-047) | Lavação 047: site e painel para lavação automotiva em Garuva-SC, o dono edita preços, textos e fotos | Em produção | [landing-page-lavacao047.vercel.app](https://landing-page-lavacao047.vercel.app) |
| [`nutriscan`](https://evandro.dev.br/cases/nutriscan) | NutriScan: consulta de produtos por nome e código de barras | Em produção | [nutriscan.evandro.dev.br](https://nutriscan.evandro.dev.br) |
| [`twins-lab`](https://evandro.dev.br/cases/twins-lab) | Twins Lab: painel ao vivo de um reator simulado | Protótipo | sem demo |
| [`reddit-explorer`](https://evandro.dev.br/cases/reddit-explorer) | Reddit Posts Explorer: posts de um fórum de IA, filtrados por período | Concluído | sem demo |
| [`sc-plus`](https://evandro.dev.br/cases/sc-plus) | SC Plus Aquicultura: site institucional com áreas, projetos e contato | Concluído | [scplus.evandro.dev.br](https://scplus.evandro.dev.br) |
| [`gcc-installer`](https://evandro.dev.br/cases/gcc-installer) | MinGW GCC Installer: instalador do compilador GCC para Windows | Concluído | sem demo |
| [`as-servicos`](https://evandro.dev.br/cases/as-servicos) | AS Serviços: site institucional com cena 3D | Concluído | sem demo |
## Por que usar
O repositório é a referência de como o site funciona, não um template. As decisões que valem conferir:
- **Sem backend.** O formulário de contato abre o `wa.me` com o briefing já preenchido, e o rodapé mantém o contato direto por e-mail; não há API route ativa nem banco.
- **3D só onde o aparelho aguenta.** O hero e o notebook de projetos em Three.js aparecem só onde o dispositivo comporta; em telas pequenas, com ponteiro `coarse` ou movimento reduzido, o site entrega a versão em HTML estático.
- **Analytics com consentimento.** O Google Analytics só carrega depois de aceite explícito, e o consentimento pode ser revogado depois (detalhes em `/privacidade`).
- **Demos externas não derrubam o render.** O estado de cada demo vem de um snapshot versionado; em produção, o render não consulta as demos.
Quando não usar: se você procura um template de portfólio. O projeto é pessoal (`"private": true` no `package.json`) e não tem licença publicada.
## Como funciona
1. O conteúdo vem de `public/portfolio-data.json` (lido por `lib/portfolio-data.server.ts`) e de `lib/site-content.ts`; a homepage escolhe os projetos pelo campo `featured`.
2. `data/project-health.json` é um snapshot versionado das demos. Ele só controla o CTA de cada card (disponível, indisponível, desconhecido ou sem demo) e nunca remove um card.
3. Em produção, o render só lê o snapshot. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. `pnpm check:projects` atualiza o arquivo por rename atômico. Contrato completo em [`docs/project-health.md`](docs/project-health.md).
4. Um único workflow, [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml), roda em `push`, `pull_request` e `workflow_dispatch`, com permissão `contents: read`:
| Job | O que roda | Bloqueia o deploy |
|---|---|---|
| `verify` | Node 22, `pnpm install --frozen-lockfile`, `lint`, `typecheck`, `test -- --runInBand`, `build` | Sim |
| `audit` | `pnpm audit --prod` e `pnpm audit --dev`, ambos com `--audit-level=high` | Não |
| `e2e` | Playwright (Chromium) contra o build de produção na porta 3001; publica `test-results/` | Não |
| `deploy` | Vercel CLI `61.1.0`, só após `verify`: branch padrão vai para produção, as demais para preview; pull request não publica | n/a |
O deploy usa o secret `VERCEL_TOKEN`; o projeto na Vercel é o nome do repositório, ou a variável `VERCEL_PROJECT`. [`nixpacks.toml`](nixpacks.toml) (`nodejs_22`, `pnpm-9_x`) e [`vercel.json`](vercel.json) (`framework: nextjs`) descrevem o build.
<details>
<summary>Rotas e estrutura</summary>
| Rota | Conteúdo |
|---|---|
| `/` | Hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato |
| `/cases`, `/cases/<id>` | Índice e página de cada um dos nove cases |
| `/servicos` | Índice dos seis serviços |
| `/servicos/criacao-de-sites` | Criação de sites |
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida |
| `/servicos/consultoria-nextjs` | Consultoria Next.js |
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux |
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT |
| `/servicos/arquitetura-de-software` | Diagnóstico de arquitetura |
| `/ia` | Revisão de sistema e de app feito com IA |
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf` |
| `/privacidade` | Privacidade e consentimento de analytics |
```text
app/ rotas (App Router) e componentes
lib/ conteúdo (site-content.ts), SEO, JSON-LD, WhatsApp, project health
public/ portfolio-data.json, Profile.pdf, logos, capturas e mídias dos cases
data/ project-health.json, snapshot versionado das demos
scripts/ check-projects.mjs e utilitários de SEO e vídeo
e2e/ testes Playwright (9 specs)
docs/ SEO/GEO, project health e planos
.gitea/workflows/ ci.yml (verify, audit, e2e, deploy)
```
</details>
## Início rápido
Requisitos: Node.js `22.x` (`engines`), pnpm `9.15.9` (`packageManager`) e, só para `pnpm test:e2e`, o Chromium do Playwright.
```bash
corepack [email protected] install --frozen-lockfile
corepack [email protected] dev # Next.js com Turbopack
corepack [email protected] build # build de produção
corepack [email protected] start # serve um build já gerado
```
A instalação é sempre com `--frozen-lockfile`, igual ao CI e ao Nixpacks.
> [!IMPORTANT]
> O Next.js (`16.3.8`) é fixado de propósito: a `16.2.11` mantinha três advisories transitivos de severidade alta, e a `16.3.0` foi a menor versão estável da mesma major que deixou as auditorias sem vulnerabilidades high. Não faça downgrade sem repetir `pnpm audit`.
Validação completa, na mesma ordem do CI:
```bash
corepack [email protected] install --frozen-lockfile
corepack [email protected] lint
corepack [email protected] typecheck
corepack [email protected] test -- --runInBand
corepack [email protected] build
corepack [email protected] audit --prod --audit-level=high
corepack [email protected] audit --dev --audit-level=high
```
Testes de ponta a ponta: `pnpm build && pnpm start -p 3001` e, em outro terminal, `BASE_URL=http://localhost:3001 pnpm test:e2e`.
Outros scripts: `pnpm format` (Biome), `pnpm test:watch` (Jest em modo watch) e `pnpm check:projects` (atualiza `data/project-health.json`).
## Configuração
Não há `.env.example`, e nenhuma variável é necessária para rodar localmente. O código lê:
| Variável | Descrição |
|---|---|
| `NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL` | Rótulo de conversão do Google Ads; sem ele, nenhum evento de conversão é enviado |
| `BASE_URL` | Endereço do site testado por `pnpm test:e2e` (padrão `http://localhost:3001`) |
| `PW_CHROMIUM` | Caminho de um Chromium local para o Playwright, para não baixar o navegador |
## Estado do projeto
Em produção em [evandro.dev.br](https://evandro.dev.br). Stack: Next.js 16.3.8, React 19.1.0, TypeScript 5.9.2, Tailwind CSS 4, Three.js, Lenis e Lucide React. Qualidade: Biome, Jest 30 com Testing Library (150 arquivos de teste), Playwright e axe-core. Todas as dependências diretas estão fixadas em versões exatas, exceto `lenis` (`^1.3.26`).
Limites e próximos passos:
- **Demos externas podem aparecer como indisponíveis.** O CTA reflete o último snapshot versionado, não uma consulta em tempo real. No snapshot de 2026-07-11, só a SEV respondeu 200; `nutriscan` falhou na consulta, `twins-lab` respondeu 404 e `sc-plus`, 502. Rode `pnpm check:projects` para renová-lo. Em 2026-10-24, `nutriscan.evandro.dev.br` continuava sem responder com o app.
- **Ações externas de SEO/GEO pendentes.** Search Console, Bing, Cloudflare e CrUX estão só no runbook.
Documentação: [`docs/project-health.md`](docs/project-health.md) (estados do CTA das demos e atualização do snapshot), [`docs/seo-geo-operations.md`](docs/seo-geo-operations.md) (runbook de SEO e GEO, descrito e **não executado**), [`docs/seo-geo-validation.md`](docs/seo-geo-validation.md) (validação de SEO e GEO) e [`CREDITS.md`](CREDITS.md).
O CI roda em `push` e `pull_request`; antes de abrir um PR, rode `pnpm lint && pnpm typecheck && pnpm test -- --runInBand && pnpm build`.
## Licença
Sem licença publicada: todos os direitos reservados.
<div align="center">
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div>
-109
View File
@@ -1,109 +0,0 @@
/**
* Dublê central e completo de `next/navigation` para o Jest (jest.config.ts: moduleNameMapper). Todo teste que importa
* o módulo, direta ou indiretamente (HoverPrefetchLink, Header, páginas com `notFound`), recebe este arquivo: nenhum
* teste precisa montar um `jest.mock("next/navigation", ...)` parcial, e nenhum componente vê o módulo sem um export.
*
* Estado: `usePathname`, `useSearchParams`, `useParams` e `useSelectedLayoutSegment(s)` leem `navigationState`.
* Troque-o com `setNavigation({ pathname: "/cases" })` (ou `jest.mocked(usePathname).mockReturnValue("/cases")`) e
* volte ao padrão ("/", sem parâmetros) com `resetNavigation()` num `beforeEach`.
* Erros: `notFound` e `redirect` lançam como os do Next (com `digest`), e `notFound` lança com a mensagem
* "NEXT_NOT_FOUND", que os testes das páginas conferem.
*/
type Params = Record<string, string | string[]>;
interface NavigationState {
pathname: string | null;
searchParams: URLSearchParams;
params: Params;
segments: string[];
}
const initialState = (): NavigationState => ({
pathname: "/",
searchParams: new URLSearchParams(),
params: {},
segments: [],
});
export const navigationState: NavigationState = initialState();
/** Cada `jest.fn` com a implementação padrão, para o `resetNavigation` devolvê-la depois de um `mockReset`. */
const defaults: Array<[jest.Mock, (...args: never[]) => unknown]> = [];
function mockFn<Args extends unknown[], Result>(
implementation: (...args: Args) => Result,
) {
const fn = jest.fn(implementation);
defaults.push([fn as jest.Mock, implementation as never]);
return fn;
}
export function setNavigation(
next: Partial<Omit<NavigationState, "searchParams">> & {
searchParams?: string | URLSearchParams;
},
) {
const { searchParams, ...rest } = next;
Object.assign(navigationState, rest);
if (searchParams !== undefined)
navigationState.searchParams = new URLSearchParams(searchParams);
}
export function resetNavigation() {
Object.assign(navigationState, initialState());
for (const [fn, implementation] of defaults) {
fn.mockReset();
fn.mockImplementation(implementation);
}
}
export const router = {
push: mockFn(() => undefined),
replace: mockFn(() => undefined),
back: mockFn(() => undefined),
forward: mockFn(() => undefined),
refresh: mockFn(() => undefined),
prefetch: mockFn(() => undefined),
};
export const usePathname = mockFn(() => navigationState.pathname);
export const useRouter = mockFn(() => router);
export const useSearchParams = mockFn(() => navigationState.searchParams);
export const useParams = mockFn(() => navigationState.params);
export const useSelectedLayoutSegments = mockFn(() => navigationState.segments);
export const useSelectedLayoutSegment = mockFn(
() => navigationState.segments[0] ?? null,
);
export const useServerInsertedHTML = mockFn(() => undefined);
export const unstable_isUnrecognizedActionError = mockFn(() => false);
export const unstable_rethrow = mockFn((error: unknown): void => {
throw error;
});
export const RedirectType = { push: "push", replace: "replace" } as const;
export const ReadonlyURLSearchParams = URLSearchParams;
/** Erro com `digest`, como os que o Next lança para interromper a renderização. */
function nextError(message: string, digest: string): never {
throw Object.assign(new Error(message), { digest });
}
export const notFound = mockFn((): never => {
return nextError("NEXT_NOT_FOUND", "NEXT_HTTP_ERROR_FALLBACK;404");
});
export const forbidden = mockFn((): never => {
return nextError("NEXT_FORBIDDEN", "NEXT_HTTP_ERROR_FALLBACK;403");
});
export const unauthorized = mockFn((): never => {
return nextError("NEXT_UNAUTHORIZED", "NEXT_HTTP_ERROR_FALLBACK;401");
});
export const redirect = mockFn(
(url: string, type: "push" | "replace" = "replace"): never => {
return nextError("NEXT_REDIRECT", `NEXT_REDIRECT;${type};${url};307;`);
},
);
export const permanentRedirect = mockFn(
(url: string, type: "push" | "replace" = "replace"): never => {
return nextError("NEXT_REDIRECT", `NEXT_REDIRECT;${type};${url};308;`);
},
);
-32
View File
@@ -1,32 +0,0 @@
import type { ReactNode } from "react";
/**
* O React estável do Jest não exporta <ViewTransition> (só o canary do Next). Para conferir as props dele:
* `jest.mock("react", () => jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe());`
* O dublê põe `name`, `share` e `default` num <div> em volta dos filhos (data-vt-name, data-vt-share, data-vt-default).
*/
export function reactWithProbe() {
const actual = jest.requireActual("react");
function ViewTransition({
children,
name,
share,
default: fallback,
}: {
children?: ReactNode;
name?: string;
share?: string;
default?: string;
}) {
return actual.createElement(
"div",
{
"data-vt-name": name,
"data-vt-share": share,
"data-vt-default": fallback,
},
children,
);
}
return { ...actual, ViewTransition };
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,165 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import { isValidElement } from "react";
import {
OG_IMAGE_MAX_BYTES,
OG_SIZE,
OgCard,
readProjectOgImage,
} from "@/lib/og-card";
import { caseStudies } from "@/lib/site-content";
import Image, { generateImageMetadata } from "./opengraph-image";
/**
* Testes de caminhos-base de Image (imagem de compartilhamento do case):
* slug desconhecido, id da imagem diferente do case, case com capa gravada (entregue como está, em JPEG,
* abaixo de 150 KB), capa pesada demais (cartão simples) e projeto de laboratório.
*/
/** O jsdom não tem `Response`; a rota roda no Node e devolve uma. Aqui basta guardar o corpo e os cabeçalhos. */
class FakeResponse {
constructor(
readonly body: Uint8Array,
readonly init: { headers: Record<string, string> },
) {}
}
beforeAll(() => {
Object.assign(globalThis, { Response: FakeResponse });
});
jest.mock("@/lib/og-card", () => {
const actual = jest.requireActual("@/lib/og-card");
return { ...actual, readProjectOgImage: jest.fn(actual.readProjectOgImage) };
});
const mockedReadProjectOgImage = jest.mocked(readProjectOgImage);
const actualReadProjectOgImage =
jest.requireActual<typeof import("@/lib/og-card")>(
"@/lib/og-card",
).readProjectOgImage;
afterEach(() => {
mockedReadProjectOgImage.mockImplementation(actualReadProjectOgImage);
});
jest.mock("next/og", () => ({ ImageResponse: jest.fn() }));
const mockedImageResponse = jest.mocked(ImageResponse);
beforeEach(() => {
jest.mocked(notFound).mockClear();
mockedImageResponse.mockClear();
});
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
function renderImage(slug: string, imageId: string) {
return Image({
params: Promise.resolve({ slug }),
id: Promise.resolve(imageId),
});
}
function sentCard() {
const [element, options] = mockedImageResponse.mock.calls[0];
if (!isValidElement<React.ComponentProps<typeof OgCard>>(element)) {
throw new Error("ImageResponse did not receive an element");
}
return { card: element, options };
}
describe("opengraph-image Image", () => {
it("path: case with a recorded cover -> the cover itself, in JPEG, under 150 KB, and no generated card", async () => {
const caseStudy = getCase("sev-modaintima");
const response = (await renderImage(
caseStudy.id,
caseStudy.id,
)) as unknown as FakeResponse;
expect(notFound).not.toHaveBeenCalled();
expect(mockedImageResponse).not.toHaveBeenCalled();
expect(response).toBeInstanceOf(FakeResponse);
expect(response.init.headers["Content-Type"]).toBe("image/jpeg");
const cover = readFileSync(
join(process.cwd(), "public/videos/sev-modaintima/og.jpg"),
);
expect(Buffer.from(response.body).equals(cover)).toBe(true);
expect(response.body.length).toBeLessThan(150_000);
});
it("path: cover that weighs 150 KB or more -> the plain generated card, with no background", async () => {
const caseStudy = getCase("sev-modaintima");
mockedReadProjectOgImage.mockReturnValue(undefined);
expect(OG_IMAGE_MAX_BYTES).toBe(150_000);
const response = await renderImage(caseStudy.id, caseStudy.id);
expect(response).not.toBeInstanceOf(FakeResponse);
expect(mockedImageResponse).toHaveBeenCalledTimes(1);
const { card, options } = sentCard();
expect(card.type).toBe(OgCard);
expect(card.props).toMatchObject({
eyebrow: "Case de cliente",
title: caseStudy.title,
description: caseStudy.summary,
});
expect(card.props.backgroundImage).toBeUndefined();
expect(options).toEqual(OG_SIZE);
});
it("path: image metadata -> JPEG for covers, PNG for generated cards, and every case listed without a slug", () => {
const [sev] = generateImageMetadata({ params: { slug: "sev-modaintima" } });
const [twins] = generateImageMetadata({ params: { slug: "twins-lab" } });
expect(sev).toMatchObject({
id: "sev-modaintima",
contentType: "image/jpeg",
size: OG_SIZE,
});
expect(twins).toMatchObject({
id: "twins-lab",
contentType: "image/png",
size: OG_SIZE,
});
const all = generateImageMetadata({ params: {} });
expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id));
expect(
all.filter(({ contentType }) => contentType === "image/jpeg"),
).toHaveLength(6);
});
it("path: unknown slug -> notFound() and no image", async () => {
await expect(renderImage("nao-existe", "nao-existe")).rejects.toThrow(
"NEXT_NOT_FOUND",
);
expect(notFound).toHaveBeenCalledTimes(1);
expect(mockedImageResponse).not.toHaveBeenCalled();
});
it("path: known slug but another case's image id -> notFound() and no image", async () => {
await expect(renderImage("sev-modaintima", "lavacao-047")).rejects.toThrow(
"NEXT_NOT_FOUND",
);
expect(notFound).toHaveBeenCalledTimes(1);
expect(mockedImageResponse).not.toHaveBeenCalled();
});
it("path: laboratory case -> card labelled as a laboratory project, no cover when none is recorded", async () => {
const caseStudy = getCase("twins-lab");
await renderImage(caseStudy.id, caseStudy.id);
const { card } = sentCard();
expect(card.props).toMatchObject({
eyebrow: "Projeto de laboratório",
title: caseStudy.title,
});
expect(card.props.backgroundImage).toBeUndefined();
});
});
-73
View File
@@ -1,73 +0,0 @@
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import {
OG_CONTENT_TYPE,
OG_PHOTO_CONTENT_TYPE,
OG_SIZE,
OgCard,
readProjectOgImage,
} from "@/lib/og-card";
import { caseStudies, isClientCase } from "@/lib/site-content";
function getCaseStudy(slug: string) {
return caseStudies.find(({ id }) => id === slug);
}
// Quem tem a capa gravada (public/videos/<id>/og.jpg, abaixo de 150 KB) a entrega como está, em JPEG: o cartão em PNG com a capa de fundo passava de 270 KB.
function getImageMetadata(caseStudy: (typeof caseStudies)[number]) {
return {
id: caseStudy.id,
alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`,
size: OG_SIZE,
contentType: readProjectOgImage(caseStudy.id)
? OG_PHOTO_CONTENT_TYPE
: OG_CONTENT_TYPE,
};
}
export function generateImageMetadata({
params,
}: {
params: { slug?: string };
}) {
if (!params.slug) {
return caseStudies.map(getImageMetadata);
}
const caseStudy = getCaseStudy(params.slug);
if (!caseStudy) notFound();
return [getImageMetadata(caseStudy)];
}
export default async function Image({
params,
id,
}: {
params: Promise<{ slug: string }>;
id: Promise<string>;
}) {
const [{ slug }, imageId] = await Promise.all([params, id]);
const caseStudy = getCaseStudy(slug);
if (!caseStudy || imageId !== caseStudy.id) notFound();
const photo = readProjectOgImage(caseStudy.id);
if (photo) {
return new Response(new Uint8Array(photo), {
headers: {
"Content-Type": OG_PHOTO_CONTENT_TYPE,
"Cache-Control": "public, max-age=86400",
},
});
}
return new ImageResponse(
<OgCard
eyebrow={
isClientCase(caseStudy) ? "Case de cliente" : "Projeto de laboratório"
}
title={caseStudy.title}
description={caseStudy.summary}
/>,
OG_SIZE,
);
}
@@ -1,39 +0,0 @@
import { render, screen } from "@testing-library/react";
import { setNavigation } from "@/__mocks__/next/navigation";
import { caseStudies } from "@/lib/site-content";
import CasePage from "./page";
/**
* Teste de caminhos-base de CasePage: os "fatos do projeto" no topo.
* O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`).
*/
beforeEach(() => {
setNavigation({ pathname: "/cases" });
});
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
describe("CasePage: fatos do projeto", () => {
it("path: empty facts list -> no list in the hero, summary stays", async () => {
const caseStudy = getCase("lavacao-047");
caseStudy.facts = [];
try {
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
render(ui);
expect(
screen.queryByRole("list", { name: "Fatos do projeto" }),
).toBeNull();
expect(screen.getByText(caseStudy.summary)).toBeVisible();
} finally {
delete caseStudy.facts;
}
});
});
-146
View File
@@ -1,146 +0,0 @@
import { render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { setNavigation } from "@/__mocks__/next/navigation";
import { caseStudies, getRelatedServices } from "@/lib/site-content";
import { getCaseMedia } from "../_components/case-media";
import CasePage, { generateMetadata } from "./page";
/**
* Testes de caminhos-base de CasePage e generateMetadata (complemento de page.test.tsx).
* Cobrem só o que o arquivo principal não exercita: slug desconhecido, mídia só no celular,
* demonstração sem captura, resultado vazio e case sem serviço relacionado.
*/
beforeEach(() => {
setNavigation({ pathname: "/cases" });
});
jest.mock("../_components/case-media", () => {
const actual = jest.requireActual("../_components/case-media");
return { ...actual, getCaseMedia: jest.fn(actual.getCaseMedia) };
});
const actualGetCaseMedia = jest.requireActual<
typeof import("../_components/case-media")
>("../_components/case-media").getCaseMedia;
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
beforeEach(() => {
jest.mocked(notFound).mockClear();
});
afterEach(() => {
mockedGetCaseMedia.mockImplementation(actualGetCaseMedia);
});
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
async function renderCase(id: string) {
const ui = await CasePage({ params: Promise.resolve({ slug: id }) });
return render(ui);
}
describe("CasePage: caminhos que o teste principal não cobre", () => {
it("path: unknown slug -> calls notFound() and renders nothing", async () => {
await expect(
CasePage({ params: Promise.resolve({ slug: "nao-existe" }) }),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(notFound).toHaveBeenCalledTimes(1);
});
it("path: no demo and no desktop capture, but a mobile capture -> shows only the phone", async () => {
const caseStudy = getCase("as-servicos");
expect(caseStudy.demoUrl).toBeNull();
mockedGetCaseMedia.mockImplementation((id, device) =>
device === "mobile" ? actualGetCaseMedia(id, device) : undefined,
);
const { container } = await renderCase(caseStudy.id);
expect(
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
).toBeVisible();
expect(
screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
}),
).toBeInTheDocument();
expect(
screen.queryByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
}),
).toBeNull();
// sem captura do computador, o topo fica em uma coluna, sem moldura de navegador
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(
container.querySelector("section")?.querySelector(".grid"),
).toBeNull();
expect(container.querySelector(".ds-phone")).not.toBeNull();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
});
it("path: demo published but no capture on disk -> keeps the block with the site button and no video", async () => {
const caseStudy = getCase("sev-modaintima");
expect(caseStudy.demoUrl).toBeTruthy();
mockedGetCaseMedia.mockImplementation(() => undefined);
const { container } = await renderCase(caseStudy.id);
expect(
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
).toBeVisible();
expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector(".ds-phone")).toBeNull();
});
it("path: result is an empty list -> no result section", async () => {
const caseStudy = getCase("lavacao-047");
caseStudy.result = [];
try {
await renderCase(caseStudy.id);
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
expect(
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
).toBeVisible();
} finally {
delete caseStudy.result;
}
});
it("path: case that no service points to -> no related service links, contact stays", async () => {
const caseStudy = getCase("nutriscan");
expect(getRelatedServices(caseStudy.id)).toHaveLength(0);
await renderCase(caseStudy.id);
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
// o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos)
const contacts = screen.getAllByRole("link", {
name: "Chamar no WhatsApp",
});
expect(contacts).toHaveLength(1);
expect(contacts[0]).toBeVisible();
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
).toHaveAttribute("href", "/cases");
});
});
describe("generateMetadata: caminho do slug desconhecido", () => {
it("path: unknown slug -> only a 'not found' title, no canonical or social tags", async () => {
const metadata = await generateMetadata({
params: Promise.resolve({ slug: "nao-existe" }),
});
expect(metadata).toEqual({ title: "Case não encontrado" });
});
});
-795
View File
@@ -1,795 +0,0 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { setNavigation } from "@/__mocks__/next/navigation";
import {
buttonLabels,
caseStudies,
clientCasesForListing,
getRelatedServices,
siteUrl,
} from "@/lib/site-content";
import { similarProjectSubject } from "@/lib/whatsapp";
import CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } from "./page";
beforeEach(() => {
setNavigation({ pathname: "/cases" });
});
const clientCaseIds = [
"sev-modaintima",
"lavacao-047",
"sc-plus",
"as-servicos",
"julia-beauty",
];
const labCaseIds = [
"nutriscan",
"twins-lab",
"reddit-explorer",
"gcc-installer",
];
const publicCaseIds = [...clientCaseIds, ...labCaseIds];
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
async function renderCase(id: string) {
const ui = await CasePage({ params: Promise.resolve({ slug: id }) });
return render(ui);
}
function isBefore(first: Element, second: Element) {
return Boolean(
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
);
}
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
const scripts = container.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]',
);
if (scripts.length === 0) throw new Error("Missing JSON-LD");
return Array.from(scripts).flatMap((script) => {
if (!script.textContent) throw new Error("Empty JSON-LD");
const value = JSON.parse(script.textContent) as Record<string, unknown>;
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
});
}
/**
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
* ("Chamar no WhatsApp") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Chamar no WhatsApp" seguidos.
*/
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
const ctas = screen.getAllByRole("link", { name: buttonLabels.whatsapp });
expect(ctas).toHaveLength(1);
for (const cta of ctas) {
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(
similarProjectSubject(caseStudy.title),
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
// limão preenchido só no contato: o botão de play e os demais não o usam
expect(cta.className).toContain("bg-[var(--slash)]");
}
}
it("exposes the nine public cases, the new client cases among them", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(9);
expect(new Set(staticSlugs).size).toBe(9);
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
expect(staticSlugs).toContain("sev-modaintima");
expect(staticSlugs).toContain("lavacao-047");
expect(staticSlugs).toContain("julia-beauty");
for (const id of [
"sev-modaintima",
"lavacao-047",
"as-servicos",
"julia-beauty",
]) {
const caseStudy = getCase(id);
const url = `${siteUrl}/cases/${caseStudy.id}`;
const { metaTitle: title, metaDescription: description } = caseStudy;
const metadata = await generateMetadata({
params: Promise.resolve({ slug: caseStudy.id }),
});
expect(metadata).toMatchObject({
title,
description,
alternates: { canonical: url },
openGraph: { title, description, url },
twitter: { title, description },
});
expect(metadata).not.toHaveProperty("keywords");
}
});
it("renders a client case in the sales order, with media, contact and collapsed technical details", async () => {
const caseStudy = getCase("sev-modaintima");
const { container } = await renderCase(caseStudy.id);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveTextContent(caseStudy.headline);
expect(isBefore(heading, screen.getByText(caseStudy.summary))).toBe(true);
expect(screen.getByText(/Case de cliente/)).toBeVisible();
const sectionNames = [
"O cliente e o contexto",
"O problema",
"O que foi feito",
"Como ficou",
"O que o cliente passou a poder fazer",
"Quer um projeto parecido com este?",
];
const sectionHeadings = sectionNames.map((name) =>
screen.getByRole("heading", { level: 2, name }),
);
for (let index = 1; index < sectionHeadings.length; index += 1) {
expect(isBefore(sectionHeadings[index - 1], sectionHeadings[index])).toBe(
true,
);
}
expect(isBefore(heading, sectionHeadings[0])).toBe(true);
expect(screen.getByText(caseStudy.context)).toBeVisible();
expect(screen.getByText(caseStudy.problem)).toBeVisible();
for (const delivery of caseStudy.deliveries) {
expect(screen.getByText(delivery)).toBeVisible();
}
// sem dado real, não há seção de resultado
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
// mídia: o pôster do computador e do celular com um botão de play; nenhum vídeo parado com controles
expect(container.querySelector("video")).toBeNull();
const desktopPlay = screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
});
const mobilePlay = screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
});
const mediaSection = screen
.getByRole("heading", { level: 2, name: "Como ficou" })
.closest("section");
expect(mediaSection?.querySelector(".ds-bframe")).toContainElement(
desktopPlay,
);
expect(mediaSection?.querySelector(".ds-phone")).toContainElement(mobilePlay);
expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
// um botão de contato só, no fecho: as capacidades (logo depois de "Como ficou") não repetem o botão
expectContactAndNoDeadLinks(caseStudy);
const capabilitiesSection = sectionHeadings[4].closest("section");
expect(
within(capabilitiesSection as HTMLElement).queryByRole("link", {
name: buttonLabels.whatsapp,
}),
).toBeNull();
const closingSection = sectionHeadings[5].closest("section");
expect(closingSection).toContainElement(
within(closingSection as HTMLElement).getByRole("link", {
name: buttonLabels.whatsapp,
}),
);
for (const capability of caseStudy.capabilities ?? []) {
expect(
within(capabilitiesSection as HTMLElement).getByText(capability),
).toBeVisible();
}
// detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
const details = container.querySelector("details");
if (!details) throw new Error("Missing technical details");
expect(details).not.toHaveAttribute("open");
expect(within(details).getByText("Detalhes técnicos")).toBeVisible();
for (const technology of caseStudy.stack) {
expect(within(details).getByText(technology)).not.toBeVisible();
}
for (const decision of caseStudy.decisions) {
expect(within(details).getByText(decision)).not.toBeVisible();
}
const doneSection = sectionHeadings[2].closest("section");
expect(doneSection).toContainElement(details);
expect(isBefore(screen.getByText(caseStudy.deliveries[0]), details)).toBe(
true,
);
expect(isBefore(details, sectionHeadings[3])).toBe(true);
expect(container.querySelectorAll("details")).toHaveLength(1);
expect(
container.querySelector("section[aria-label='Detalhes técnicos']"),
).toBeNull();
// o que saiu: desafio verificável, limites da evidência, fontes e commits
for (const name of [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
]) {
expect(screen.queryByRole("heading", { name })).toBeNull();
}
expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull();
expect(container.textContent).not.toMatch(/Commit:|[0-9a-f]{40}/);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"CreativeWork",
"BreadcrumbList",
]);
expect(JSON.stringify(graph)).not.toMatch(
/Article|FAQPage|ProfessionalService|LocalBusiness/,
);
const breadcrumb = graph[1] as {
itemListElement: Array<{
"@type": string;
position: number;
name: string;
item: string;
}>;
};
const canonicalUrl = `${siteUrl}/cases/${caseStudy.id}`;
expect(breadcrumb.itemListElement).toEqual([
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
{
"@type": "ListItem",
position: 3,
name: caseStudy.title,
item: canonicalUrl,
},
]);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Trilha de navegação",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
current: link.getAttribute("aria-current"),
})),
).toEqual([
{ name: "Início", href: "/", current: null },
{ name: "Cases", href: "/cases", current: null },
{
name: caseStudy.title,
href: `/cases/${caseStudy.id}`,
current: "page",
},
]);
});
it("renders the two new client cases from the portfolio data", async () => {
for (const id of ["sev-modaintima", "lavacao-047"]) {
const caseStudy = getCase(id);
const { unmount } = await renderCase(id);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
caseStudy.headline,
);
expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
// um botão de contato só, no fecho, mesmo com "Como ficou" na página
expectContactAndNoDeadLinks(caseStudy);
unmount();
}
});
it("labels a laboratory case, skips the media block and shows its service as a card", async () => {
const caseStudy = getCase("twins-lab");
const { container } = await renderCase(caseStudy.id);
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(
screen.getByRole("heading", { level: 2, name: "O contexto do projeto" }),
).toBeVisible();
expect(screen.queryByRole("heading", { name: "Como ficou" })).toBeNull();
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
expect(screen.queryByText(/indispon/i)).toBeNull();
const relatedLinks = screen.getAllByRole("link", {
name: /^Ver o serviço:/,
});
expect(relatedLinks.map((link) => link.getAttribute("href"))).toEqual(
getRelatedServices(caseStudy.id).map(({ slug }) => `/servicos/${slug}`),
);
expect(relatedLinks).toHaveLength(1);
expectContactAndNoDeadLinks(caseStudy);
});
it("turns the contact of a laboratory case into the 'Fale sobre o seu caso' invitation, with the same button label as a client case", async () => {
for (const id of labCaseIds) {
const { unmount } = await renderCase(id);
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
expect(
screen.queryByRole("link", {
name: /Conversar sobre um projeto parecido|Tem um problema parecido/,
}),
).toBeNull();
expect(
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
// um botão só, com ou sem "Como ficou" (NutriScan tem)
expectContactAndNoDeadLinks(getCase(id));
// o laboratório não tem "o que o cliente passou a poder fazer"
expect(
screen.queryByRole("heading", {
name: "O que o cliente passou a poder fazer",
}),
).toBeNull();
unmount();
}
});
it("writes what the client can do now right after 'Como ficou', with no contact button of its own, for every client case", async () => {
for (const id of clientCaseIds) {
const caseStudy = getCase(id);
const { unmount } = await renderCase(id);
const media = screen.getByRole("heading", { level: 2, name: "Como ficou" });
const capabilities = screen.getByRole("heading", {
level: 2,
name: "O que o cliente passou a poder fazer",
});
// a seção vem logo depois da mídia, sem outra seção no meio
const mediaSection = media.closest("section");
const capabilitiesSection = capabilities.closest("section");
expect(mediaSection?.nextElementSibling).toBe(capabilitiesSection);
const items = within(capabilitiesSection as HTMLElement)
.getAllByRole("listitem")
.map((item) => item.textContent);
expect(items).toEqual(caseStudy.capabilities);
expect(items.length).toBeGreaterThanOrEqual(2);
expect(items.length).toBeLessThanOrEqual(3);
// sem número inventado
expect(items.join(" ")).not.toMatch(/\d/);
// o único botão de contato é o do fecho, depois da lista: não há dois "Chamar no WhatsApp" seguidos
expect(
within(capabilitiesSection as HTMLElement).queryByRole("link", {
name: buttonLabels.whatsapp,
}),
).toBeNull();
const closingSection = screen
.getByRole("heading", {
level: 2,
name: "Quer um projeto parecido com este?",
})
.closest("section");
expect(capabilitiesSection?.nextElementSibling).toBe(closingSection);
expect(
within(closingSection as HTMLElement).getAllByRole("link", {
name: buttonLabels.whatsapp,
}),
).toHaveLength(1);
unmount();
}
// a frase do dono da Lavação 047 vem dos dados do projeto
await renderCase("lavacao-047");
expect(
screen.getByText(
"O dono muda preços, textos, fotos e o mapa sem pedir a ninguém, e a mudança aparece no site sem nova publicação.",
),
).toBeVisible();
});
it("shows the author's role and no site button for a client project without a public site", async () => {
const caseStudy = getCase("as-servicos");
expect(caseStudy.demoUrl).toBeNull();
await renderCase(caseStudy.id);
expect(screen.getByText("Minha participação:")).toBeVisible();
expect(
screen.getByText(caseStudy.role ?? "", { exact: false }),
).toBeVisible();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
expect(screen.queryByText(/indispon/i)).toBeNull();
// a capa continua nas molduras
expect(
screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
}),
).toBeInTheDocument();
});
it("shows the result section only when the case has real data", async () => {
const caseStudy = getCase("lavacao-047");
const { unmount } = await renderCase(caseStudy.id);
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
unmount();
caseStudy.result = ["Dado real informado pelo cliente."];
try {
await renderCase(caseStudy.id);
const heading = screen.getByRole("heading", {
level: 2,
name: "Resultado",
});
expect(screen.getByText("Dado real informado pelo cliente.")).toBeVisible();
expect(
isBefore(
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
heading,
),
).toBe(true);
expect(
isBefore(
heading,
screen.getByRole("heading", {
level: 2,
name: "Quer um projeto parecido com este?",
}),
),
).toBe(true);
} finally {
delete caseStudy.result;
}
});
it("shows at most two related services, as cards under the contact button", async () => {
// lavacao-047 tem três serviços ligados; só os dois primeiros viram cards
const sev = getCase("lavacao-047");
const all = getRelatedServices(sev.id);
expect(all.length).toBeGreaterThan(2);
const { unmount } = await renderCase(sev.id);
const cards = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
expect(cards).toHaveLength(2);
expect(cards.map((link) => link.getAttribute("href"))).toEqual(
all.slice(0, 2).map(({ slug }) => `/servicos/${slug}`),
);
for (const link of cards) {
const card = link.closest("article");
expect(card).toHaveClass("ds-card");
expect(
within(card as HTMLElement).getByRole("heading", { level: 3 }),
).toBeVisible();
}
expect(
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull();
// o botão do fecho (o único) vem antes dos cartões
const contact = screen.getByRole("link", { name: buttonLabels.whatsapp });
expect(isBefore(contact, cards[0])).toBe(true);
unmount();
// sc-plus e a SEV têm dois (a SEV liga à oferta de site e à de sistema); a AS Serviços, um
for (const [id, expected] of [
["sc-plus", 2],
["as-servicos", 1],
["sev-modaintima", 2],
] as const) {
const { unmount: unmountCase } = await renderCase(id);
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
expect(links).toHaveLength(expected);
expect(links.map((link) => link.getAttribute("href"))).toEqual(
getRelatedServices(id)
.slice(0, 2)
.map(({ slug }) => `/servicos/${slug}`),
);
expectContactAndNoDeadLinks(getCase(id));
unmountCase();
}
});
it("links the SEV case to the site offer and to the system offer, each by the name of the offer", async () => {
await renderCase("sev-modaintima");
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/servicos/criacao-de-sites",
"/servicos/engenharia-de-software",
]);
expect(links.map((link) => link.textContent)).toEqual([
"Ver o serviço: Site que traz pedidos pelo WhatsApp",
"Ver o serviço: Sistema para tirar a operação da planilha",
]);
});
it("writes the CreativeWork of a client case with creator, client and keywords", async () => {
const caseStudy = getCase("lavacao-047");
const { container } = await renderCase(caseStudy.id);
const work = readJsonLdEntities(container).find(
(node) => node["@type"] === "CreativeWork",
);
expect(work).toEqual({
"@type": "CreativeWork",
"@id": `${siteUrl}/cases/lavacao-047#creative-work`,
name: "Lavação 047: Site e painel para lavação automotiva",
description: caseStudy.metaDescription,
url: `${siteUrl}/cases/lavacao-047`,
image: `${siteUrl}/cases/lavacao-047/opengraph-image/lavacao-047`,
inLanguage: "pt-BR",
creator: { "@id": `${siteUrl}/#person` },
sourceOrganization: {
"@type": "Organization",
name: "Lavação 047",
url: "https://landing-page-lavacao047.vercel.app",
},
keywords: caseStudy.stack.join(", "),
});
});
it("writes the CreativeWork of a laboratory case without a client", async () => {
const caseStudy = getCase("twins-lab");
const { container } = await renderCase(caseStudy.id);
const work = readJsonLdEntities(container).find(
(node) => node["@type"] === "CreativeWork",
);
expect(work).toMatchObject({
name: "Twins Lab: Painel ao vivo de um reator simulado",
url: `${siteUrl}/cases/twins-lab`,
creator: { "@id": `${siteUrl}/#person` },
keywords: "Next.js, TypeScript, NextAuth, MongoDB, SSE",
});
expect(work).not.toHaveProperty("sourceOrganization");
});
it("puts the project cover next to the title, and below it on a phone, in a browser frame", async () => {
const caseStudy = getCase("sev-modaintima");
const { container } = await renderCase(caseStudy.id);
const hero = container.querySelector("section");
if (!hero) throw new Error("Missing hero");
const frame = hero.querySelector(".ds-bframe");
if (!frame) throw new Error("Missing hero frame");
expect(frame.querySelector("img")).toHaveAttribute(
"alt",
`Captura de tela do site ${caseStudy.title}`,
);
expect(frame.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
);
// a capa abre a página: carrega antes das demais imagens
expect(frame.querySelector("img")).not.toHaveAttribute("loading", "lazy");
// ordem do documento (a do celular): título, mídia, resumo
const heading = screen.getByRole("heading", { level: 1 });
const summary = screen.getByText(caseStudy.summary);
expect(hero).toContainElement(summary);
expect(isBefore(heading, frame)).toBe(true);
expect(isBefore(frame, summary)).toBe(true);
// no computador: duas colunas, texto à esquerda e mídia à direita
const grid = frame.closest(".grid");
expect(grid?.className).toContain("lg:grid-cols-[");
expect(frame.parentElement?.className).toContain("lg:col-start-2");
expect(heading.parentElement?.className).toContain("lg:col-start-1");
});
it("keeps the hero in one column when the case has no capture", async () => {
const { container } = await renderCase("twins-lab");
const hero = container.querySelector("section");
expect(hero?.querySelector(".ds-bframe")).toBeNull();
expect(hero?.querySelector(".grid")).toBeNull();
});
it("shows the optional facts only when they are filled in", async () => {
const caseStudy = getCase("lavacao-047");
const empty = await renderCase(caseStudy.id);
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
empty.unmount();
caseStudy.facts = ["Fato de teste um", "Fato de teste dois"];
try {
await renderCase(caseStudy.id);
const list = screen.getByRole("list", { name: "Fatos do projeto" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
expect(within(list).getByText("Fato de teste um")).toBeVisible();
expect(isBefore(screen.getByText(caseStudy.summary), list)).toBe(true);
} finally {
delete caseStudy.facts;
}
});
it("starts the video only when the visitor presses play, and moves focus to it", async () => {
const caseStudy = getCase("sev-modaintima");
const { container } = await renderCase(caseStudy.id);
const play = screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
});
expect(play).toBeVisible();
expect(play.className).not.toContain("--slash");
expect(container.querySelector("video")).toBeNull();
fireEvent.click(play);
const video = screen.getByLabelText(
`Vídeo do site ${caseStudy.title} no computador`,
);
expect(video.tagName).toBe("VIDEO");
expect(video).toHaveAttribute(
"poster",
`/videos/${caseStudy.id}/desktop.webp`,
);
expect(video).toHaveAttribute("controls");
expect(video).toHaveFocus();
expect(
video.querySelectorAll("source[src^='/videos/sev-modaintima/']"),
).toHaveLength(2);
expect(video.closest(".ds-bframe")).not.toBeNull();
expect(
screen.queryByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
}),
).toBeNull();
// o celular continua com o pôster até a pessoa tocar nele
expect(
screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
}),
).toBeVisible();
});
it("gives the case page the same shell as the home, with no narrow max-width", async () => {
const { container } = await renderCase("sev-modaintima");
for (const shell of container.querySelectorAll(".ds-section-shell")) {
expect(shell.className).not.toMatch(/max-w-/);
}
});
it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
const { container } = render(<CasesPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(/Abra um case/);
// o título e o subtítulo não repetem "Veja o que já"
expect(heading.nextElementSibling).not.toHaveTextContent(/Veja o que já/);
expect(container.textContent).not.toMatch(/Selected work/i);
const clientHeading = screen.getByRole("heading", {
level: 2,
name: "Projetos de clientes",
});
const labHeading = screen.getByRole("heading", {
level: 2,
name: "Laboratório",
});
expect(isBefore(heading, clientHeading)).toBe(true);
expect(isBefore(clientHeading, labHeading)).toBe(true);
expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible();
// cases de cliente, na ordem da vitrine (SEV primeiro): capa (pôster) em moldura de navegador e botão "Ver case" em contorno
const listedIds = clientCasesForListing.map(({ id }) => id);
expect([...listedIds].sort()).toEqual([...clientCaseIds].sort());
expect(listedIds).toEqual([
"sev-modaintima",
"lavacao-047",
"sc-plus",
"as-servicos",
"julia-beauty",
]);
const clientLinks = listedIds.map((id) => {
const card = container
.querySelector(`a[href="/cases/${id}"]`)
?.closest("article");
if (!card) throw new Error(`Missing client card ${id}`);
expect(isBefore(clientHeading, card)).toBe(true);
expect(isBefore(card, labHeading)).toBe(true);
expect(card.querySelector(".ds-bframe img")).toHaveAttribute(
"alt",
`Captura de tela do site ${getCase(id).title}`,
);
return within(card).getByRole("link", {
name: `${buttonLabels.case} ${getCase(id).title}`,
});
});
expect(clientLinks.map((link) => link.getAttribute("href"))).toEqual(
listedIds.map((id) => `/cases/${id}`),
);
// laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão
const labLinks = within(
labHeading.closest("section") as HTMLElement,
).getAllByRole("link", { name: /^Ver case / });
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
labCaseIds.map((id) => `/cases/${id}`),
);
const labList = labLinks[0].closest("ul");
expect(labList?.querySelectorAll(":scope > li")).toHaveLength(
labCaseIds.length,
);
expect(labList?.querySelector("article, .ds-card, .ds-bframe")).toBeNull();
labLinks.forEach((link, index) => {
const row = link.closest("li");
const caseStudy = getCase(labCaseIds[index]);
expect(isBefore(labHeading, link)).toBe(true);
expect(link).toHaveAccessibleName(`Ver case ${caseStudy.title}`);
expect(
within(row as HTMLElement).getByRole("heading", {
level: 3,
name: caseStudy.title,
}),
).toBeVisible();
expect(
within(row as HTMLElement).getByText(caseStudy.subtitle),
).toBeVisible();
expect(
within(row as HTMLElement).getByText("Projeto próprio"),
).toBeVisible();
// sem o resumo longo do cartão antigo
expect(row).not.toHaveTextContent(caseStudy.summary);
});
// contato pelo WhatsApp, sem /#contact nem GitHub
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
expect(hrefs).toContain("/servicos");
const cta = screen.getByRole("link", { name: buttonLabels.whatsapp });
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Trilha de navegação",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => [link.textContent, link.getAttribute("href")]),
).toEqual([
["Início", "/"],
["Cases", "/cases"],
]);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"CollectionPage",
"ItemList",
"BreadcrumbList",
]);
const itemList = graph[1] as {
itemListElement: Array<{
"@type": string;
position: number;
name: string;
url: string;
}>;
};
const visibleItems = [...clientLinks, ...labLinks].map((link, index) => {
const href = link.getAttribute("href");
// cartão do cliente ou linha da lista do laboratório
const card = link.closest("article, li");
if (!card) throw new Error(`Missing visible card for ${href}`);
const expected = getCase((href ?? "").replace("/cases/", ""));
expect(card).toHaveTextContent(expected.title);
return {
"@type": "ListItem",
position: index + 1,
name: expected.title,
url: `${siteUrl}${href}`,
};
});
expect(itemList.itemListElement).toEqual(visibleItems);
});
-148
View File
@@ -1,148 +0,0 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { safeJsonLd } from "@/lib/jsonld";
import {
buildBreadcrumbList,
buildCaseJsonLd,
buildPageSocialMetadata,
} from "@/lib/seo";
import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
import { CaseCapabilitiesSection } from "../_components/CaseCapabilitiesSection";
import { CaseContactSection } from "../_components/CaseContactSection";
import { CaseHero } from "../_components/CaseHero";
import { CaseMediaSection } from "../_components/CaseMediaSection";
import { CaseResultSection } from "../_components/CaseResultSection";
interface CasePageProps {
params: Promise<{ slug: string }>;
}
export function generateStaticParams() {
return caseStudies.map((caseStudy) => ({ slug: caseStudy.id }));
}
export async function generateMetadata({
params,
}: CasePageProps): Promise<Metadata> {
const { slug } = await params;
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) return { title: "Case não encontrado" };
const url = `${siteUrl}/cases/${caseStudy.id}`;
return {
title: caseStudy.metaTitle,
description: caseStudy.metaDescription,
...buildPageSocialMetadata({
url,
title: caseStudy.metaTitle,
description: caseStudy.metaDescription,
type: "article",
}),
};
}
export default async function CasePage({ params }: CasePageProps) {
const { slug } = await params;
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) notFound();
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
const isClient = isClientCase(caseStudy);
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
buildCaseJsonLd(caseStudy),
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", `${siteUrl}/cases`],
[caseStudy.title, pageUrl],
] as const),
],
};
return (
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<article>
<CaseHero caseStudy={caseStudy} isClient={isClient} />
<section className="ds-section" aria-labelledby="case-context">
<div className="ds-section-shell">
<div className="ds-grid-2 items-start">
<div className="max-w-[68ch]">
<h2 id="case-context" className="ds-card-title !mt-0">
{isClient
? "O cliente e o contexto"
: "O contexto do projeto"}
</h2>
<p className="ds-copy">{caseStudy.context}</p>
{caseStudy.role ? (
<p className="ds-copy">
<strong className="font-semibold text-[var(--ink)]">
Minha participação:
</strong>{" "}
{caseStudy.role}
</p>
) : null}
</div>
<div className="max-w-[68ch]">
<h2 id="case-problem" className="ds-card-title !mt-0">
O problema
</h2>
<p className="ds-copy">{caseStudy.problem}</p>
</div>
</div>
</div>
</section>
<section className="ds-section" aria-labelledby="case-done">
<div className="ds-section-shell">
<div className="max-w-[860px]">
<h2 id="case-done" className="ds-card-title !mt-0">
O que foi feito
</h2>
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
{caseStudy.deliveries.map((delivery) => (
<li key={delivery}>{delivery}</li>
))}
</ul>
<details className="mt-sp-8 border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
Detalhes técnicos
</summary>
<div className="max-w-[80ch]">
<p className="ds-kicker mt-sp-5">Tecnologias</p>
<ul
className="ds-tags !mt-sp-3 !pt-0"
aria-label="Tecnologias do projeto"
>
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
</ul>
<p className="ds-kicker mt-sp-6">Decisões técnicas</p>
<ul className="ds-list !mt-sp-3 space-y-sp-2">
{caseStudy.decisions.map((decision) => (
<li key={decision}>{decision}</li>
))}
</ul>
</div>
</details>
</div>
</div>
</section>
<CaseMediaSection caseStudy={caseStudy} />
<CaseCapabilitiesSection caseStudy={caseStudy} isClient={isClient} />
<CaseResultSection result={caseStudy.result} />
<CaseContactSection caseStudy={caseStudy} isClient={isClient} />
</article>
</main>
);
}
@@ -1,69 +0,0 @@
import { render, screen } from "@testing-library/react";
import { setNavigation } from "@/__mocks__/next/navigation";
import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
/**
* Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Chamar no WhatsApp"
* seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não).
*/
beforeEach(() => {
setNavigation({ pathname: "/cases" });
});
const capabilities = [
"O dono muda os preços sem pedir a ninguém.",
"O cliente pede pelo WhatsApp com a mensagem pronta.",
];
describe("CaseCapabilitiesSection", () => {
it("path: items -> the list under the client title, with no contact button of its own", () => {
const { container } = render(
<CaseCapabilitiesSection caseStudy={{ capabilities }} isClient />,
);
expect(
screen.getByRole("heading", {
level: 2,
name: "O que o cliente passou a poder fazer",
}),
).toBeVisible();
for (const item of capabilities) {
expect(screen.getByText(item)).toBeVisible();
}
// o botão "Chamar no WhatsApp" é do fecho do case (CaseContactSection), não desta seção
expect(screen.queryByRole("link")).toBeNull();
expect(container.querySelectorAll("a")).toHaveLength(0);
});
it("path: a laboratory case with items -> the title does not talk about a client", () => {
render(
<CaseCapabilitiesSection caseStudy={{ capabilities }} isClient={false} />,
);
expect(
screen.getByRole("heading", {
level: 2,
name: "O que dá para fazer com ele",
}),
).toBeVisible();
expect(screen.queryByText(/o cliente passou a poder fazer/i)).toBeNull();
});
it("path: no items -> renders nothing, with no button on its own even when the case has a capture or a site", () => {
const { container } = render(
<CaseCapabilitiesSection caseStudy={{}} isClient />,
);
expect(container).toBeEmptyDOMElement();
});
it("path: an empty list of items -> renders nothing", () => {
const { container } = render(
<CaseCapabilitiesSection caseStudy={{ capabilities: [] }} isClient />,
);
expect(container).toBeEmptyDOMElement();
});
});
@@ -1,34 +0,0 @@
import type { CaseStudyContent } from "@/lib/site-content";
/**
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam).
* Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois
* "Chamar no WhatsApp" seguidos. Sem itens, a seção não aparece.
*/
export function CaseCapabilitiesSection({
caseStudy,
isClient,
}: {
caseStudy: Pick<CaseStudyContent, "capabilities">;
isClient: boolean;
}) {
const capabilities = caseStudy.capabilities ?? [];
if (capabilities.length === 0) return null;
return (
<section className="ds-section" aria-labelledby="case-capabilities">
<div className="ds-section-shell">
<h2 id="case-capabilities" className="ds-card-title !mt-0">
{isClient
? "O que o cliente passou a poder fazer"
: "O que dá para fazer com ele"}
</h2>
<ul className="ds-list max-w-[78ch] space-y-sp-3 pl-[1.15rem]">
{capabilities.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</section>
);
}
@@ -1,69 +0,0 @@
import { render, screen } from "@testing-library/react";
import { getRelatedServices } from "@/lib/site-content";
import { similarProjectSubject } from "@/lib/whatsapp";
import { CaseContactSection } from "./CaseContactSection";
/**
* Testes de caminhos-base de CaseContactSection: o convite de um case de cliente ("projeto parecido") e o de
* um projeto de laboratório ("problema parecido" no botão), o link para a lista de cases e os serviços ligados.
* A mensagem do WhatsApp é a mesma frase nos dois: "um projeto parecido com o {título}".
*/
const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" };
const nutri = { id: "nutriscan", title: "NutriScan" };
function messageOf(link: HTMLElement) {
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
}
describe("CaseContactSection", () => {
it("path: client case -> 'projeto parecido' invitation, WhatsApp with the case title, services listed", () => {
render(<CaseContactSection caseStudy={sev} isClient />);
expect(
screen.getByRole("heading", {
level: 2,
name: "Quer um projeto parecido com este?",
}),
).toBeVisible();
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
expect(cta).toHaveAttribute("target", "_blank");
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
expect(messageOf(cta)).toContain(
`Quero conversar sobre ${similarProjectSubject(sev.title)}.`,
);
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
expect(
screen.getAllByRole("link", { name: /^Ver o serviço:/ }).length,
).toBeGreaterThan(0);
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
).toHaveAttribute("href", "/cases");
});
it("path: laboratory case -> 'problema parecido' invitation and no mention of a similar project", () => {
render(<CaseContactSection caseStudy={nutri} isClient={false} />);
expect(
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
// o rótulo do botão é o mesmo do case de cliente: uma lista só
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
expect(messageOf(cta)).toContain(
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
);
expect(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /);
expect(
screen.queryByRole("link", {
name: /Tem um problema parecido|Conversar sobre um projeto parecido/,
}),
).toBeNull();
// o NutriScan não é citado por nenhum serviço: sem cartões, o convite continua
expect(getRelatedServices(nutri.id)).toHaveLength(0);
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
).toBeVisible();
});
});
@@ -1,60 +0,0 @@
import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
import { similarProjectSubject } from "@/lib/whatsapp";
import { CaseRelatedServices } from "./CaseRelatedServices";
/**
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
* O botão abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também:
* `similarProjectSubject`.
*/
const contactCopy = {
client: { title: "Quer um projeto parecido com este?" },
lab: { title: "Fale sobre o seu caso" },
} as const;
/** Fecho do case: convite ao WhatsApp, volta para a lista de cases e os serviços ligados ao projeto. */
export function CaseContactSection({
caseStudy,
isClient,
}: {
caseStudy: Pick<CaseStudyContent, "id" | "title">;
isClient: boolean;
}) {
const copy = contactCopy[isClient ? "client" : "lab"];
return (
<section
className="ds-section ds-section-dark"
aria-labelledby="case-contact"
>
<div className="ds-section-shell">
<h2 id="case-contact" className="ds-card-title !mt-0 text-white">
{copy.title}
</h2>
<p className="ds-copy max-w-[60ch]">
Conte o que você precisa. A conversa começa pelo WhatsApp, sem
formulário.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service={similarProjectSubject(caseStudy.title)}
placement="case_detail"
className={primaryButton}
>
{buttonLabels.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases
</HoverPrefetchLink>
</div>
<CaseRelatedServices caseId={caseStudy.id} />
</div>
</section>
);
}
@@ -1,99 +0,0 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster),
* os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`)
* e o pôster como elemento compartilhado da troca de página.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
const props = {
id: "sev-modaintima",
title: "SEV Moda Íntima",
demoUrl: "https://sevmodaintima.com.br",
};
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("CaseCover", () => {
it("path: capture found -> the poster is the shared element of the page change (case-<id>, only the pair animates)", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
const transition = image.parentElement;
expect(transition).toHaveAttribute("data-vt-name", `case-${props.id}`);
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
expect(transition).toHaveAttribute("data-vt-default", "none");
});
it("coverTransitionProps: every case gets its own name, valid as a CSS identifier", () => {
const names = caseStudies.map(({ id }) => coverTransitionProps(id).name);
for (const name of names) expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
expect(new Set(names).size).toBe(names.length);
});
it("path: no desktop capture -> renders nothing", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
const { container } = render(<CaseCover {...props} />);
expect(container).toBeEmptyDOMElement();
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
});
it("path: capture found -> poster inside the browser frame, with the real domain in the bar", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(container.querySelector(".ds-bframe")).toContainElement(image);
expect(image.getAttribute("src")).toContain(
encodeURIComponent(`/videos/${props.id}/desktop.webp`),
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
);
});
it("path: sizes and priority omitted -> default sizes and lazy loading", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", coverSizes.card);
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
});
it("path: sizes and priority given -> uses them, with eager loading and high fetch priority", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} sizes="320px" priority />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high");
});
it("path: no 100vw sizes -> every preset describes the width the cover really has", () => {
// 100vw baixava a imagem de 750 px para uma capa de 300 px no celular
for (const sizes of Object.values(coverSizes)) {
expect(sizes).not.toMatch(/(^|, )100vw$/);
expect(sizes).toMatch(/calc\(100vw - \d+px\)$/);
}
});
});
-72
View File
@@ -1,72 +0,0 @@
import Image from "next/image";
import { ViewTransition } from "react";
import {
BrowserFrame,
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
* (com 100vw, um celular de 390 px pedia a versão de 750 px para uma capa de 300 px). As contas seguem o
* padding das seções (20 px até 768 px; depois 6vw, no máximo 64 px), a coluna central de 1240 px e os cartões
* (borda e 20 a 28 px de padding).
*/
export const coverSizes = {
/** Topo do case e do serviço: coluna da direita a partir de 1024 px, e a largura da página abaixo disso. */
hero: "(min-width: 1440px) 560px, (min-width: 1024px) 40vw, (min-width: 769px) 88vw, calc(100vw - 40px)",
/** Capa dentro de um cartão (`ds-card`) da grade de duas colunas, que vira uma só até 1024 px. */
card: "(min-width: 1440px) 560px, (min-width: 1025px) calc(50vw - 134px), (min-width: 769px) calc(88vw - 56px), calc(100vw - 88px)",
/** Capa solta numa grade de duas colunas, sem cartão em volta (a partir de 768 px). */
column:
"(min-width: 1440px) 604px, (min-width: 1068px) calc(50vw - 80px), (min-width: 768px) calc(44vw - 16px), calc(100vw - 40px)",
} as const;
/**
* Props da imagem que abre a página (a maior acima da dobra): carrega na hora e com prioridade de rede alta.
* No Next 16 a prop `priority` do `Image` só gera o `<link rel="preload">`; a prioridade de rede vem do `fetchPriority`.
*/
export const priorityImageProps = {
loading: "eager",
fetchPriority: "high",
} as const;
/**
* Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada.
* `priority` é para a imagem que abre a página: aplica `priorityImageProps`.
*/
export function CaseCover({
id,
title,
demoUrl,
sizes = coverSizes.card,
priority = false,
}: {
id: string;
title: string;
demoUrl?: string | null;
sizes?: string;
priority?: boolean;
}) {
const media = getCaseMedia(id, "desktop");
if (!media) return null;
const priorityProps = priority ? priorityImageProps : {};
return (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<ViewTransition {...coverTransitionProps(id)}>
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
{...priorityProps}
className="object-cover object-top"
/>
</ViewTransition>
</div>
</BrowserFrame>
);
}
@@ -1,106 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { coverSizes } from "./CaseCover";
import { CaseCoverLink } from "./CaseCoverLink";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseCoverLink: sem captura (nada), com captura (link fora da leitura e do teclado,
* com a capa dentro), os valores padrão de `sizes` e `priority`, o pré-carregamento só ao chegar no link
* e os valores dados.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
const props = {
id: "sev-modaintima",
title: "SEV Moda Íntima",
demoUrl: "https://sevmodaintima.com.br",
};
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("CaseCoverLink", () => {
it("path: no desktop capture -> renders nothing, not even the link", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
const { container } = render(<CaseCoverLink {...props} />);
expect(container).toBeEmptyDOMElement();
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
});
it("path: capture found -> a link to the case, out of reading order and tab order, around the cover", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<CaseCoverLink {...props} />);
const link = container.querySelector("a");
expect(link).toHaveAttribute("href", `/cases/${props.id}`);
expect(link).toHaveAttribute("aria-hidden", "true");
expect(link).toHaveAttribute("tabindex", "-1");
expect(link?.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
);
expect(
screen.getByAltText(`Captura de tela do site ${props.title}`),
).toBeInTheDocument();
});
it("path: sizes and priority omitted -> default sizes and lazy image", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCoverLink {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", coverSizes.card);
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
});
it("path: link -> pre-fetches the case only when the pointer or focus reaches it", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<CaseCoverLink {...props} />);
const link = container.querySelector("a");
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link as HTMLAnchorElement);
expect(link).toHaveAttribute("data-prefetch", "null");
});
it("path: sizes and priority given -> both are used", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCoverLink {...props} sizes="320px" priority />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high");
});
it("path: no demo address -> the browser bar falls back to the project title", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(
<CaseCoverLink id={props.id} title={props.title} demoUrl={null} />,
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
props.title,
);
});
});
-41
View File
@@ -1,41 +0,0 @@
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
/**
* Capa do case dentro de um cartão: a imagem leva ao case, mas fica fora da leitura e da ordem de tabulação,
* porque o link de texto do mesmo cartão já leva ao mesmo lugar. Sem captura gravada, não mostra nada.
* A rota do case só é pré-carregada quando a pessoa chega no link (HoverPrefetchLink).
*/
export function CaseCoverLink({
id,
title,
demoUrl,
sizes = coverSizes.card,
priority = false,
}: {
id: string;
title: string;
demoUrl?: string | null;
sizes?: string;
priority?: boolean;
}) {
if (!getCaseMedia(id, "desktop")) return null;
return (
<HoverPrefetchLink
href={`/cases/${id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={id}
title={title}
demoUrl={demoUrl}
sizes={sizes}
priority={priority}
/>
</HoverPrefetchLink>
);
}
@@ -1,152 +0,0 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseHero } from "./CaseHero";
import { getCaseMedia } from "./case-media";
import { compactHero } from "./hero-styles";
/**
* Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de
* laboratório, e a lista de fatos ausente, vazia ou preenchida. A existência da captura é simulada em getCaseMedia.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
const client = getCase("sev-modaintima");
const lab = getCase("twins-lab");
function withCapture(has: boolean) {
mockedGetCaseMedia.mockImplementation((id, device) =>
has ? getShowcaseAssets(id, device) : undefined,
);
}
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("CaseHero: captura gravada e tipo de case", () => {
it("path: client case with a capture -> two columns, the cover on the right, the client eyebrow", () => {
withCapture(true);
const { container } = render(<CaseHero caseStudy={client} isClient />);
expect(container.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(container.querySelector(".ds-bframe")).toBeInTheDocument();
expect(screen.getByText(/Case de cliente/)).toBeVisible();
expect(
screen.getByRole("heading", { level: 1, name: client.headline }),
).toBeVisible();
});
it("path: client case without a capture -> one column and no cover", () => {
withCapture(false);
const { container } = render(<CaseHero caseStudy={client} isClient />);
expect(container.querySelector(".ds-section-shell")).not.toHaveClass(
"grid",
);
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(screen.getByText(client.summary)).toBeVisible();
});
it("path: laboratory case with a capture -> the cover shows, with the laboratory eyebrow", () => {
withCapture(true);
const { container } = render(<CaseHero caseStudy={lab} isClient={false} />);
expect(container.querySelector(".ds-bframe")).toBeInTheDocument();
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.queryByText(/Case de cliente/)).toBeNull();
});
it("path: laboratory case without a capture -> one column, laboratory eyebrow, title and summary", () => {
withCapture(false);
const { container } = render(<CaseHero caseStudy={lab} isClient={false} />);
expect(container.querySelector(".ds-section-shell")).not.toHaveClass(
"grid",
);
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.getByText(lab.summary)).toBeVisible();
});
it("path: with a capture -> the top strip keeps the default height", () => {
withCapture(true);
const { container } = render(<CaseHero caseStudy={client} isClient />);
expect(container.querySelector("section")).not.toHaveClass(compactHero);
});
it("path: without a capture -> the top strip is the lower one-column strip, for client and laboratory cases", () => {
withCapture(false);
for (const [caseStudy, isClient] of [
[client, true],
[lab, false],
] as const) {
const { container, unmount } = render(
<CaseHero caseStudy={caseStudy} isClient={isClient} />,
);
expect(container.querySelector("section")).toHaveClass(compactHero);
unmount();
}
});
it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => {
withCapture(false);
render(<CaseHero caseStudy={client} isClient />);
const links = screen.getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/",
"/cases",
`/cases/${client.id}`,
]);
expect(links[2]).toHaveAttribute("aria-current", "page");
});
});
describe("CaseHero: fatos do projeto", () => {
const withFacts = (facts: string[] | undefined) => ({
...client,
facts,
});
it("path: no facts field -> no list", () => {
withCapture(false);
render(<CaseHero caseStudy={withFacts(undefined)} isClient />);
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
});
it("path: empty facts list -> no list", () => {
withCapture(false);
render(<CaseHero caseStudy={withFacts([])} isClient />);
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
});
it("path: filled facts -> one item each, after the summary", () => {
withCapture(false);
render(
<CaseHero
caseStudy={withFacts(["No ar desde 2025", "Dois painéis"])}
isClient
/>,
);
const list = screen.getByRole("list", { name: "Fatos do projeto" });
expect(list.querySelectorAll("li")).toHaveLength(2);
expect(
screen.getByText(client.summary).compareDocumentPosition(list) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
-91
View File
@@ -1,91 +0,0 @@
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
import type { CaseStudyContent } from "@/lib/site-content";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { compactHero } from "./hero-styles";
/** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */
function CaseFacts({ facts }: { facts?: string[] }) {
if (!facts?.length) return null;
return (
<ul
className="mt-sp-5 flex flex-wrap gap-x-sp-6 gap-y-sp-2 text-[15px] leading-6 text-white/80"
aria-label="Fatos do projeto"
>
{facts.map((fact) => (
<li key={fact}>{fact}</li>
))}
</ul>
);
}
/** Pôster do site na coluna da direita (no celular, logo abaixo do título). */
function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
return (
<div className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes={coverSizes.hero}
priority
/>
</div>
);
}
/**
* Topo do case: trilha, título, resumo e fatos. Com captura gravada, o texto fica à esquerda e o pôster à direita;
* no celular a mídia vem logo abaixo do título. Sem captura, o topo é uma coluna só e mais baixo.
*/
export function CaseHero({
caseStudy,
isClient,
}: {
caseStudy: CaseStudyContent;
isClient: boolean;
}) {
const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop"));
return (
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasMedia ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${
hasMedia
? "grid items-center gap-y-sp-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16 lg:gap-y-0"
: ""
}`}
>
<div className="max-w-[860px] lg:col-start-1 lg:row-start-1 lg:self-end">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
]}
/>
{/* o tipo do projeto, sem o nome: a trilha logo acima já o diz */}
<p className="ds-eyebrow">
{isClient ? "Case de cliente" : "Projeto de laboratório"}
</p>
<h1 className="ds-section-title text-balance text-white">
{caseStudy.headline}
</h1>
</div>
{hasMedia ? <CaseHeroCover caseStudy={caseStudy} /> : null}
<div className="max-w-[68ch] lg:col-start-1 lg:row-start-2 lg:self-start">
<p className="ds-copy !text-[rgba(255,255,255,.78)]">
{caseStudy.summary}
</p>
<CaseFacts facts={caseStudy.facts} />
</div>
</div>
</section>
);
}
@@ -1,133 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { CaseMedia } from "./CaseMedia";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseMedia: com computador e celular, só um dos dois, ou nenhum.
* Cada quadro mostra o pôster com um botão de play; o vídeo só aparece depois do clique.
* A existência dos arquivos em disco é simulada em getCaseMedia, para o teste não depender de public/.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
function withCaptures(devices: ReadonlyArray<"desktop" | "mobile">) {
mockedGetCaseMedia.mockImplementation((id, device) =>
devices.includes(device) ? getShowcaseAssets(id, device) : undefined,
);
}
const props = {
id: "sev-modaintima",
title: "SEV Moda Íntima",
demoUrl: "https://sevmodaintima.com.br",
};
const desktopPlay = `Reproduzir o vídeo do site ${props.title} no computador`;
const mobilePlay = `Reproduzir o vídeo do site ${props.title} no celular`;
const desktopVideo = `Vídeo do site ${props.title} no computador`;
const mobileVideo = `Vídeo do site ${props.title} no celular`;
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("CaseMedia", () => {
it("path: desktop and mobile captures -> both frames, the browser bar shows the real domain", () => {
withCaptures(["desktop", "mobile"]);
const { container } = render(<CaseMedia {...props} />);
expect(container.querySelector(".ds-bframe")).toContainElement(
screen.getByRole("button", { name: desktopPlay }),
);
expect(container.querySelector(".ds-phone")).toContainElement(
screen.getByRole("button", { name: mobilePlay }),
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
);
expect(
screen.getByText(`O site ${props.title} no computador.`),
).toBeVisible();
expect(screen.getByText("No celular.")).toBeVisible();
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "mobile");
});
it("path: no capture at all -> renders nothing", () => {
withCaptures([]);
const { container } = render(<CaseMedia {...props} />);
expect(container).toBeEmptyDOMElement();
});
it("path: only the mobile capture -> phone frame without the browser frame", () => {
withCaptures(["mobile"]);
const { container } = render(<CaseMedia {...props} />);
expect(
screen.getByRole("button", { name: mobilePlay }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: desktopPlay })).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector(".ds-phone")).not.toBeNull();
expect(screen.queryByText(/no computador\./)).toBeNull();
});
it("path: only the desktop capture -> browser frame without the phone frame", () => {
withCaptures(["desktop"]);
const { container } = render(<CaseMedia {...props} />);
expect(
screen.getByRole("button", { name: desktopPlay }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: mobilePlay })).toBeNull();
expect(container.querySelector(".ds-phone")).toBeNull();
expect(container.querySelector(".ds-bframe")).not.toBeNull();
expect(screen.queryByText("No celular.")).toBeNull();
});
it("path: both captures -> no video until play, then only the pressed frame plays", () => {
withCaptures(["desktop", "mobile"]);
const { container } = render(<CaseMedia {...props} />);
expect(container.querySelector("video")).toBeNull();
// o pôster do computador vem com texto alternativo vazio: o botão já descreve o quadro
expect(container.querySelector(".ds-bframe img")).toHaveAttribute(
"alt",
"",
);
fireEvent.click(screen.getByRole("button", { name: mobilePlay }));
const phone = screen.getByLabelText(mobileVideo);
expect(container.querySelector(".ds-phone")).toContainElement(phone);
expect(phone).toHaveAttribute("poster", `/videos/${props.id}/mobile.webp`);
expect(phone.querySelectorAll("source")).toHaveLength(2);
expect(screen.queryByLabelText(desktopVideo)).toBeNull();
expect(
screen.getByRole("button", { name: desktopPlay }),
).toBeInTheDocument();
expect(container.querySelectorAll("video")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: desktopPlay }));
expect(screen.getByLabelText(desktopVideo)).toHaveAttribute(
"poster",
`/videos/${props.id}/desktop.webp`,
);
expect(container.querySelectorAll("video")).toHaveLength(2);
expect(screen.queryByRole("button")).toBeNull();
});
it("path: no demo address -> the browser bar falls back to the project title", () => {
withCaptures(["desktop"]);
const { container } = render(
<CaseMedia id={props.id} title={props.title} demoUrl={null} />,
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
props.title,
);
});
});
-53
View File
@@ -1,53 +0,0 @@
import {
BrowserFrame,
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { PhoneFrame } from "@/app/components/showcase/PhoneFrame";
import { CasePlayer } from "./CasePlayer";
import { getCaseMedia } from "./case-media";
/**
* Pôster e vídeo do projeto: o site no computador (moldura de navegador) e no celular (moldura de celular).
* Cada quadro mostra o pôster com um botão de play; o vídeo só baixa quando a pessoa aperta o botão.
* Sem captura gravada, não mostra nada.
*/
export function CaseMedia({
id,
title,
demoUrl,
}: {
id: string;
title: string;
demoUrl?: string | null;
}) {
const desktop = getCaseMedia(id, "desktop");
const mobile = getCaseMedia(id, "mobile");
if (!desktop && !mobile) return null;
return (
<div className="mt-sp-8 grid max-w-[1040px] items-end gap-sp-8 md:grid-cols-[minmax(0,1fr)_auto]">
{desktop ? (
<figure className="min-w-0">
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<CasePlayer assets={desktop} title={title} device="desktop" />
</div>
</BrowserFrame>
<figcaption className="mt-sp-3 font-[family-name:var(--font-body)] text-[15px] text-white/70">
O site {title} no computador.
</figcaption>
</figure>
) : null}
{mobile ? (
<figure className="mx-auto w-[min(62vw,220px)] md:mx-0">
<PhoneFrame poster={mobile.poster}>
<CasePlayer assets={mobile} title={title} device="mobile" />
</PhoneFrame>
<figcaption className="mt-sp-3 text-center font-[family-name:var(--font-body)] text-[15px] text-white/70">
No celular.
</figcaption>
</figure>
) : null}
</div>
);
}
@@ -1,56 +0,0 @@
import { secondaryButtonDark } from "@/app/components/button-styles";
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
import { CaseMedia } from "./CaseMedia";
import { getCaseMedia } from "./case-media";
/** O bloco "Como ficou" existe quando há site no ar ou captura gravada, no computador ou no celular. */
export function hasCaseMedia({
id,
demoUrl,
}: Pick<CaseStudyContent, "id" | "demoUrl">) {
return (
Boolean(demoUrl) ||
Boolean(getCaseMedia(id, "desktop")) ||
Boolean(getCaseMedia(id, "mobile"))
);
}
/** "Como ficou": pôster e vídeo do projeto e o botão para abrir o site. Sem site nem captura, não mostra nada. */
export function CaseMediaSection({
caseStudy,
}: {
caseStudy: Pick<CaseStudyContent, "id" | "title" | "demoUrl">;
}) {
if (!hasCaseMedia(caseStudy)) return null;
return (
<section
className="ds-section ds-section-dark"
aria-labelledby="case-media"
>
<div className="ds-section-shell">
<h2 id="case-media" className="ds-card-title !mt-0 text-white">
Como ficou
</h2>
<CaseMedia
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
/>
{caseStudy.demoUrl ? (
<div className="mt-sp-8">
<a
className={secondaryButtonDark}
href={caseStudy.demoUrl}
target="_blank"
rel="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
</div>
) : null}
</div>
</section>
);
}
-94
View File
@@ -1,94 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { CasePlayer } from "./CasePlayer";
const title = "SEV Moda Íntima";
describe("CasePlayer", () => {
it("shows the poster and an accessible play button instead of a paused video", () => {
const { container } = render(
<div className="relative">
<CasePlayer
assets={getShowcaseAssets("sev-modaintima", "desktop")}
title={title}
device="desktop"
/>
</div>,
);
expect(container.querySelector("video")).toBeNull();
// a imagem é decorativa: o botão já diz o que o quadro mostra
const poster = container.querySelector("img");
expect(poster).toHaveAttribute("alt", "");
expect(poster?.getAttribute("src")).toContain(
encodeURIComponent("/videos/sev-modaintima/desktop.webp"),
);
const button = screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${title} no computador`,
});
expect(button).toHaveAttribute("type", "button");
// em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior
const circle = button.querySelector("[aria-hidden='true']");
const restClasses = (circle?.className ?? "")
.split(" ")
.filter((name) => !name.startsWith("group-"));
expect(restClasses.join(" ")).not.toContain("--slash");
expect(circle).toHaveClass(
"group-hover:scale-108",
"group-hover:bg-[var(--slash)]",
"group-focus-visible:scale-108",
"group-focus-visible:bg-[var(--slash)]",
);
});
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
const assets = getShowcaseAssets("lavacao-047", "desktop");
render(
<div className="relative">
<CasePlayer assets={assets} title="Lavação 047" device="desktop" />
</div>,
);
fireEvent.click(screen.getByRole("button"));
const video = screen.getByLabelText(
"Vídeo do site Lavação 047 no computador",
);
expect(video).toHaveFocus();
expect(video).toHaveAttribute("controls");
expect(video).toHaveAttribute("poster", assets.poster);
expect(video).toHaveAttribute("width", String(assets.width));
expect(video).toHaveAttribute("height", String(assets.height));
expect((video as HTMLVideoElement).muted).toBe(true);
expect((video as HTMLVideoElement).loop).toBe(true);
expect(
[...video.querySelectorAll("source")].map((source) => [
source.getAttribute("src"),
source.getAttribute("type"),
]),
).toEqual(assets.sources.map(({ src, type }) => [src, type]));
expect(screen.queryByRole("button")).toBeNull();
});
it("leaves the poster to the phone frame on mobile and names the device in the labels", () => {
const { container } = render(
<div className="relative">
<CasePlayer
assets={getShowcaseAssets("sc-plus", "mobile")}
title="SC Plus Aquicultura"
device="mobile"
/>
</div>,
);
expect(container.querySelector("img")).toBeNull();
fireEvent.click(
screen.getByRole("button", {
name: "Reproduzir o vídeo do site SC Plus Aquicultura no celular",
}),
);
expect(
screen.getByLabelText("Vídeo do site SC Plus Aquicultura no celular"),
).toHaveAttribute("poster", "/videos/sc-plus/mobile.webp");
});
});
-91
View File
@@ -1,91 +0,0 @@
"use client";
import { Play } from "lucide-react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { Device, ShowcaseAssets } from "@/lib/project-showcase";
const deviceLabel: Record<Device, string> = {
desktop: "no computador",
mobile: "no celular",
};
/**
* Pôster com um botão de play. O vídeo só é montado, e só baixa, quando a pessoa aperta o botão:
* antes disso a página mostra a imagem do site, não um vídeo parado em 0:00 com controles do navegador.
* Vai dentro de um quadro com `position: relative` (moldura de navegador ou de celular) que dá o tamanho.
* `sizes` segue a grade de CaseMedia: a coluna da esquerda de um bloco de até 1040 px (88 vw da página abaixo de 1182 px)
* com a moldura do celular ao lado (220 px mais 32 px de vão); abaixo de 768 px, a largura da página menos 40 px.
*/
export function CasePlayer({
assets,
title,
device,
sizes = "(min-width: 1182px) 788px, (min-width: 768px) calc(88vw - 252px), calc(100vw - 40px)",
}: {
assets: ShowcaseAssets;
title: string;
device: Device;
sizes?: string;
}) {
const [playing, setPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// o botão sai de cena ao tocar: o foco vai para o vídeo, para quem usa o teclado não o perder
useEffect(() => {
if (playing) videoRef.current?.focus();
}, [playing]);
if (playing) {
return (
<video
ref={videoRef}
className="absolute inset-0 h-full w-full object-cover object-top"
autoPlay
controls
muted
loop
playsInline
poster={assets.poster}
width={assets.width}
height={assets.height}
tabIndex={0}
aria-label={`Vídeo do site ${title} ${deviceLabel[device]}`}
disablePictureInPicture
>
{assets.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
}
return (
<>
{/* o quadro do celular já mostra o pôster como fundo; no computador a imagem entra aqui */}
{device === "desktop" ? (
<Image
src={assets.poster}
alt=""
fill
sizes={sizes}
className="object-cover object-top"
/>
) : null}
<button
type="button"
onClick={() => setPlaying(true)}
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
>
{/* sob o ponteiro ou no foco o círculo cresce e fica verde (só o `scale` anima; a cor troca na hora) */}
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-[scale] duration-[280ms] ease-[var(--ease-out)] group-hover:scale-108 group-hover:border-[var(--slash)] group-hover:bg-[var(--slash)] group-hover:text-[var(--ink)] group-focus-visible:scale-108 group-focus-visible:border-[var(--slash)] group-focus-visible:bg-[var(--slash)] group-focus-visible:text-[var(--ink)] motion-reduce:transition-none sm:size-16"
>
<Play className="ml-1 size-6" fill="currentColor" />
</span>
</button>
</>
);
}
@@ -1,34 +0,0 @@
import { textLinkDark } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { getRelatedServices } from "@/lib/site-content";
/** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */
export const MAX_RELATED_SERVICES = 2;
/** Cards dos serviços que citam este case (no máximo dois), sobre fundo escuro. Sem serviço relacionado, não mostra nada. */
export function CaseRelatedServices({ caseId }: { caseId: string }) {
const services = getRelatedServices(caseId).slice(0, MAX_RELATED_SERVICES);
if (services.length === 0) return null;
return (
<div className="mt-sp-10">
<p className="ds-kicker">Serviços ligados a este projeto</p>
<ul className="mt-sp-4 grid gap-sp-4 md:grid-cols-2">
{services.map((service) => (
<li key={service.slug} className="flex">
<article className="ds-card w-full">
<h3 className="ds-card-title !mt-0">{service.navLabel}</h3>
<p className="ds-copy">{service.intro}</p>
<HoverPrefetchLink
className={`${textLinkDark} mt-sp-4 self-start`}
href={`/servicos/${service.slug}`}
>
Ver o serviço: {service.navLabel}
</HoverPrefetchLink>
</article>
</li>
))}
</ul>
</div>
);
}
@@ -1,19 +0,0 @@
/** Resultado do case, só com dado real e autorizado. Sem dado, a seção não aparece. */
export function CaseResultSection({ result }: { result?: string[] }) {
if (!result || result.length === 0) return null;
return (
<section className="ds-section" aria-labelledby="case-result">
<div className="ds-section-shell">
<h2 id="case-result" className="ds-card-title !mt-0">
Resultado
</h2>
<ul className="ds-list max-w-[78ch] space-y-sp-3 pl-[1.15rem]">
{result.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</section>
);
}
-108
View File
@@ -1,108 +0,0 @@
import { render } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { getMosaicPosters, MOSAIC_SIZE, PosterMosaic } from "./PosterMosaic";
/**
* Mosaico de pôsteres do topo de /servicos e /cases: quais pôsteres entram (só os com captura, no máximo três),
* e como cada imagem carrega (a primeira, a maior, na hora e com prioridade alta; as outras só quando aparecem).
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("getMosaicPosters", () => {
it("path: every id has a capture -> keeps the order and stops at three", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const posters = getMosaicPosters(["a", "b", "c", "d"]);
expect(MOSAIC_SIZE).toBe(3);
expect(posters).toEqual([
{ id: "a", poster: "/videos/a/desktop.webp" },
{ id: "b", poster: "/videos/b/desktop.webp" },
{ id: "c", poster: "/videos/c/desktop.webp" },
]);
});
it("path: some ids have no capture -> skips them and fills with the next ones", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
id === "b" ? undefined : getShowcaseAssets(id, device),
);
expect(getMosaicPosters(["a", "b", "c", "d"]).map(({ id }) => id)).toEqual([
"a",
"c",
"d",
]);
expect(mockedGetCaseMedia).toHaveBeenCalledWith("b", "desktop");
});
it("path: no id has a capture -> an empty list", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
expect(getMosaicPosters(["a", "b"])).toEqual([]);
});
});
describe("PosterMosaic", () => {
const three = ["a", "b", "c"].map((id) => ({
id,
poster: `/videos/${id}/desktop.webp`,
}));
it("path: no posters -> renders nothing", () => {
const { container } = render(<PosterMosaic posters={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("path: three posters -> decorative, the first one eager with high priority and the real column width", () => {
const { container } = render(<PosterMosaic posters={three} />);
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
const images = Array.from(container.querySelectorAll("img"));
expect(images).toHaveLength(3);
for (const image of images) {
expect(image).toHaveAttribute("alt", "");
}
const [main, ...side] = images;
expect(main).toHaveAttribute("loading", "eager");
expect(main).toHaveAttribute("fetchpriority", "high");
expect(main).toHaveAttribute("sizes", coverSizes.hero);
expect(main.closest("div")).toHaveClass("col-span-2");
for (const image of side) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
// metade da coluna, e nunca a largura toda da tela
expect(image.getAttribute("sizes")).toBe(
"(min-width: 1440px) 274px, 20vw",
);
}
});
it("path: the smaller posters are hidden below 1024 px, so they are not downloaded on a phone", () => {
const { container } = render(<PosterMosaic posters={three} />);
const [main, ...side] = Array.from(container.querySelectorAll("img")).map(
(image) => image.closest("div"),
);
expect(main).not.toHaveClass("hidden");
for (const frame of side) {
expect(frame).toHaveClass("hidden", "lg:block");
}
});
it("path: a single poster -> only the main one, with no side posters", () => {
const { container } = render(<PosterMosaic posters={three.slice(0, 1)} />);
expect(container.querySelectorAll("img")).toHaveLength(1);
});
});
-68
View File
@@ -1,68 +0,0 @@
import Image from "next/image";
import { coverSizes, priorityImageProps } from "./CaseCover";
import { getCaseMedia } from "./case-media";
export interface MosaicPoster {
id: string;
poster: string;
}
/** Quantos pôsteres entram no mosaico: um grande em cima e dois menores embaixo. */
export const MOSAIC_SIZE = 3;
/**
* Pôsteres gravados dos cases, na ordem dada, só dos que têm captura no disco (no máximo `MOSAIC_SIZE`).
* Roda no servidor, como `getCaseMedia`.
*/
export function getMosaicPosters(ids: readonly string[]): MosaicPoster[] {
return ids
.flatMap((id) => {
const media = getCaseMedia(id, "desktop");
return media ? [{ id, poster: media.poster }] : [];
})
.slice(0, MOSAIC_SIZE);
}
const frame =
"relative aspect-[16/10] overflow-hidden border border-white/15 bg-[#0a0a0a]";
/**
* Mosaico de pôsteres dos sites no topo das páginas internas, na coluna que ficaria vazia ao lado do título.
* É decorativo (texto alternativo vazio): quem lê os cases tem os links logo abaixo. O primeiro pôster é o maior
* e o que abre a página, então carrega na hora e com prioridade alta; no celular só ele aparece, e os outros dois
* (que não são baixados enquanto estão ocultos) entram a partir de 1024 px.
*/
export function PosterMosaic({
posters,
}: {
posters: readonly MosaicPoster[];
}) {
const [main, ...others] = posters;
if (!main) return null;
return (
<div className="grid min-w-0 grid-cols-2 gap-sp-3" aria-hidden="true">
<div className={`${frame} col-span-2`}>
<Image
src={main.poster}
alt=""
fill
sizes={coverSizes.hero}
{...priorityImageProps}
className="object-cover object-top"
/>
</div>
{others.map(({ id, poster }) => (
<div key={id} className={`${frame} hidden lg:block`}>
<Image
src={poster}
alt=""
fill
sizes="(min-width: 1440px) 274px, 20vw"
className="object-cover object-top"
/>
</div>
))}
</div>
);
}
@@ -1,41 +0,0 @@
import { existsSync } from "node:fs";
import { getCaseMedia } from "./case-media";
/** Testes de caminhos-base de getCaseMedia: id inválido, pôster ausente e pôster presente em disco. */
jest.mock("node:fs", () => {
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
return { ...actual, existsSync: jest.fn(actual.existsSync) };
});
const mockedExistsSync = jest.mocked(existsSync);
beforeEach(() => {
mockedExistsSync.mockClear();
});
describe("getCaseMedia", () => {
it.each([
["with path traversal", "../../etc/passwd"],
["with upper case", "Sev-Modaintima"],
["with a slash", "sev/modaintima"],
["empty", ""],
])("path: id %s -> undefined without touching the disk", (_label, id) => {
expect(getCaseMedia(id, "desktop")).toBeUndefined();
expect(mockedExistsSync).not.toHaveBeenCalled();
});
it("path: valid id with no poster on disk -> undefined", () => {
expect(getCaseMedia("twins-lab", "desktop")).toBeUndefined();
expect(mockedExistsSync).toHaveBeenCalledTimes(1);
});
it("path: valid id with the poster on disk -> the showcase assets of that device", () => {
const desktop = getCaseMedia("sev-modaintima", "desktop");
const mobile = getCaseMedia("sev-modaintima", "mobile");
expect(desktop?.poster).toBe("/videos/sev-modaintima/desktop.webp");
expect(mobile?.poster).toBe("/videos/sev-modaintima/mobile.webp");
expect(desktop?.sources.length).toBeGreaterThan(0);
});
});
-24
View File
@@ -1,24 +0,0 @@
import "server-only";
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
type Device,
getShowcaseAssets,
type ShowcaseAssets,
} from "@/lib/project-showcase";
const idPattern = /^[a-z0-9-]+$/;
/**
* Pôster e vídeo gravados em public/videos/<id>/, ou `undefined` quando o projeto não tem captura.
* Roda no servidor (na geração das páginas): olha se o pôster existe no disco.
*/
export function getCaseMedia(
id: string,
device: Device,
): ShowcaseAssets | undefined {
if (!idPattern.test(id)) return undefined;
const assets = getShowcaseAssets(id, device);
const file = join(process.cwd(), "public", assets.poster);
return existsSync(file) ? assets : undefined;
}
-10
View File
@@ -1,10 +0,0 @@
/**
* A capa como elemento compartilhado da troca de página: com o mesmo nome na lista (home, /cases) e no topo do case,
* ela viaja de um lugar ao outro (classe case-cover em globals.css). Só a troca entre as duas anima
* (`default="none"`). Duas instâncias com o mesmo nome na mesma página cancelam a transição: cada página mostra
* a capa de um case uma vez só. Fica fora de CaseCover porque a lista da home é componente de cliente, e
* CaseCover lê as capturas só no servidor.
*/
export function coverTransitionProps(id: string) {
return { name: `case-${id}`, share: "case-cover", default: "none" } as const;
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Faixa de topo de uma página interna que não tem imagem ao lado: uma coluna só e mais baixa que a padrão.
* O "!" vence o CSS sem camada de globals.css (.ds-section: 72 a 112 px de folga em cima e embaixo).
* Em cima fica a altura do cabeçalho fixo mais um respiro, para a trilha não ficar por baixo dele; embaixo, 48 px.
* No celular vale o padding de `pageTop` (button-styles.ts) e o padrão de baixo.
*/
export const compactHero =
"md:!pt-[calc(var(--site-header-height)+var(--sp-10))] md:!pb-sp-12";
/**
* Topo com imagem: texto à esquerda e a mídia à direita a partir de 1024 px (1,1 parte para 1), uma coluna abaixo disso.
* Os `sizes` das imagens (CaseCover e PosterMosaic) seguem esta grade.
*/
export const heroWithMediaGrid =
"grid items-center gap-y-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16";
-18
View File
@@ -1,18 +0,0 @@
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
export const alt =
"Sites e sistemas feitos para empresas — Evandro Fonseca Junior";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default async function Image() {
return new ImageResponse(
<OgCard
eyebrow="Trabalhos entregues"
title="Sites e sistemas feitos para empresas"
description="Veja como cada projeto ficou e converse sobre o seu."
/>,
OG_SIZE,
);
}
-77
View File
@@ -1,77 +0,0 @@
import { render } from "@testing-library/react";
import type { ComponentProps } from "react";
import { clientCasesForListing as clientCases } from "@/lib/site-content";
import CasesPage from "./page";
/**
* Carregamento da lista de cases: sem mosaico no topo, a capa do primeiro cartão (a maior imagem acima da dobra) carrega
* na hora e com prioridade alta, as outras capas são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos.
*/
type LinkProps = ComponentProps<typeof import("next/link").default>;
/** Propriedades de cada `Link` renderizado: o `prefetch` não aparece no HTML, então a rota é espiada aqui. */
const mockLinkProps: LinkProps[] = [];
jest.mock("next/link", () => ({
__esModule: true,
default: ({ children, href, prefetch, ...anchor }: LinkProps) => {
mockLinkProps.push({ children, href, prefetch, ...anchor });
return (
<a href={String(href)} {...(anchor as Record<string, unknown>)}>
{children}
</a>
);
},
}));
beforeEach(() => {
mockLinkProps.length = 0;
});
function coverImages(container: HTMLElement) {
return clientCases.map((caseStudy) => {
const image = container
.querySelector(`a[href="/cases/${caseStudy.id}"][aria-hidden="true"]`)
?.querySelector("img");
if (!image) throw new Error(`Missing cover of ${caseStudy.id}`);
return image;
});
}
describe("CasesPage: carregamento das capas", () => {
it("loads the first card cover at once with high priority, and every other image lazily", () => {
const { container } = render(<CasesPage />);
// sem mosaico: a única imagem decorativa (alt vazio) é a peça de blocos do topo, preguiçosa e sem prioridade
const decorative = Array.from(
container.querySelectorAll<HTMLImageElement>("img"),
).filter((image) => image.getAttribute("alt") === "");
expect(decorative).toHaveLength(1);
expect(decorative[0].getAttribute("src")).toContain("estado-tela.webp");
expect(decorative[0]).toHaveAttribute("loading", "lazy");
expect(decorative[0]).not.toHaveAttribute("fetchpriority");
const [first, ...others] = coverImages(container);
expect(first).toHaveAttribute("loading", "eager");
expect(first).toHaveAttribute("fetchpriority", "high");
expect(others.length).toBeGreaterThan(0);
for (const image of others) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
}
});
it("does not prefetch the case pages from the cover links, nor from the card buttons", () => {
render(<CasesPage />);
const toCases = mockLinkProps.filter(({ href }) =>
String(href).startsWith("/cases/"),
);
// uma capa e um botão por case de cliente, e um botão por projeto do laboratório
expect(toCases.length).toBeGreaterThanOrEqual(clientCases.length * 2);
for (const props of toCases) {
expect(props.prefetch).toBe(false);
}
});
});
-181
View File
@@ -1,181 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import {
buttonLabels,
clientCasesForListing,
labCases,
} from "@/lib/site-content";
import { getCaseMedia } from "./_components/case-media";
import CasesPage from "./page";
/**
* Teste de caminhos-base da lista de cases de cliente em CasesPage:
* o caminho "case de cliente sem captura gravada" (a página principal só vê cases com captura).
*/
jest.mock("./_components/case-media", () => {
const actual = jest.requireActual("./_components/case-media");
return { ...actual, getCaseMedia: jest.fn(actual.getCaseMedia) };
});
const actualGetCaseMedia = jest.requireActual<
typeof import("./_components/case-media")
>("./_components/case-media").getCaseMedia;
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
afterEach(() => {
mockedGetCaseMedia.mockImplementation(actualGetCaseMedia);
});
function clientCard(container: HTMLElement, id: string) {
const card = container
.querySelector(`a[href="/cases/${id}"]`)
?.closest("article");
if (!card) throw new Error(`Missing client card ${id}`);
return card;
}
describe("CasesPage: cases de cliente", () => {
it("path: client case without a desktop capture -> card keeps text and links but no cover", () => {
const [withoutCapture, withCapture] = clientCasesForListing;
mockedGetCaseMedia.mockImplementation((id, device) =>
id === withoutCapture.id ? undefined : actualGetCaseMedia(id, device),
);
const { container } = render(<CasesPage />);
const bare = clientCard(container, withoutCapture.id);
expect(bare.querySelector(".ds-bframe")).toBeNull();
expect(bare.querySelector("img")).toBeNull();
expect(bare.querySelector("a[aria-hidden='true']")).toBeNull();
expect(within(bare).getByText(withoutCapture.headline)).toBeVisible();
expect(
within(bare).getByRole("link", {
name: `${buttonLabels.case} ${withoutCapture.title}`,
}),
).toHaveAttribute("href", `/cases/${withoutCapture.id}`);
// os outros cases de cliente continuam com a capa
const covered = clientCard(container, withCapture.id);
expect(covered.querySelector(".ds-bframe img")).toHaveAttribute(
"alt",
`Captura de tela do site ${withCapture.title}`,
);
});
it("path: every case -> one 'Ver case' button, named after its project, and no 'Ver o case' or 'Ler case'", () => {
render(<CasesPage />);
const buttons = screen.getAllByRole("link", { name: /^Ver case / });
expect(buttons).toHaveLength(
clientCasesForListing.length + labCases.length,
);
for (const link of buttons) {
expect(link.textContent?.startsWith(buttonLabels.case)).toBe(true);
}
expect(
screen.queryByRole("link", { name: /Ver o case|Ler case|Ver o projeto/ }),
).toBeNull();
});
it("path: an odd number of client cases -> the last card takes the whole row on wide screens, so the grid ends with no empty cell", () => {
const { container } = render(<CasesPage />);
const wide = clientCasesForListing.map((caseStudy) =>
clientCard(container, caseStudy.id).className.includes(
"min-[1025px]:col-span-2",
),
);
const odd = clientCasesForListing.length % 2 === 1;
expect(wide).toEqual(
clientCasesForListing.map(
(_, index) => odd && index === clientCasesForListing.length - 1,
),
);
});
it("path: contact -> 'Chamar no WhatsApp', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => {
const { container } = render(<CasesPage />);
expect(
screen.getByRole("link", { name: buttonLabels.whatsapp }),
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
const clientTitles = Array.from(
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
).map((kicker) => kicker.textContent);
expect(clientTitles).toEqual(
clientCasesForListing.map(({ title }) => title),
);
expect(clientTitles).toEqual([
"SEV Moda Íntima",
"Lavação 047",
"SC Plus Aquicultura",
"AS Serviços",
"Julia Beauty",
]);
// um botão de contato só na página: o do fecho
expect(
screen.getAllByRole("link", { name: buttonLabels.whatsapp }),
).toHaveLength(1);
});
});
describe("CasesPage: topo sem mosaico", () => {
it("path: captures found -> no poster mosaic (the cards below already carry the covers), only the block piece beside the text, a lower strip, no 'Veja o que já' twice", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
const images = hero?.querySelectorAll("img") ?? [];
expect(images).toHaveLength(1);
expect(images[0].getAttribute("src")).toContain("estado-tela.webp");
// decorativa e só de 1024 px em diante
expect(images[0].closest('[aria-hidden="true"]')).toHaveClass(
"hidden",
"lg:block",
);
expect(hero?.className).toContain("md:!pb-sp-12");
// o título e o subtítulo não repetem a mesma frase
const heading = within(hero as HTMLElement).getByRole("heading", {
level: 1,
});
expect(heading).toHaveTextContent("Veja o que já fiz para empresas.");
expect(hero?.textContent?.match(/Veja o que já/g)).toHaveLength(1);
expect(hero?.textContent).not.toMatch(/\bdireto\b/);
});
it("path: no capture for the other clients -> still no mosaic, and the first card cover takes the priority", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
id === clientCasesForListing[0].id
? actualGetCaseMedia(id, device)
: undefined,
);
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelectorAll("img")).toHaveLength(1);
const firstCover = clientCard(
container,
clientCasesForListing[0].id,
).querySelector(".ds-bframe img");
expect(firstCover).toHaveAttribute("loading", "eager");
expect(firstCover).toHaveAttribute("fetchpriority", "high");
});
});
describe("CasesPage: prioridade da capa do primeiro cartão", () => {
it("path: no mosaic -> the first card cover is the page's biggest image, so it loads at once with high priority; the others stay lazy", () => {
const { container } = render(<CasesPage />);
const [first, ...others] = clientCasesForListing;
const firstCover = clientCard(container, first.id).querySelector(
".ds-bframe img",
);
expect(firstCover).toHaveAttribute("loading", "eager");
expect(firstCover).toHaveAttribute("fetchpriority", "high");
for (const { id } of others) {
const cover = clientCard(container, id).querySelector(".ds-bframe img");
expect(cover).toHaveAttribute("loading", "lazy");
expect(cover).not.toHaveAttribute("fetchpriority");
}
});
});
-241
View File
@@ -1,241 +0,0 @@
import type { Metadata } from "next";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
primaryButton,
secondaryButtonDark,
secondaryButtonLight,
textLink,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { PagePiece } from "@/app/components/PagePiece";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import {
buttonLabels,
type CaseStudyContent,
clientCasesForListing,
labCases,
siteUrl,
} from "@/lib/site-content";
import { CASES_SUBJECT } from "@/lib/whatsapp";
import { CaseCoverLink } from "./_components/CaseCoverLink";
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases: sites e sistemas para empresas";
const pageDescription =
"Cases de sites e sistemas para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
/** Abertura da página: o que o visitante encontra nos cases, sem repetir a frase do título. */
const pageLead =
"Abra um case para ver o problema do cliente, o que foi feito e como o site ficou, da loja virtual ao site que o dono edita sozinho.";
const allCases = [...clientCasesForListing, ...labCases];
export const metadata: Metadata = {
title: pageTitle,
description: pageDescription,
...buildPageSocialMetadata({
url: pageUrl,
title: pageTitle,
description: pageDescription,
type: "website",
}),
};
/**
* Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case.
* `wide`: o cartão que sobraria sozinho na última linha ocupa a linha inteira, capa à esquerda e texto à direita.
*/
function ClientCaseCard({
caseStudy,
priority = false,
wide = false,
}: {
caseStudy: CaseStudyContent;
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
priority?: boolean;
wide?: boolean;
}) {
return (
<article
className={`ds-card !h-auto ${
// a partir de 1025 px, onde .ds-grid-2 (globals.css) passa a duas colunas; o "!" vence o display:flex de .ds-card,
// que fica fora das camadas do Tailwind
wide
? "min-[1025px]:col-span-2 min-[1025px]:!grid min-[1025px]:grid-cols-2 min-[1025px]:items-center min-[1025px]:gap-x-sp-8 min-[1025px]:[&>:first-child]:mb-0"
: ""
}`}
>
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
/>
<div>
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
) : null}
</div>
</div>
</article>
);
}
/**
* Uma linha por projeto do laboratório: nome, o que é, a marca "Projeto próprio" e o link. Sem capa nem resumo, para a lista
* ocupar pouco espaço (no celular, o nome e a marca ficam na primeira linha).
*/
function LabCaseRow({ caseStudy }: { caseStudy: CaseStudyContent }) {
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-sp-6 gap-y-sp-1 py-sp-4 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto_auto]">
<h3 className="text-base font-semibold leading-6">{caseStudy.title}</h3>
<p className="ds-kicker justify-self-end md:order-3">Projeto próprio</p>
<p className="col-span-2 text-[15px] leading-6 text-[var(--ink-80)] md:order-2 md:col-span-1">
{caseStudy.subtitle}
</p>
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={`${textLink} col-span-2 justify-self-start md:order-4 md:col-span-1`}
>
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
</li>
);
}
export default function CasesPage() {
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "CollectionPage",
"@id": `${pageUrl}#page`,
name: pageTitle,
url: pageUrl,
description: pageDescription,
},
{
"@type": "ItemList",
itemListElement: allCases.map((caseStudy, index) => ({
"@type": "ListItem",
position: index + 1,
name: caseStudy.title,
url: `${pageUrl}/${caseStudy.id}`,
})),
},
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", pageUrl],
] as const),
],
};
return (
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
{/* sem mosaico de pôsteres: os cartões logo abaixo já trazem a capa de cada cliente, e a mesma imagem não aparece duas vezes */}
<section
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
>
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
<div className="min-w-0 max-w-[860px]">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
]}
/>
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
Veja o que já fiz para empresas.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
{pageLead}
</p>
</div>
<PagePiece image="/3d/estado-tela.webp" />
</div>
</section>
<section className="ds-section" aria-labelledby="cases-clientes">
<div className="ds-section-shell">
<h2 id="cases-clientes" className="ds-card-title !mt-0">
Projetos de clientes
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCasesForListing.map((caseStudy, index, all) => (
<ClientCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
priority={index === 0}
wide={index === all.length - 1 && all.length % 2 === 1}
/>
))}
</div>
</div>
</section>
<section className="ds-section" aria-labelledby="cases-laboratorio">
<div className="ds-section-shell">
<h2 id="cases-laboratorio" className="ds-card-title !mt-0">
Laboratório
</h2>
<p className="ds-copy max-w-[64ch]">
Projetos pessoais e de estudo, feitos para praticar e testar ideias.
</p>
<ul className="mt-sp-6 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{labCases.map((caseStudy) => (
<LabCaseRow key={caseStudy.id} caseStudy={caseStudy} />
))}
</ul>
</div>
</section>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
<h2 className="ds-card-title !mt-0 text-white">
Quer um projeto parecido com algum destes?
</h2>
<p className="ds-copy max-w-[60ch]">
Conte o que você precisa. A conversa começa pelo WhatsApp, sem
formulário.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service={CASES_SUBJECT}
placement="cases_hub_footer"
className={primaryButton}
>
{buttonLabels.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
{buttonLabels.allServices}
</HoverPrefetchLink>
</div>
</div>
</section>
</main>
);
}
-220
View File
@@ -1,220 +0,0 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import About from "./About";
// o Link do Next, trocado por um <a> que mostra o prefetch recebido: HoverPrefetchLink começa em false
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
/**
* Testes de caminhos-base de About: a entrada dos quatro cartões (Reveal, CSS) com atrasos crescentes, o
* feedback de toque e de mouse por classes (cv-press, cv-lift; cartões sem foco), o conteúdo e a coluna de
* apoio. Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS.
*/
jest.mock("@/public/edev.jpg", () => ({
__esModule: true,
default: { src: "/edev.jpg", width: 10, height: 10 },
}));
jest.mock("./Section", () => ({
Section: ({
children,
id,
title,
description,
aside,
}: {
children?: React.ReactNode;
id?: string;
title?: string;
description?: string;
aside?: React.ReactNode;
}) => (
<section id={id}>
<h2>{title}</h2>
<p data-testid="about-description">{description}</p>
{aside}
{children}
</section>
),
}));
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
jest.mock("./animations/LazyRevealImage", () => ({
LazyRevealImage: ({ alt }: { alt: string }) => (
<span role="img" aria-label={alt} />
),
}));
describe("About", () => {
it("path: the four cards enter through Reveal -> same rise and duration, delays growing in steps", () => {
const { container } = render(<About />);
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
// 3 etapas da história + o card do perfil (o cabeçalho da seção é um dublê, sem Reveal)
expect(reveals).toHaveLength(4);
expect(
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
).toEqual(["20px", "20px", "20px", "20px"]);
expect(
reveals.map((element) =>
element.style.getPropertyValue("--reveal-duration"),
),
).toEqual(["0.8s", "0.8s", "0.8s", "0.8s"]);
const delays = reveals.map((element) =>
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
);
expect(delays).toEqual([0.08, 0.16, 0.24, 0.32]);
for (const element of reveals) {
expect(element.style.opacity).toBe("");
expect(element.style.transform).toBe("");
}
});
it("path: the four cards are not focus stops and use the CSS press feedback", () => {
const { container } = render(<About />);
const cards = container.querySelectorAll("article");
expect(cards).toHaveLength(4);
for (const card of cards) {
expect(card).not.toHaveAttribute("tabindex");
expect(card).toHaveClass("cv-press");
}
});
it("path: the three story cards lift on hover and press with CSS; the profile card presses only", () => {
const { container } = render(<About />);
const cards = [...container.querySelectorAll("article")];
expect(cards).toHaveLength(4);
for (const card of cards.slice(0, 3)) {
expect(card).toHaveClass("cv-press", "cv-lift");
}
expect(cards[3]).toHaveClass("cv-press");
});
it("path: the section has three points, each with the proof that backs it, and the résumé link", () => {
const { container } = render(<About />);
const titles = [
"Quem conversa com você escreve o código",
"Aproveito o que você já tem",
"Do código ao sistema no ar",
];
for (const name of titles) {
expect(
screen.getByRole("heading", { level: 3, name }),
).toBeInTheDocument();
}
// no máximo três pontos: os três cartões da história; o cartão do perfil não é um quarto ponto, só diz do que cuido
const story = [...container.querySelectorAll("article")].slice(0, 3);
expect(story).toHaveLength(3);
for (const card of story) {
expect(within(card).getAllByText("Na prática:")).toHaveLength(1);
}
// a prova de cada ponto é concreta: a Apolo desde 2022, a revisão e os cases entregues
expect(story[0]).toHaveTextContent(
/Na prática: Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT/,
);
expect(story[1]).toHaveTextContent(
/Na prática: Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer/,
);
expect(story[2]).toHaveTextContent(
/Na prática: Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços\./,
);
expect(
within(story[1]).getByRole("link", {
name: "Ver como funciona a revisão",
}),
).toHaveAttribute("href", "/ia");
expect(
screen.getByRole("link", { name: "Ver trajetória completa" }),
).toHaveAttribute("href", "/curriculo");
});
it("path: the idea of talking to whoever develops shows up once, not in five texts", () => {
const { container } = render(<About />);
expect(container.textContent).not.toMatch(
/Sem intermediários|Uma pessoa cuida|Quem responde pelo projeto|acompanha as decisões|fala com quem desenvolve|Solução do tamanho|Tudo funcionando junto/,
);
expect(container.textContent?.match(/mesma pessoa/g)).toHaveLength(1);
});
it("path: the résumé and the review links pre-fetch only when the visitor reaches them (HoverPrefetchLink), as in the rest of the home", () => {
render(<About />);
for (const name of [
"Ver trajetória completa",
"Ver como funciona a revisão",
]) {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
}
});
it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work once, as the proof of the first point", () => {
const { container } = render(<About />);
expect(container.textContent).not.toMatch(
/protocolos de baixo nível|operabilidade|camada física|sinal físico/i,
);
// a seção abre pela credencial, logo depois do nome
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
"Evandro Fonseca Junior",
);
expect(screen.getByTestId("about-description").textContent).toBe(
"Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas.",
);
// só o que o currículo sustenta: nada de "os sistemas internos e a IoT" da empresa inteira
expect(container.textContent).not.toMatch(
/os sistemas internos e a IoT da Apolo/,
);
// a frase da Apolo aparece uma só vez (na prova do primeiro ponto)
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
});
it("path: the first story card does not reuse the name 'Como eu trabalho' of the process list in the offers section right before", () => {
const { container } = render(<About />);
expect(container.textContent).not.toMatch(/Como eu trabalho/);
expect(screen.getByText("Quem desenvolve")).toBeInTheDocument();
});
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
const { container } = render(<About />);
expect(container.textContent).not.toMatch(
/contato direto|fala direto|falar direto|conversa comigo/i,
);
});
it("path: the location label stays on one line and the portrait card keeps its own height next to the text cards", () => {
const { container } = render(<About />);
expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass(
"whitespace-nowrap",
);
// items-start: o retrato tem a altura do próprio conteúdo e acompanha a rolagem; só os cartões de texto se esticam entre si
expect(container.querySelector(".ds-grid-2")).toHaveClass("items-start");
const story = container.querySelectorAll("article");
for (const card of Array.from(story).slice(0, 3)) {
expect(card).toHaveClass("flex-1");
}
});
});
-137
View File
@@ -1,137 +0,0 @@
import profilePhoto from "@/public/edev.jpg";
import { DrawLine } from "./animations/DrawLine";
import { LazyRevealImage } from "./animations/LazyRevealImage";
import { textLink } from "./button-styles";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
/**
* Os três pontos da seção, cada um com a prova que o sustenta. A ideia de que quem conversa com você é quem desenvolve
* aparece só no primeiro cartão: antes ela se repetia em cinco textos ("Sem intermediários", "Uma pessoa cuida",
* "Quem responde pelo projeto", "Você acompanha as decisões" e "Você fala com quem desenvolve", que segue só no hero).
*/
const STORY_DATA = [
{
label: "Quem desenvolve",
title: "Quem conversa com você escreve o código",
content:
"A mesma pessoa entende o problema, decide a estrutura e desenvolve o sistema, do começo ao fim.",
proof:
"Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas.",
},
{
label: "Sem começar do zero",
title: "Aproveito o que você já tem",
content:
"Antes de mexer em um sistema que já está em uso, vejo o que existe e combino com você o que vem primeiro. Nem todo problema exige começar de novo.",
proof:
"Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer, com o motivo de cada parte.",
link: { href: "/ia", label: "Ver como funciona a revisão" },
},
{
label: "Depois da entrega",
title: "Do código ao sistema no ar",
content:
"Colocar no ar, corrigir falhas, documentar e manter o sistema entram no planejamento desde o começo, junto com as funcionalidades.",
proof:
"Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços.",
},
];
/** Atraso (s) de cada cartão: entram em escada, como na vitrine de serviços. */
const cardDelay = (index: number) => 0.08 * (index + 1);
export default function About() {
return (
<Section
id="about"
eyebrow="Quem conduz o projeto"
title="Evandro Fonseca Junior"
description="Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas."
disableChildrenAnimation
aside={
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-slash" />
Garuva, SC · Atendimento remoto
</div>
}
>
{/* items-start: o cartão do retrato tem a altura do próprio conteúdo, e não a das três colunas de texto; no desktop ele acompanha a rolagem */}
<div className="ds-grid-2 items-start">
<div className="flex flex-col gap-sp-6">
{STORY_DATA.map((step, index) => (
<Reveal
key={step.title}
y={20}
duration={0.8}
delay={cardDelay(index)}
className="flex flex-1 flex-col"
>
<article className="ds-card cv-press cv-lift flex-1">
<div className="ds-kicker">{step.label}</div>
<DrawLine delay={0.03} />
<h3 className="ds-card-title">{step.title}</h3>
<p className="ds-copy" data-reel="right">
{step.content}
</p>
<p className="mt-sp-4 border-t border-[var(--border)] pt-sp-3 font-display text-[15px] leading-6 text-[var(--ink)]">
<span className="font-semibold">Na prática: </span>
{step.proof}
</p>
{step.link && (
<HoverPrefetchLink
href={step.link.href}
className={`${textLink} self-start text-[var(--ink-80)]`}
>
{step.link.label}
</HoverPrefetchLink>
)}
</article>
</Reveal>
))}
</div>
<Reveal
y={20}
duration={0.8}
delay={cardDelay(STORY_DATA.length)}
className="lg:sticky lg:top-28"
>
<article className="ds-card cv-press cv-lift">
<figure>
<LazyRevealImage
src={profilePhoto}
alt="Retrato de Evandro Fonseca Junior"
className="aspect-[4/3] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
imageClassName="object-cover object-[50%_25%]"
sizes="(min-width: 1024px) 50vw, 100vw"
/>
<figcaption className="mt-sp-3 font-display text-[15px] leading-6 text-[var(--ink-80)]">
Evandro Fonseca Junior · Engenheiro da computação · Garuva, SC
</figcaption>
</figure>
<div className="ds-kicker mt-sp-6">Do que eu cuido</div>
<h3 className="ds-card-title !mt-sp-2">
Tela, dados e equipamentos
</h3>
<DrawLine delay={0.05} />
<p className="ds-copy" data-reel="top">
Cuido da tela que sua equipe usa, dos dados por trás dela e,
quando o negócio tem máquinas ou sensores, da ligação deles com o
sistema. Ver tudo junto ajuda a decidir o que construir, o que
conectar e o que dá para aproveitar.
</p>
<HoverPrefetchLink
href="/curriculo"
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
>
Ver trajetória completa
</HoverPrefetchLink>
</article>
</Reveal>
</div>
</Section>
);
}
-57
View File
@@ -1,57 +0,0 @@
import { render, screen } from "@testing-library/react";
import { Breadcrumb } from "./Breadcrumb";
/**
* Testes de caminhos-base de Breadcrumb: a barra de separação vem depois de cada item menos do último (a quebra de linha
* no celular nunca deixa uma linha começando por "/"), e só o último é a página atual (inclusive quando é o único).
*/
describe("Breadcrumb", () => {
it("path: several items -> a separator after every item but the last, so a wrapped line starts with a name", () => {
const { container } = render(
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
{ label: "SEV Moda Íntima", href: "/cases/sev-modaintima" },
]}
/>,
);
const entries = Array.from(container.querySelectorAll("li"));
expect(entries).toHaveLength(3);
expect(
entries[0].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(
entries[1].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(entries[2].querySelector("span[aria-hidden='true']")).toBeNull();
// o nome vem antes da barra em todos os itens, e nenhum item abre por "/"
for (const entry of entries) {
expect(entry.firstElementChild?.tagName).toBe("A");
expect(entry.textContent?.trimStart().startsWith("/")).toBe(false);
}
expect(entries[0].className).toContain("flex");
});
it("path: a single item -> no separator, and it is the current page", () => {
const { container } = render(
<Breadcrumb items={[{ label: "Início", href: "/" }]} />,
);
expect(container.querySelector("span[aria-hidden='true']")).toBeNull();
expect(screen.getByRole("link", { name: "Início" })).toHaveAttribute(
"aria-current",
"page",
);
});
it("path: no items -> an empty list inside the navigation", () => {
render(<Breadcrumb items={[]} />);
const nav = screen.getByRole("navigation");
expect(nav.querySelectorAll("li")).toHaveLength(0);
expect(screen.queryByRole("link")).toBeNull();
});
});
@@ -1,52 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { Breadcrumb } from "./Breadcrumb";
/**
* A trilha de navegação só pré-carrega a rota de um item quando a pessoa chega nele (mouse, teclado ou toque):
* o `prefetch` não aparece no HTML, então o Link é trocado por um que o expõe em `data-prefetch`.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const items = [
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
];
describe("Breadcrumb: pré-carregamento", () => {
it("path: opening the page -> no link pre-fetches", () => {
render(<Breadcrumb items={items} />);
for (const link of screen.getAllByRole("link")) {
expect(link).toHaveAttribute("data-prefetch", "false");
}
});
it.each([
["mouse", fireEvent.mouseEnter],
["keyboard focus", fireEvent.focus],
["touch", fireEvent.touchStart],
])("path: %s reaching one link -> only that link pre-fetches", (_, event) => {
render(<Breadcrumb items={items} />);
const [home, cases] = screen.getAllByRole("link");
event(cases);
expect(cases).toHaveAttribute("data-prefetch", "null");
expect(home).toHaveAttribute("data-prefetch", "false");
});
});
-31
View File
@@ -1,31 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import { Breadcrumb } from "./Breadcrumb";
describe("Breadcrumb", () => {
const items = [
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
{ label: "SEV Moda Íntima", href: "/cases/sev-modaintima" },
];
it("marks only the last item as the current page", () => {
render(<Breadcrumb items={items} />);
const links = within(
screen.getByRole("navigation", { name: "Trilha de navegação" }),
).getAllByRole("link");
expect(links.map((link) => link.getAttribute("aria-current"))).toEqual([
null,
null,
"page",
]);
});
it("keeps the text at 15 px or more, as the body text of the inner pages", () => {
const { container } = render(<Breadcrumb items={items} />);
const list = container.querySelector("ol");
expect(list).toHaveClass("text-[15px]");
expect(list?.className).not.toMatch(/text-(xs|sm)\b|text-\[1[0-4]px\]/);
});
});
-34
View File
@@ -1,34 +0,0 @@
import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual.
* Os links só pré-carregam a rota quando a pessoa chega neles (HoverPrefetchLink).
* A barra vem depois de cada item, menos do último: quando a trilha não cabe numa linha (celular), a quebra cai
* depois da barra e a linha de baixo começa pelo nome, nunca por "/".
*/
export function Breadcrumb({
items,
}: {
items: readonly { label: string; href: string }[];
}) {
return (
<nav aria-label="Trilha de navegação" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-x-sp-2 gap-y-sp-1 text-[15px]">
{items.map(({ label, href }, index) => (
<li key={href} className="flex items-center gap-sp-2">
<HoverPrefetchLink
aria-current={index === items.length - 1 ? "page" : undefined}
className="ds-link ds-link-text"
href={href}
>
{label}
</HoverPrefetchLink>
{index < items.length - 1 ? (
<span aria-hidden="true">/</span>
) : null}
</li>
))}
</ol>
</nav>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { render, screen } from "@testing-library/react";
import ClientStrip from "./ClientStrip";
/**
* Testes de caminhos-base de ClientStrip (complemento de ClientStrip.test.tsx): o separador entre clientes
* (um cliente, vários; só entre itens, nunca no fim de uma linha) e o className opcional.
*/
const clients = [
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
{ id: "lavacao-047", name: "Lavação 047" },
{ id: "sc-plus", name: "SC Plus Aquicultura" },
];
describe("ClientStrip", () => {
it("path: a single client -> no separator dot", () => {
const { container } = render(<ClientStrip clients={clients.slice(0, 1)} />);
expect(screen.getAllByRole("link")).toHaveLength(1);
expect(container.querySelectorAll("[aria-hidden='true']")).toHaveLength(0);
});
it("path: several clients -> a separator before every client except the first", () => {
render(<ClientStrip clients={clients} />);
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(
items.map((item) => item.querySelector("[aria-hidden='true']") !== null),
).toEqual([false, true, true]);
expect(items[1].querySelector("[aria-hidden='true']")).toHaveTextContent(
"·",
);
});
it("path: className given -> appended to the wrapper without a stray space", () => {
const { container } = render(
<ClientStrip clients={clients} className="hero-in mt-sp-4" />,
);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.className.endsWith("hero-in mt-sp-4")).toBe(true);
});
it("path: className omitted -> the wrapper class has no trailing space", () => {
const { container } = render(<ClientStrip clients={clients} />);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.className).toBe(wrapper.className.trim());
});
});
-136
View File
@@ -1,136 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import ClientStrip from "./ClientStrip";
const clients = [
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
{ id: "lavacao-047", name: "Lavação 047" },
];
it("lists each client as a link to its case", () => {
render(<ClientStrip clients={clients} />);
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
const list = screen.getByRole("list");
const links = within(list).getAllByRole("link");
expect(links.map((link) => link.textContent)).toEqual([
"SEV Moda Íntima",
"Lavação 047",
]);
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
"/cases/lavacao-047",
]);
});
it("renders nothing without clients", () => {
const { container } = render(<ClientStrip clients={[]} />);
expect(container).toBeEmptyDOMElement();
const withoutProp = render(<ClientStrip />);
expect(withoutProp.container).toBeEmptyDOMElement();
});
it("puts the years of experience in the same line, after the clients", () => {
render(<ClientStrip clients={clients} years={5} />);
const experience = screen.getByText("anos de experiência");
expect(screen.getByText("5")).toBeInTheDocument();
const lastClient = screen.getByRole("link", { name: "Lavação 047" });
expect(
lastClient.compareDocumentPosition(experience) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(experience.closest("div")).toBe(
screen.getByText("Já entregue para:").parentElement,
);
});
it("shows only the experience when there are no clients, and nothing without either", () => {
const only = render(<ClientStrip years={5} />);
expect(screen.queryByText("Já entregue para:")).toBeNull();
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
only.unmount();
const none = render(<ClientStrip years={0} />);
expect(none.container).toBeEmptyDOMElement();
});
it("gives every client link 44 px of touch target on the phone (min-h-11) and 24 px from 640 px on, where the sector shares the item", () => {
render(<ClientStrip clients={clients} />);
for (const link of screen.getAllByRole("link")) {
expect(link).toHaveClass("min-h-11", "sm:min-h-6");
}
});
it("puts the separator only BETWEEN items: none before the first, one before each of the others (years included)", () => {
const withYears = render(<ClientStrip clients={clients} years={5} />);
const items = withYears.container.querySelectorAll("li");
expect(items).toHaveLength(clients.length + 1);
expect(
Array.from(items).map(
(item) => item.querySelector("[aria-hidden='true']") !== null,
),
).toEqual([false, true, true]);
// nenhum item termina com o ponto: ele sempre abre o item, para a margem negativa escondê-lo no começo de uma linha
for (const item of items) {
expect(item.lastElementChild).not.toHaveAttribute("aria-hidden");
}
withYears.unmount();
// zero anos não escreve um "0" solto nem um ponto a mais
const zero = render(<ClientStrip clients={clients} years={0} />);
expect(
zero.container.querySelectorAll("li [aria-hidden='true']"),
).toHaveLength(clients.length - 1);
expect(zero.container).not.toHaveTextContent(/anos de experiência/);
expect(screen.queryByText("0")).toBeNull();
});
it("hides the separator of an item that wraps to the next line: negative margin on every item but the first, and the list clips the overflow on the x axis", () => {
const { container } = render(<ClientStrip clients={clients} years={5} />);
const list = container.querySelector("ul") as HTMLElement;
expect(list).toHaveClass("flex-wrap", "overflow-x-clip");
const items = Array.from(list.querySelectorAll("li"));
expect(items[0]).not.toHaveClass("-ml-4");
for (const item of items.slice(1)) {
expect(item).toHaveClass("-ml-4");
// o ponto ocupa os mesmos 16 px que a margem negativa
expect(item.querySelector("[aria-hidden='true']")).toHaveClass("w-4");
}
for (const item of items) expect(item).toHaveClass("pr-4");
});
it("keeps the years number in the display font (the mono font is not downloaded above the fold)", () => {
render(<ClientStrip clients={clients} years={5} />);
expect(screen.getByText("5").className).not.toContain("font-mono");
});
it("stacks the sector under each name from 640 px on, outside the link, and shows only the name for a client without one", () => {
render(
<ClientStrip
clients={[
{
id: "sev-modaintima",
name: "SEV Moda Íntima",
sector: "loja virtual",
},
{ id: "lavacao-047", name: "Lavação 047" },
]}
/>,
);
const sector = screen.getByText("loja virtual");
// 13 px e apagado, e só de 640 px em diante (abaixo, só os nomes)
expect(sector).toHaveClass("text-[13px]", "max-sm:hidden");
const link = screen.getByRole("link", { name: "SEV Moda Íntima" });
expect(link).not.toContainElement(sector);
expect(link.parentElement).toContainElement(sector);
expect(link.parentElement).toHaveClass("flex-col");
// o nome continua levando ao case
expect(link).toHaveAttribute("href", "/cases/sev-modaintima");
const plain = screen.getByRole("link", { name: "Lavação 047" });
expect(plain.parentElement?.childElementCount).toBe(1);
});
-100
View File
@@ -1,100 +0,0 @@
import HoverPrefetchLink from "./HoverPrefetchLink";
export type ClientLink = {
id: string;
name: string;
/** Ramo do cliente ("loja virtual"), sob o nome de 640 px em diante (lib/proof.ts). */
sector?: string;
};
/**
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
* De 640 px em diante cada cliente é um par empilhado: o nome (link) sobre o ramo, em 13 px e apagado; abaixo disso,
* só os nomes. Os links têm 44 px de altura de toque no celular e 24 px a partir de 640 px, onde o ramo divide a
* altura do item; a rota só é pré-carregada quando a pessoa chega no link. Os pontos, o rótulo e os anos se alinham
* pela linha dos nomes (`sm:items-start`).
*
* O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item
* (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item
* anterior, e é nesses 16 px que o ponto (`w-4`) fica. Quando a lista quebra e um item abre a linha seguinte, o
* ponto dele cai na margem à esquerda da lista e o `overflow-x: clip` o esconde: nenhuma linha termina nem começa
* com "·". O recuo de 4 px da lista (`-ml-1 pl-1`) só dá espaço ao contorno de foco do primeiro link (2 px de
* contorno e 2 px de afastamento); o ponto, centrado na margem, fica 6 px além dele.
*/
export default function ClientStrip({
clients = [],
years,
className = "",
}: {
clients?: ClientLink[];
/** Anos de experiência, na mesma linha dos clientes. */
years?: number;
className?: string;
}) {
if (clients.length === 0 && !years) return null;
const items = [
...clients.map((client) => ({
key: client.id,
node: (
<span className="flex flex-col">
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center self-start font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] sm:min-h-6"
>
{client.name}
</HoverPrefetchLink>
{client.sector && (
<span className="text-[13px] leading-[18px] max-sm:hidden">
{client.sector}
</span>
)}
</span>
),
})),
...(years
? [
{
key: "years",
node: (
<p>
<span className="font-semibold text-[var(--slash)]">
{years}
</span>{" "}
anos de experiência
</p>
),
},
]
: []),
];
return (
<div
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] sm:items-start ${className}`.trim()}
>
{clients.length > 0 && <p>Já entregue para:</p>}
{/* de 640 px em diante a lista fica ao lado do rótulo enquanto sobram 24rem, e quebra por dentro; menos que isso,
ela desce para baixo do rótulo e usa a largura toda */}
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1 sm:min-w-[min(24rem,100%)] sm:flex-1 sm:basis-0 sm:items-start sm:gap-y-sp-2">
{items.map((item, index) => (
<li
key={item.key}
className={`flex items-center pr-4 sm:items-start${index > 0 ? " -ml-4" : ""}`}
>
{index > 0 && (
<span
aria-hidden="true"
className="w-4 shrink-0 text-center text-[rgba(244,242,237,.4)]"
>
·
</span>
)}
{item.node}
</li>
))}
</ul>
</div>
);
}
@@ -1,84 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import Contact from "./Contact";
import { CONTACT_KIND_EVENT, DEFAULT_CONTACT_SUBJECT } from "./contact-subject";
/**
* Testes de caminhos-base da leitura do tipo da oferta em Contact (`/?tipo=…#contact-form`), complemento de
* Contact.offer.test.tsx: tipo válido, inválido e ausente no endereço, e a ordem entre o endereço e o evento.
*/
const subjectSelect = () =>
screen.getByRole("combobox", {
name: /tipo de projeto/i,
}) as HTMLSelectElement;
const choose = (kind: unknown) =>
act(() => {
window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }));
});
afterEach(() => {
window.history.replaceState(null, "", "/");
});
describe("Contact: tipo no endereço", () => {
it.each([
["/", "sem busca nenhuma (tipo ausente)"],
["/?utm_source=instagram#contact", "só outros parâmetros (tipo ausente)"],
["/?tipo=#contact", "tipo vazio"],
["/?tipo=SITE#contact", "tipo com outra caixa (só vale em minúsculas)"],
["/?tipo=constructor#contact", "nome herdado de todo objeto"],
["/?tipo=outra-coisa#contact", "tipo desconhecido"],
])("path: %s (%s) -> keeps the default option", (address) => {
window.history.replaceState(null, "", address);
render(<Contact />);
expect(subjectSelect()).toHaveValue(DEFAULT_CONTACT_SUBJECT);
});
it.each([
["/?tipo=site#contact-form", "Site que traz pedidos pelo WhatsApp"],
[
"/?tipo=sistema#contact-form",
"Sistema para tirar a operação da planilha",
],
[
"/?utm_source=x&tipo=revisao#contact-form",
"Revisão de sistema que já existe",
],
// o endereço antigo, com #contact, continua valendo: o tipo vem da busca, não do fragmento
["/?tipo=site#contact", "Site que traz pedidos pelo WhatsApp"],
// com o parâmetro repetido vale o primeiro
[
"/?tipo=site&tipo=revisao#contact-form",
"Site que traz pedidos pelo WhatsApp",
],
])("path: %s (valid type) -> selects %s", (address, option) => {
window.history.replaceState(null, "", address);
render(<Contact />);
expect(subjectSelect()).toHaveValue(option);
});
it("path: valid type in the address, then an unknown one from the event -> the address choice stays", () => {
window.history.replaceState(null, "", "/?tipo=sistema#contact");
render(<Contact />);
choose("desconhecido");
expect(subjectSelect()).toHaveValue(
"Sistema para tirar a operação da planilha",
);
});
it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => {
window.history.replaceState(null, "", "/?tipo=sistema#contact");
render(<Contact />);
choose("revisao");
expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
});
});
-247
View File
@@ -1,247 +0,0 @@
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Contact from "./Contact";
import { CONTACT_KIND_EVENT } from "./contact-subject";
/**
* O tipo de projeto do formulário vindo da oferta escolhida na home: pelo evento que o botão da oferta dispara
* (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página). As três ofertas entram no formulário
* com o nome que têm na home, o formulário avisa "Você escolheu: <oferta>" e o link direto do WhatsApp leva o tipo.
*/
const subjectSelect = () =>
screen.getByRole("combobox", {
name: /tipo de projeto/i,
}) as HTMLSelectElement;
const choose = (kind: unknown) =>
act(() => {
window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }));
});
afterEach(() => {
window.history.replaceState(null, "", "/");
});
describe("Contact: opções do tipo de projeto", () => {
it("lists the five offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
render(<Contact />);
const options = within(subjectSelect()).getAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Ainda preciso definir",
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
"Revisão de sistema que já existe",
"Nota fiscal pronta para a reforma tributária",
]);
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
});
it("puts the review type in the message that goes to WhatsApp and e-mail", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.selectOptions(
subjectSelect(),
"Revisão de sistema que já existe",
);
const mail = screen.getByRole("link", { name: /e-mail/i });
const body = new URL(mail.getAttribute("href") ?? "").searchParams.get(
"body",
);
expect(body).toContain("Tipo de projeto: Revisão de sistema que já existe");
});
});
describe("Contact: tipo escolhido pela oferta", () => {
it.each([
["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistema para tirar a operação da planilha"],
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
["revisao", "Revisão de sistema que já existe"],
["fiscal", "Nota fiscal pronta para a reforma tributária"],
])("the offer event with %s selects %s", (kind, option) => {
render(<Contact />);
choose(kind);
expect(subjectSelect()).toHaveValue(option);
});
it("a later offer replaces the earlier choice, and a person's own pick is replaced by the offer too", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
choose("site");
await user.selectOptions(subjectSelect(), "Aplicativos");
expect(subjectSelect()).toHaveValue("Aplicativos");
choose("revisao");
expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
});
it.each([
"desconhecido",
"constructor",
"__proto__",
"",
undefined,
null,
42,
])("an unknown type %p changes nothing", (kind) => {
render(<Contact />);
choose("sistema");
choose(kind);
expect(subjectSelect()).toHaveValue(
"Sistema para tirar a operação da planilha",
);
});
it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => {
window.history.replaceState(null, "", "/?tipo=revisao#contact-form");
render(<Contact />);
expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
expect(screen.getByRole("status")).toHaveTextContent(
"Você escolheu: Revisão de sistema que já existe",
);
});
it("ignores an unknown ?tipo= in the address and keeps the default", () => {
window.history.replaceState(null, "", "/?tipo=outra-coisa");
render(<Contact />);
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
});
it("stops listening when it unmounts", () => {
const add = jest.spyOn(window, "addEventListener");
const remove = jest.spyOn(window, "removeEventListener");
const { unmount } = render(<Contact />);
const registered = add.mock.calls.find(
([type]) => type === CONTACT_KIND_EVENT,
);
expect(registered).toBeDefined();
unmount();
expect(remove).toHaveBeenCalledWith(CONTACT_KIND_EVENT, registered?.[1]);
add.mockRestore();
remove.mockRestore();
});
});
describe("Contact: você escolheu e o WhatsApp direto", () => {
const directLink = () =>
screen.getByRole("link", { name: /Prefere conversar direto/ });
const directMessage = () =>
new URL(directLink().getAttribute("href") ?? "").searchParams.get("text");
it("renders the form card with the id the offers scroll to, and an empty status until an offer is chosen", () => {
const { container } = render(<Contact />);
expect(container.querySelector("form")).toHaveAttribute(
"id",
"contact-form",
);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it.each([
["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistema para tirar a operação da planilha"],
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
["revisao", "Revisão de sistema que já existe"],
["fiscal", "Nota fiscal pronta para a reforma tributária"],
])(
"after the offer %s the form says 'Você escolheu: <name>'",
(kind, name) => {
render(<Contact />);
choose(kind);
expect(screen.getByRole("status")).toHaveTextContent(
`Você escolheu: ${name}`,
);
expect(within(screen.getByRole("status")).getByText(name).tagName).toBe(
"STRONG",
);
},
);
it("drops the message when the person picks another type in the form, and keeps it for an unknown offer", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
choose("desconhecido");
expect(screen.getByRole("status")).toBeEmptyDOMElement();
choose("sistema");
expect(screen.getByRole("status")).not.toBeEmptyDOMElement();
await user.selectOptions(subjectSelect(), "Aplicativos");
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it("takes the chosen type to the direct WhatsApp link of the contact column, as a phrase, and back to 'um projeto'", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
expect(directMessage()).toBe(
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.",
);
choose("sistema");
expect(directMessage()).toBe(
"Olá, Evandro! Quero conversar sobre um sistema para tirar a operação da planilha. Contato iniciado pelo site evandro.dev.br.",
);
choose("revisao");
expect(directMessage()).toContain(
"Quero conversar sobre a revisão de um sistema que já existe.",
);
choose("whatsapp");
expect(directMessage()).toContain(
"Quero conversar sobre um atendimento no WhatsApp com IA sob controle.",
);
choose("fiscal");
expect(directMessage()).toContain(
"Quero conversar sobre a adaptação da nota fiscal à reforma tributária.",
);
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
await user.selectOptions(
subjectSelect(),
"Site que traz pedidos pelo WhatsApp",
);
expect(directMessage()).toContain(
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
);
// um tipo sem oferta volta para a frase de "um projeto"
await user.selectOptions(subjectSelect(), "Aplicativos");
expect(directMessage()).toContain("Quero conversar sobre um projeto.");
// nada de nome de oferta com maiúscula na frase
expect(directMessage()).not.toMatch(/sobre [A-Z]/);
});
});
describe("Contact: texto da seção", () => {
it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => {
const { container } = render(<Contact />);
expect(screen.getByText("Prefere conversar direto?")).toBeInTheDocument();
expect(container.textContent).not.toMatch(/fala direto|fale direto/i);
expect(
screen.getByRole("heading", {
level: 3,
name: "Como a conversa começa.",
}),
).toBeInTheDocument();
});
});
-282
View File
@@ -1,282 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Contact from "./Contact";
/**
* Testes de caminhos-base de Contact (complemento de Contact.test.tsx): validate() campo a campo,
* foco no primeiro campo inválido, limpeza independente dos erros e montagem da mensagem.
*/
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
delete window["ga-disable-G-QTT9QT0T3S"];
window.gtag = jest.fn();
// uma aba aberta de verdade: se devolver null, o Contact cai para location.href
jest
.spyOn(window, "open")
.mockImplementation(() => ({ opener: {} }) as unknown as Window);
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
const NAME_ERROR = "Informe seu nome para eu saber com quem falo.";
const EMAIL_ERROR =
"Confira o e-mail: ele precisa ter o formato [email protected].";
const MESSAGE_ERROR = "Conte em poucas palavras o que você precisa resolver.";
const nameField = () => screen.getByRole("textbox", { name: /nome/i });
const emailField = () => screen.getByRole("textbox", { name: /e-mail/i });
const messageField = () => screen.getByRole("textbox", { name: /contexto/i });
const submitButton = () => screen.getByRole("button", { name: /whatsapp/i });
async function fill(
user: ReturnType<typeof userEvent.setup>,
values: { name?: string; email?: string; message?: string },
) {
if (values.name !== undefined) await user.type(nameField(), values.name);
if (values.email !== undefined) await user.type(emailField(), values.email);
if (values.message !== undefined)
await user.type(messageField(), values.message);
}
function sentMessage() {
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
return url.searchParams.get("text") ?? "";
}
describe("Contact: validate()", () => {
it("path: only the name is missing -> only the name is flagged and gets the focus", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await fill(user, {
email: "[email protected]",
message: "Organizar pedidos",
});
await user.click(submitButton());
expect(nameField()).toHaveAttribute("aria-invalid", "true");
expect(emailField()).not.toHaveAttribute("aria-invalid");
expect(messageField()).not.toHaveAttribute("aria-invalid");
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
expect(nameField()).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled();
});
it("path: only the e-mail is malformed -> only the e-mail is flagged and gets the focus (not the name)", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await fill(user, {
name: "Ana",
email: "ana@semponto",
message: "Organizar pedidos",
});
await user.click(submitButton());
expect(emailField()).toHaveAttribute("aria-invalid", "true");
expect(emailField()).toHaveAccessibleDescription(EMAIL_ERROR);
expect(nameField()).not.toHaveAttribute("aria-invalid");
expect(messageField()).not.toHaveAttribute("aria-invalid");
expect(emailField()).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
});
it("path: only the message is missing -> only the message is flagged and gets the focus", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await fill(user, { name: "Ana" });
await user.click(submitButton());
expect(messageField()).toHaveAttribute("aria-invalid", "true");
expect(messageField()).toHaveAccessibleDescription(
new RegExp(MESSAGE_ERROR),
);
expect(nameField()).not.toHaveAttribute("aria-invalid");
expect(emailField()).not.toHaveAttribute("aria-invalid");
expect(messageField()).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
});
it("path: name and message made only of spaces -> both rejected", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await fill(user, { name: " ", message: " " });
await user.click(submitButton());
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
expect(nameField()).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
});
it("path: e-mail made only of spaces -> counts as omitted: no error and no e-mail line", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await fill(user, {
name: "Ana",
email: " ",
message: "Organizar pedidos",
});
await user.click(submitButton());
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(sentMessage()).not.toContain("E-mail:");
});
it.each(["a@b", "a@c.", "@c.com", "a@@c.com", "a [email protected]", "sem-arroba.com"])(
"path: e-mail %j -> invalid format is blocked",
async (email) => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
await user.click(submitButton());
expect(screen.getByText(EMAIL_ERROR)).toBeVisible();
expect(window.open).not.toHaveBeenCalled();
},
);
it.each(["[email protected]", "[email protected]"])(
"path: e-mail %j -> valid format is accepted and sent",
async (email) => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
await user.click(submitButton());
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(sentMessage()).toContain(`E-mail: ${email}\n`);
},
);
});
describe("Contact: updateField() and the error state", () => {
it("path: typing in one field clears only that field's error", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.click(submitButton());
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
await user.type(messageField(), "Organizar pedidos");
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
expect(messageField()).not.toHaveAttribute("aria-invalid");
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(nameField()).toHaveAttribute("aria-invalid", "true");
});
it("path: changing the project type keeps the other fields' errors untouched", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.click(submitButton());
await user.selectOptions(
screen.getByRole("combobox", { name: /tipo de projeto/i }),
"Aplicativos",
);
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
expect(
screen.getByRole("combobox", { name: /tipo de projeto/i }),
).toHaveValue("Aplicativos");
});
it("path: fixing every error and submitting again -> errors gone, WhatsApp opens once", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.click(submitButton());
expect(window.open).not.toHaveBeenCalled();
await fill(user, { name: "Ana", message: "Organizar pedidos" });
await user.click(submitButton());
expect(screen.queryByText(NAME_ERROR)).toBeNull();
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledTimes(1);
});
});
describe("Contact: mensagem montada", () => {
it("path: surrounding spaces are trimmed in name, e-mail and message", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await fill(user, {
name: " Ana ",
email: " [email protected] ",
message: " Organizar pedidos ",
});
await user.click(submitButton());
const message = sentMessage();
expect(message).toContain("Nome: Ana\n");
expect(message).toContain("E-mail: [email protected]\n");
expect(message).toContain(
"Contexto, objetivo e prazo:\nOrganizar pedidos\n\n",
);
});
it("path: the chosen project type and the e-mail line reach the mailto draft, and the e-mail line is omitted when blank", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.selectOptions(
screen.getByRole("combobox", { name: /tipo de projeto/i }),
"Aplicativos",
);
await fill(user, { name: "Ana", message: "Organizar pedidos" });
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
const body = new URL(mail.getAttribute("href") ?? "").searchParams.get(
"body",
);
expect(body).toContain("Tipo de projeto: Aplicativos");
expect(body).not.toContain("E-mail:");
await user.type(emailField(), "[email protected]");
const withEmail = new URL(
screen
.getByRole("link", { name: "Prefere e-mail? Enviar por e-mail" })
.getAttribute("href") ?? "",
).searchParams.get("body");
expect(withEmail).toContain("E-mail: [email protected]\n");
});
it("path: the direct e-mail row reports its own placement", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
const direct = screen.getByRole("link", {
name: /evandrofonsecajunior@gmail\.com/,
});
// o jsdom não navega para mailto:; o clique só precisa chegar ao rastreio
direct.addEventListener("click", (event) => event.preventDefault());
await user.click(direct);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "contact_direct" }),
);
});
});
-441
View File
@@ -1,441 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderToStaticMarkup } from "react-dom/server";
import Contact from "./Contact";
/**
* Sem atraso entre as teclas: com o atraso padrão, digitar o contexto longo (cerca de 130 caracteres) levava
* mais de 5 s em máquina lenta e o teste estourava o tempo. O texto longo ainda entra por colagem.
*/
const setup = () => userEvent.setup({ delay: null });
/** Aba aberta de verdade; o Contact corta o vínculo com a página (opener = null) logo depois. */
let openedWindow: { opener: unknown };
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
delete window["ga-disable-G-QTT9QT0T3S"];
window.gtag = jest.fn();
openedWindow = { opener: {} };
jest
.spyOn(window, "open")
.mockImplementation(() => openedWindow as unknown as Window);
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => {
const user = setup();
render(<Contact />);
const name = "Ana & João";
const email = "[email protected]";
const context =
"Integrar pedidos & estoque? Objetivo: evitar retrabalho.\nPrazo: outubro #2026";
await user.type(screen.getByRole("textbox", { name: /nome/i }), name);
await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email);
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
await user.selectOptions(subject, "Integrações e automações");
await user.click(screen.getByRole("textbox", { name: /contexto/i }));
await user.paste(context);
expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const [destination, target, ...rest] = jest.mocked(window.open).mock.calls[0];
const url = new URL(String(destination));
expect(url.origin).toBe("https://wa.me");
expect(url.pathname).toMatch(/^\/\d+$/);
expect([...url.searchParams.keys()]).toEqual(["text"]);
const message = url.searchParams.get("text");
expect(message).toContain(name);
expect(message).toContain(email);
expect(message).toContain("Integrações e automações");
expect(message).toContain(context);
expect(target).toBe("_blank");
// sem "noopener" na chamada (ele faria o window.open devolver sempre null); o vínculo é cortado depois
expect(rest).toEqual([]);
expect(openedWindow.opener).toBeNull();
expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
send_to: "G-QTT9QT0T3S",
service: "Contato",
placement: "contact_form",
page_path: "/",
page_location: "https://evandro.dev.br/",
page_referrer: "",
});
});
it("does not open WhatsApp while required fields are empty or the email is invalid", async () => {
const user = setup();
render(<Contact />);
const submit = screen.getByRole("button", { name: /whatsapp/i });
await user.click(submit);
expect(window.open).not.toHaveBeenCalled();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(submit);
expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled();
});
it("opens the project draft with analytics unavailable and consent denied", async () => {
localStorage.setItem("evandro.analytics-consent.v1", "denied");
delete window.gtag;
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"[email protected]",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const destination = new URL(
String(jest.mocked(window.open).mock.calls[0][0]),
);
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain("Organizar pedidos");
});
it("lets the visitor skip the e-mail and leaves that line out of the message", async () => {
const user = setup();
render(<Contact />);
expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
const message = url.searchParams.get("text") ?? "";
expect(message).toContain("Nome: Ana");
expect(message).toContain("Tipo de projeto: Ainda preciso definir");
expect(message).not.toContain("E-mail:");
});
it("offers 'Ainda preciso definir' as the first and default project type", () => {
render(<Contact />);
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
const options = Array.from(subject.querySelectorAll("option")).map(
(option) => option.value,
);
expect(options[0]).toBe("Ainda preciso definir");
expect(options).toContain("Site que traz pedidos pelo WhatsApp");
expect(subject).toHaveValue("Ainda preciso definir");
});
it("labels the first field 'Seu nome'", () => {
render(<Contact />);
expect(screen.getByLabelText("Seu nome")).toHaveAttribute("name", "name");
expect(screen.queryByLabelText("Nome completo")).toBeNull();
});
it("explains each invalid field in Portuguese and marks it for assistive technology", async () => {
const user = setup();
render(<Contact />);
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
const name = screen.getByRole("textbox", { name: /nome/i });
const email = screen.getByRole("textbox", { name: /e-mail/i });
const message = screen.getByRole("textbox", { name: /contexto/i });
for (const field of [name, email, message]) {
expect(field).toHaveAttribute("aria-invalid", "true");
}
expect(name).toHaveAccessibleDescription(
"Informe seu nome para eu saber com quem falo.",
);
expect(email).toHaveAccessibleDescription(
"Confira o e-mail: ele precisa ter o formato [email protected].",
);
expect(message).toHaveAccessibleDescription(
/Conte em poucas palavras o que você precisa resolver\./,
);
expect(name).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
await user.type(name, "Ana");
expect(name).not.toHaveAttribute("aria-invalid");
expect(
screen.queryByText("Informe seu nome para eu saber com quem falo."),
).toBeNull();
});
it("offers the same text by e-mail, below the WhatsApp button", async () => {
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos & estoque",
);
const submit = screen.getByRole("button", { name: /whatsapp/i });
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
expect(
submit.compareDocumentPosition(mail) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const href = mail.getAttribute("href") ?? "";
expect(href.startsWith("mailto:[email protected]?")).toBe(true);
const url = new URL(href);
expect(url.searchParams.get("subject")).toBe("Projeto pelo site");
const body = url.searchParams.get("body") ?? "";
expect(body).toContain("Nome: Ana");
expect(body).toContain("Organizar pedidos & estoque");
expect(body).toContain("Tipo de projeto: Ainda preciso definir");
await user.click(mail);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "contact_form" }),
);
});
it("does not call the direct WhatsApp row 'Contato'", () => {
render(<Contact />);
const direct = screen.getByRole("link", { name: /Prefere conversar direto/ });
const text = new URL(direct.getAttribute("href") ?? "").searchParams.get(
"text",
);
expect(text).toContain("Quero conversar sobre um projeto.");
expect(text).not.toMatch(/sobre Contato/);
});
describe("e-mail draft validation", () => {
it("does not open the e-mail client while the form is empty, and explains what is missing", () => {
render(<Contact />);
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
// fireEvent.click devolve false quando o clique foi cancelado (preventDefault)
expect(fireEvent.click(mail)).toBe(false);
expect(
screen.getByText("Informe seu nome para eu saber com quem falo."),
).toBeVisible();
expect(
screen.getByText("Conte em poucas palavras o que você precisa resolver."),
).toBeVisible();
expect(screen.getByRole("textbox", { name: /nome/i })).toHaveFocus();
expect(window.gtag).not.toHaveBeenCalled();
});
it("blocks a malformed e-mail address the same way", async () => {
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
expect(fireEvent.click(mail)).toBe(false);
expect(screen.getByRole("textbox", { name: /e-mail/i })).toHaveFocus();
});
it("lets the click through, and measures it, once the form is valid", async () => {
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
// o clique não é cancelado pela validação: o navegador segue para o mailto:
expect(fireEvent.click(mail)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "contact_form" }),
);
});
});
describe("WhatsApp fallback", () => {
it("goes to WhatsApp in the same page when the browser blocks the new tab", async () => {
// o jsdom não navega: ele avisa "Not implemented: navigation" ao atribuir location.href
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.mocked(window.open).mockImplementation(() => null);
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
expect(
errors.mock.calls.some(([error]) =>
String(error).includes("Not implemented: navigation"),
),
).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
});
it("does not navigate the page when the new tab opened", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(errors).not.toHaveBeenCalled();
});
/** Preenche o mínimo e envia; devolve os erros que o jsdom registrou ao "navegar" para location.href. */
async function submitWithMinimumFields() {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
return errors;
}
const navigated = (errors: jest.SpyInstance) =>
errors.mock.calls.some(([error]) =>
String(error).includes("Not implemented: navigation"),
);
it("goes to WhatsApp in the same page when there is no window.open", async () => {
const original = window.open;
Object.defineProperty(window, "open", {
configurable: true,
value: undefined,
});
try {
const errors = await submitWithMinimumFields();
expect(navigated(errors)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
} finally {
Object.defineProperty(window, "open", {
configurable: true,
value: original,
});
}
});
it("goes to WhatsApp in the same page when window.open throws", async () => {
jest.mocked(window.open).mockImplementation(() => {
throw new DOMException("bloqueado", "SecurityError");
});
const errors = await submitWithMinimumFields();
expect(navigated(errors)).toBe(true);
});
it("closes the new tab and stays on the same page when its link to this page cannot be cut", async () => {
const close = jest.fn();
const tab = {
close,
get opener() {
return {};
},
set opener(_value: unknown) {
throw new DOMException("sem acesso", "SecurityError");
},
};
jest.mocked(window.open).mockImplementation(() => tab as unknown as Window);
const errors = await submitWithMinimumFields();
expect(close).toHaveBeenCalledTimes(1);
expect(navigated(errors)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
});
});
describe("without JavaScript", () => {
it("keeps the form body out of the way and offers direct WhatsApp and e-mail links in a <noscript>", () => {
const html = renderToStaticMarkup(<Contact />);
const noscript = html.match(/<noscript>([\s\S]*?)<\/noscript>/)?.[1] ?? "";
expect(noscript).toMatch(/href="https:\/\/wa\.me\/\d+\?text=/);
expect(noscript).toContain('href="mailto:[email protected]"');
// o botão de envio e os campos ficam na área "js-only", que app/layout.tsx esconde sem JavaScript
const jsOnly = html.match(/class="js-only[^"]*"/);
expect(jsOnly).not.toBeNull();
expect(html.indexOf("js-only")).toBeLessThan(
html.indexOf("Continuar no WhatsApp"),
);
expect(html.indexOf("Continuar no WhatsApp")).toBeLessThan(
html.indexOf("<noscript>"),
);
});
});
-463
View File
@@ -1,463 +0,0 @@
"use client";
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
import type { FormEvent, MouseEvent, ReactNode } from "react";
import { useEffect, useState } from "react";
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
import { CONTACT_EMAIL } from "@/lib/site-navigation";
import {
buildWhatsAppUrl,
getWhatsAppDisplayNumber,
OFFER_SUBJECTS,
} from "@/lib/whatsapp";
import {
CONTACT_FORM_ID,
CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM,
CONTACT_KINDS,
CONTACT_SUBJECT_OPTIONS,
type ContactKind,
contactKindForSubject,
contactSubjectForKind,
DEFAULT_CONTACT_SUBJECT,
} from "./contact-subject";
import "./conversion.css";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
type ContactField = "name" | "email" | "message";
type FieldErrors = Partial<Record<ContactField, string>>;
const FIELD_ERRORS: Record<ContactField, string> = {
name: "Informe seu nome para eu saber com quem falo.",
email: "Confira o e-mail: ele precisa ter o formato [email protected].",
message: "Conte em poucas palavras o que você precisa resolver.",
};
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const FIELD_ORDER: ContactField[] = ["name", "email", "message"];
/** E-mail é opcional: só é erro se vier preenchido e fora do formato. */
function validate(data: {
name: string;
email: string;
message: string;
}): FieldErrors {
const errors: FieldErrors = {};
if (!data.name.trim()) errors.name = FIELD_ERRORS.name;
const email = data.email.trim();
if (email && !EMAIL_PATTERN.test(email)) errors.email = FIELD_ERRORS.email;
if (!data.message.trim()) errors.message = FIELD_ERRORS.message;
return errors;
}
/** Mesmo texto para o WhatsApp e para o e-mail; a linha do e-mail só entra se a pessoa informou um. */
function buildProjectMessage(data: {
name: string;
email: string;
subject: string;
message: string;
}) {
const email = data.email.trim();
return `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${data.name.trim()}\n${email ? `E-mail: ${email}\n` : ""}Tipo de projeto: ${data.subject}\n\nContexto, objetivo e prazo:\n${data.message.trim()}\n\nContato iniciado pelo site evandro.dev.br.`;
}
function buildMailtoUrl(message: string) {
return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent("Projeto pelo site")}&body=${encodeURIComponent(message)}`;
}
const fieldClassName =
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 !font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] aria-[invalid=true]:border-[#ff8a80]";
const labelClassName =
"block font-display text-[15px] font-medium text-white/85";
const errorClassName =
"mt-sp-2 font-display text-[15px] leading-6 text-[#ff9d94]";
/** Mensagem dos links diretos de quem está sem JavaScript (o formulário não funciona sem ele). */
const DIRECT_MESSAGE =
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.";
/** Abre a URL em outra aba, ou devolve null se não deu (sem `window.open`, bloqueado ou com erro). */
function tryOpenTab(url: string): Window | null {
try {
return window.open(url, "_blank");
} catch {
return null;
}
}
/** Corta o vínculo da aba aberta com esta página; devolve false se o navegador não deixou. */
function detachOpener(tab: Window): boolean {
try {
tab.opener = null;
return true;
} catch {
return false;
}
}
/**
* Abre o WhatsApp em outra aba. Sem "noopener" na chamada, porque com ele o window.open sempre devolve null
* e não dá para saber se abriu; o vínculo com a página (`opener`) é cortado logo em seguida. A página vai para o
* WhatsApp na própria aba quando não há aba nova (pop-up bloqueado, navegador interno de aplicativo, sem
* `window.open`) e também quando o vínculo não pôde ser cortado: nesse caso a aba aberta é fechada, para
* nenhuma aba do WhatsApp ficar com acesso a esta página.
*/
function openWhatsApp(url: string) {
const tab = tryOpenTab(url);
if (tab) {
if (detachOpener(tab)) return;
tab.close();
}
window.location.href = url;
}
/** Atributos que o campo entrega ao controle (input, textarea ou select) para o erro ser anunciado. */
interface ControlProps {
id: string;
"aria-invalid"?: true;
"aria-describedby"?: string;
}
/** Rótulo, controle e mensagem de erro de um campo; o controle recebe o id e os atributos de acessibilidade. */
function Field({
id,
label,
error,
describedBy,
children,
}: {
id: string;
label: string;
error?: string;
/** Outros textos que descrevem o campo, depois do erro. */
describedBy?: string;
children: (control: ControlProps) => ReactNode;
}) {
const errorId = `${id}-error`;
const description = [error ? errorId : "", describedBy ?? ""]
.filter(Boolean)
.join(" ");
return (
<div>
<label htmlFor={id} className={labelClassName}>
{label}
</label>
{children({
id,
"aria-invalid": error ? true : undefined,
"aria-describedby": description || undefined,
})}
{error && (
<p id={errorId} className={errorClassName}>
{error}
</p>
)}
</div>
);
}
export default function Contact() {
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
useEffect(() => {
setWhatsAppDisplay(getWhatsAppDisplayNumber());
}, []);
const [formData, setFormData] = useState({
name: "",
email: "",
subject: DEFAULT_CONTACT_SUBJECT as string,
message: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
// A oferta que a pessoa escolheu na home (botão ou `?tipo=`); some se ela trocar o tipo no próprio formulário.
const [chosenOffer, setChosenOffer] = useState<ContactKind | null>(null);
// O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra
// página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada.
useEffect(() => {
const choose = (kind: string | null | undefined) => {
const subject = contactSubjectForKind(kind);
if (!subject) return;
setFormData((current) => ({ ...current, subject }));
setChosenOffer(contactKindForSubject(subject) ?? null);
};
choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM));
const onChoose = (event: Event) =>
choose((event as CustomEvent<string | undefined>).detail);
window.addEventListener(CONTACT_KIND_EVENT, onChoose);
return () => window.removeEventListener(CONTACT_KIND_EVENT, onChoose);
}, []);
const updateField = (field: ContactField | "subject", value: string) => {
setFormData((current) => ({ ...current, [field]: value }));
if (field === "subject") setChosenOffer(null);
if (field !== "subject" && errors[field]) {
setErrors((current) => ({ ...current, [field]: undefined }));
}
};
/** Mostra os erros, leva o foco ao primeiro campo inválido e diz se há algum. */
const hasInvalidField = (form: HTMLFormElement | null) => {
const found = validate(formData);
setErrors(found);
const firstInvalid = FIELD_ORDER.find((field) => found[field]);
if (firstInvalid) {
form?.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus();
}
return firstInvalid !== undefined;
};
// O link direto de WhatsApp leva o tipo escolhido (o nome da oferta, em frase) na mensagem; sem oferta, fala de "um projeto".
const selectedOffer = contactKindForSubject(formData.subject);
const directSubject = selectedOffer
? OFFER_SUBJECTS[selectedOffer]
: undefined;
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (hasInvalidField(e.currentTarget)) return;
openWhatsApp(buildWhatsAppUrl(buildProjectMessage(formData)));
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
};
/** O rascunho por e-mail passa pela mesma validação do WhatsApp: sem ela o e-mail sairia vazio. */
const handleEmailClick = (e: MouseEvent<HTMLAnchorElement>) => {
if (hasInvalidField(e.currentTarget.closest("form"))) {
e.preventDefault();
return;
}
trackEmailClick({ placement: "contact_form" });
};
return (
<section
id="contact"
aria-labelledby="contact-title"
className="ds-section ds-section-dark"
>
<div className="ds-section-shell grid items-start gap-sp-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-16">
<div className="min-w-0">
<p className="ds-eyebrow">Contato</p>
<h2
id="contact-title"
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
>
Vamos conversar sobre o que sua empresa precisa.
</h2>
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
Conte o que você quer resolver. Pode ser um projeto novo, uma
melhoria ou uma integração. Não precisa chegar com a solução
definida.
</p>
<div className="mt-sp-8 border-l-2 border-[var(--slash)] pl-sp-5">
<h3 className="font-display text-lg font-semibold text-white">
Como a conversa começa.
</h3>
<p className="mt-sp-2 max-w-[44ch] font-display text-[15px] leading-7 text-white/65">
Sou Evandro. A primeira conversa é para entender sua necessidade;
escopo, prazo e proposta vêm depois.
</p>
</div>
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
<WhatsAppLink
placement="contact_direct"
service={directSubject}
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[var(--whatsapp)]/10 text-[var(--whatsapp)]">
<WhatsAppMark className="h-6 w-6" />
</span>
<span className="min-w-0 flex-1 font-display">
<span className="block text-[15px] font-semibold text-white group-hover:text-[var(--whatsapp)]">
Prefere conversar direto?
</span>
<span className="mt-1 block text-[15px] text-white/65">
WhatsApp · {whatsAppDisplay}
</span>
</span>
<ArrowUpRight
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[var(--whatsapp)]"
aria-hidden="true"
/>
</WhatsAppLink>
<a
href={`mailto:${CONTACT_EMAIL}`}
onClick={() => trackEmailClick({ placement: "contact_direct" })}
className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
>
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 break-all font-display text-[15px]">
{CONTACT_EMAIL}
</span>
<ArrowUpRight
className="ml-auto h-4 w-4 shrink-0"
aria-hidden="true"
/>
</a>
</div>
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] leading-6 text-white/55">
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
</p>
</div>
<form
id={CONTACT_FORM_ID}
onSubmit={handleSubmit}
noValidate
aria-labelledby="contact-form-title"
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
>
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--whatsapp)] text-[#071b10]">
<WhatsAppMark className="h-7 w-7" />
</span>
<div>
<h3
id="contact-form-title"
className="font-display text-xl font-semibold leading-7 text-white"
>
Prepare sua mensagem
</h3>
<p className="mt-1 font-display text-[15px] leading-6 text-white/65">
Você revisa no WhatsApp antes de enviar.
</p>
</div>
</div>
{/* js-only: app/layout.tsx esconde o que depende de JavaScript quando ele está desligado, e o <noscript> abaixo entra no lugar */}
<div className="js-only space-y-sp-5">
<div className="grid gap-sp-5 xl:grid-cols-2">
<Field id="contact-name" label="Seu nome" error={errors.name}>
{(control) => (
<input
{...control}
name="name"
required
type="text"
autoComplete="name"
placeholder="Como posso te chamar?"
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
className={fieldClassName}
/>
)}
</Field>
<Field
id="contact-email"
label="E-mail (opcional)"
error={errors.email}
>
{(control) => (
<input
{...control}
name="email"
type="email"
autoComplete="email"
placeholder="[email protected]"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
className={fieldClassName}
/>
)}
</Field>
</div>
<Field id="contact-subject" label="Tipo de projeto">
{(control) => (
<select
{...control}
name="subject"
value={formData.subject}
onChange={(e) => updateField("subject", e.target.value)}
className={`${fieldClassName} [color-scheme:dark]`}
>
{CONTACT_SUBJECT_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)}
</Field>
{/* <output> (role status): o texto muda quando uma oferta da home é escolhida, e o leitor de tela o anuncia; vazio, não ocupa espaço */}
<output className="block !mt-sp-2 font-display text-[15px] leading-6 text-white/85 empty:!mt-0">
{chosenOffer && (
<>
Você escolheu:{" "}
<strong className="font-semibold text-white">
{CONTACT_KINDS[chosenOffer]}
</strong>
</>
)}
</output>
<Field
id="contact-message"
label="Contexto do projeto"
error={errors.message}
describedBy="contact-privacy-note"
>
{(control) => (
<textarea
{...control}
name="message"
required
rows={4}
autoComplete="off"
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
value={formData.message}
onChange={(e) => updateField("message", e.target.value)}
className={`${fieldClassName} min-h-32 resize-y`}
/>
)}
</Field>
<button
type="submit"
className="cv-btn cv-fill flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[var(--whatsapp)] px-sp-4 py-sp-4 text-[#071b10] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--whatsapp)] motion-reduce:transition-none"
>
<WhatsAppMark className="h-6 w-6 shrink-0" />
Continuar no WhatsApp
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
</button>
<p className="text-center font-display text-[15px] leading-6 text-white/70">
<a
href={buildMailtoUrl(buildProjectMessage(formData))}
onClick={handleEmailClick}
className="inline-flex min-h-11 items-center underline underline-offset-4 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
>
Prefere e-mail? Enviar por e-mail
</a>
</p>
<p
id="contact-privacy-note"
className="font-display text-[15px] leading-6 text-white/55"
>
Nada é enviado automaticamente. O WhatsApp abre com a mensagem
para você revisar. Não inclua senhas nem dados pessoais de
clientes.
</p>
</div>
{/* Sem JavaScript o formulário não envia nada: no lugar do botão, links diretos que funcionam sem script. */}
<noscript>
<p className="font-display text-base leading-7 text-white/80">
Para enviar sua mensagem, use um destes contatos:
</p>
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
<a
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[var(--whatsapp)] px-sp-4 font-semibold text-[#071b10] no-underline"
>
Chamar no WhatsApp
</a>
<a
href={`mailto:${CONTACT_EMAIL}`}
className="inline-flex min-h-12 items-center justify-center break-all rounded-lg border border-white/25 px-sp-4 font-medium text-white no-underline"
>
{CONTACT_EMAIL}
</a>
</p>
</noscript>
</form>
</div>
</section>
);
}
@@ -1,108 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Contact from "./Contact";
/**
* Testes de caminhos-base de openWhatsApp, dentro de Contact: a aba abriu (o vínculo com a página é cortado),
* o navegador bloqueou (devolve null ou undefined e a própria página vai para o WhatsApp) e os argumentos da chamada.
* O envio com campos inválidos não abre nada.
*/
const NAVIGATION_ERROR = "Not implemented: navigation";
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
delete window["ga-disable-G-QTT9QT0T3S"];
window.gtag = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
async function fillValidForm() {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
return user;
}
const submit = () => screen.getByRole("button", { name: /whatsapp/i });
function navigationAttempts(errors: jest.SpyInstance) {
return errors.mock.calls.filter(([error]) =>
String(error).includes(NAVIGATION_ERROR),
).length;
}
describe("Contact: abertura do WhatsApp", () => {
it("path: the tab opened -> the opener link is cut and this page stays where it is", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
const opened = { opener: { stillLinked: true } };
jest
.spyOn(window, "open")
.mockImplementation(() => opened as unknown as Window);
const user = await fillValidForm();
await user.click(submit());
expect(opened.opener).toBeNull();
expect(navigationAttempts(errors)).toBe(0);
});
it("path: the call asks for a new tab with the wa.me link and no third argument (noopener would hide the result)", async () => {
jest.spyOn(window, "open").mockImplementation(() => null);
jest.spyOn(console, "error").mockImplementation(() => {});
const user = await fillValidForm();
await user.click(submit());
expect(window.open).toHaveBeenCalledTimes(1);
const args = jest.mocked(window.open).mock.calls[0];
expect(args).toHaveLength(2);
expect(String(args[0])).toMatch(/^https:\/\/wa\.me\/\d+\?text=/);
expect(args[1]).toBe("_blank");
});
it("path: the browser blocks the tab with null -> the page itself goes to WhatsApp, once", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.spyOn(window, "open").mockImplementation(() => null);
const user = await fillValidForm();
await user.click(submit());
expect(navigationAttempts(errors)).toBe(1);
});
it("path: the browser answers undefined (some in-app browsers) -> the page itself goes to WhatsApp", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest
.spyOn(window, "open")
.mockImplementation(() => undefined as unknown as Window);
const user = await fillValidForm();
await user.click(submit());
expect(navigationAttempts(errors)).toBe(1);
});
it("path: the form is invalid -> no tab, no navigation and no metric", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.spyOn(window, "open").mockImplementation(() => null);
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.click(submit());
expect(window.open).not.toHaveBeenCalled();
expect(navigationAttempts(errors)).toBe(0);
expect(window.gtag).not.toHaveBeenCalled();
});
});
@@ -1,195 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import ContactOfferLink from "./ContactOfferLink";
import { focusFragment, onFragmentClick } from "./cinematic/fragment-nav";
import { CONTACT_KIND_EVENT } from "./contact-subject";
/**
* Testes de caminhos-base de ContactOfferLink (complemento de ContactOfferLink.test.tsx).
*
* onClick (CC 6): fora da home | sem #contact-form | clique já tratado pelo gerenciador de âncoras | tecla modificadora ou
* botão que não é o principal | rolagem suave | rolagem em salto (movimento reduzido). Os seis caminhos já estão
* em ContactOfferLink.test.tsx; aqui entram o que faltava:
* - o gerenciador de âncoras REAL (fragment-nav.onFragmentClick) tratando o clique, em vez de um ouvinte avulso;
* - Alt como tecla modificadora;
* - showContactInAddress (CC 2): o endereço já é /#contact-form, então não há replaceState; e o `history.state` do roteador
* do Next sobrevive ao replaceState.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const scrollIntoView = jest.fn();
const scrollTo = jest.fn();
let form: HTMLElement;
let replaceState: jest.SpyInstance;
const originalMatchMedia = window.matchMedia;
const originalScrollTo = window.scrollTo;
beforeEach(() => {
scrollIntoView.mockReset();
scrollTo.mockReset();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
window.scrollTo = scrollTo as unknown as typeof window.scrollTo;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
window.history.replaceState(null, "", "/");
replaceState = jest.spyOn(window.history, "replaceState");
form = document.createElement("section");
form.id = "contact-form";
document.body.append(form);
});
afterEach(() => {
replaceState.mockRestore();
form.remove();
window.matchMedia = originalMatchMedia;
window.scrollTo = originalScrollTo;
window.history.replaceState(null, "", "/");
});
/** O que useCinematicScroll faz com um clique numa âncora: cancela, empurra o hash, rola e dá o foco. */
function installAnchorManager() {
return onFragmentClick((hit, event) => {
event.preventDefault();
if (window.location.hash !== hit.hash)
window.history.pushState(null, "", hit.hash);
window.scrollTo({ top: 1234, behavior: "smooth" });
focusFragment(hit.target);
});
}
describe("ContactOfferLink: gerenciador de âncoras real", () => {
it("path: the manager takes the click -> its scroll is the only one, the offer cleans the old ?tipo= and still tells the form", () => {
window.history.replaceState(null, "", "/?tipo=site");
replaceState.mockClear();
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
window.addEventListener(CONTACT_KIND_EVENT, listener);
const stop = installAnchorManager();
try {
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
const entries = window.history.length;
expect(
fireEvent.click(screen.getByRole("link", { name: "Começar" })),
).toBe(false);
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(form).toHaveFocus();
expect(heard).toEqual(["sistema"]);
// o gerenciador abriu uma entrada de histórico (/?tipo=site#contact-form); a oferta só a reescreve
expect(window.history.length).toBe(entries + 1);
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("#contact-form");
expect(replaceState).toHaveBeenCalledTimes(1);
} finally {
stop();
window.removeEventListener(CONTACT_KIND_EVENT, listener);
}
});
it("path: the manager takes the click and the address is already /#contact-form -> no replaceState at all", () => {
window.history.replaceState(null, "", "/#contact-form");
replaceState.mockClear();
const stop = installAnchorManager();
try {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(replaceState).not.toHaveBeenCalled();
expect(window.location.pathname + window.location.hash).toBe(
"/#contact-form",
);
} finally {
stop();
}
});
it("path: no manager mounted -> the offer scrolls and focuses by itself, and a second click scrolls again", () => {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
const link = screen.getByRole("link", { name: "Começar" });
expect(fireEvent.click(link)).toBe(false);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(form).toHaveFocus();
expect(window.location.hash).toBe("#contact-form");
// o endereço já é /#contact-form (o navegador não rolaria): a oferta rola de novo mesmo assim, sem tocar no endereço
(document.activeElement as HTMLElement).blur();
replaceState.mockClear();
expect(fireEvent.click(link)).toBe(false);
expect(scrollIntoView).toHaveBeenCalledTimes(2);
expect(form).toHaveFocus();
expect(replaceState).not.toHaveBeenCalled();
});
});
describe("ContactOfferLink: teclas e histórico", () => {
it("path: Alt held down -> not taken over (the browser keeps its own behavior)", () => {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
expect(
fireEvent.click(screen.getByRole("link", { name: "Começar" }), {
altKey: true,
}),
).toBe(true);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
expect(form).not.toHaveFocus();
});
it("path: a modified click still tells the form which type was chosen (the form may be in the same tab)", () => {
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
window.addEventListener(CONTACT_KIND_EVENT, listener);
try {
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }), {
ctrlKey: true,
});
expect(heard).toEqual(["revisao"]);
} finally {
window.removeEventListener(CONTACT_KIND_EVENT, listener);
}
});
it("path: replaceState keeps the history.state of the router (the Next router stores its tree there)", () => {
const routerState = { __NA: true, tree: ["", {}] };
window.history.replaceState(routerState, "", "/?tipo=site#contact-form");
replaceState.mockClear();
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(replaceState).toHaveBeenCalledWith(
routerState,
"",
"/#contact-form",
);
expect(window.history.state).toEqual(routerState);
});
});
-205
View File
@@ -1,205 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import ContactOfferLink from "./ContactOfferLink";
import { CONTACT_KIND_EVENT } from "./contact-subject";
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
describe("ContactOfferLink", () => {
it("links to the form with the type in the address, and prefetches nothing until hover or focus", () => {
render(
<ContactOfferLink kind="sistema" className="botao">
Começar pelo sistema
</ContactOfferLink>,
);
const link = screen.getByRole("link", { name: "Começar pelo sistema" });
expect(link).toHaveAttribute("href", "/?tipo=sistema#contact-form");
expect(link).toHaveClass("botao");
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
});
it("tells the form which type was chosen, through a window event, when it is clicked", () => {
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
window.addEventListener(CONTACT_KIND_EVENT, listener);
try {
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(heard).toEqual(["revisao"]);
} finally {
window.removeEventListener(CONTACT_KIND_EVENT, listener);
}
});
it("still tells the form when another handler already cancelled the click (the home scroll manager does that to scroll by itself)", () => {
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
const cancel = (event: Event) => event.preventDefault();
window.addEventListener(CONTACT_KIND_EVENT, listener);
document.addEventListener("click", cancel, true);
try {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(heard).toEqual(["site"]);
} finally {
window.removeEventListener(CONTACT_KIND_EVENT, listener);
document.removeEventListener("click", cancel, true);
}
});
});
describe("ContactOfferLink: rolagem até o formulário", () => {
const scrollIntoView = jest.fn();
const original = window.matchMedia;
let form: HTMLElement;
const setReducedMotion = (reduced: boolean) => {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: reduced && query === "(prefers-reduced-motion: reduce)",
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
};
beforeEach(() => {
scrollIntoView.mockReset();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
window.history.replaceState(null, "", "/");
setReducedMotion(false);
form = document.createElement("section");
form.id = "contact-form";
document.body.append(form);
});
afterEach(() => {
form.remove();
window.matchMedia = original;
window.history.replaceState(null, "", "/");
});
it("cancels the navigation on the home, rolls to #contact-form, focuses the form and leaves the address as /#contact-form", () => {
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
window.addEventListener(CONTACT_KIND_EVENT, listener);
const pushState = jest.spyOn(window.history, "pushState");
try {
render(
<ContactOfferLink kind="site">Começar pelo site</ContactOfferLink>,
);
const link = screen.getByRole("link", { name: "Começar pelo site" });
// fireEvent devolve false quando alguém chamou preventDefault: o Link do Next não navega
expect(fireEvent.click(link)).toBe(false);
expect(heard).toEqual(["site"]);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "smooth",
block: "start",
});
expect(form).toHaveFocus();
expect(window.location.pathname + window.location.search).toBe("/");
expect(window.location.hash).toBe("#contact-form");
expect(pushState).not.toHaveBeenCalled();
} finally {
pushState.mockRestore();
window.removeEventListener(CONTACT_KIND_EVENT, listener);
}
});
it("jumps without animation when the visitor asks for reduced motion", () => {
setReducedMotion(true);
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "instant",
block: "start",
});
});
it("drops the ?tipo= left by an earlier offer or by the arrival from another page, and keeps the other parameters", () => {
window.history.replaceState(
null,
"",
"/?utm_source=ads&tipo=site#contact-form",
);
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(window.location.search).toBe("?utm_source=ads");
expect(window.location.hash).toBe("#contact-form");
});
it("leaves the click alone when the scroll manager already took it (no second scroll), still cleaning the address", () => {
window.history.replaceState(null, "", "/?tipo=site#contact-form");
const cancel = (event: Event) => event.preventDefault();
document.addEventListener("click", cancel, true);
try {
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("#contact-form");
} finally {
document.removeEventListener("click", cancel, true);
}
});
it("does not take over a click with a modifier key (new tab) or the middle button", () => {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
const link = screen.getByRole("link", { name: "Começar" });
expect(fireEvent.click(link, { ctrlKey: true })).toBe(true);
expect(fireEvent.click(link, { metaKey: true })).toBe(true);
expect(fireEvent.click(link, { shiftKey: true })).toBe(true);
expect(fireEvent.click(link, { button: 1 })).toBe(true);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.hash).toBe("");
});
it("does not cancel anything outside the home: the Link navigates to /?tipo=…#contact-form", () => {
window.history.replaceState(null, "", "/privacidade");
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
const link = screen.getByRole("link", { name: "Começar" });
expect(link).toHaveAttribute("href", "/?tipo=revisao#contact-form");
expect(fireEvent.click(link)).toBe(true);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.pathname).toBe("/privacidade");
expect(window.location.hash).toBe("");
});
it("falls back to the Link navigation when the page has no #contact-form", () => {
form.remove();
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
expect(fireEvent.click(screen.getByRole("link", { name: "Começar" }))).toBe(
true,
);
expect(scrollIntoView).not.toHaveBeenCalled();
document.body.append(form);
});
});
-84
View File
@@ -1,84 +0,0 @@
"use client";
import type { MouseEvent, ReactNode } from "react";
import { prefersReducedMotion } from "@/lib/device";
import {
elementForHash,
focusFragment,
isPlainPrimaryClick,
} from "./cinematic/fragment-nav";
import {
CONTACT_FORM_HASH,
CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM,
type ContactKind,
contactKindHref,
} from "./contact-subject";
import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* Deixa o endereço da home com o fragmento do cartão do formulário (`/#contact-form`) e sem o `?tipo=` que uma oferta
* anterior ou a chegada de outra página deixou (o tipo já está no formulário). `replaceState`, e não uma navegação do
* Next: nada recarrega, e as âncoras seguintes (cabeçalho, rodapé) continuam a rolar a página.
*/
function showContactInAddress() {
const url = new URL(window.location.href);
url.searchParams.delete(CONTACT_KIND_PARAM);
url.hash = CONTACT_FORM_HASH;
const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current)
window.history.replaceState(window.history.state, "", next);
}
/**
* Botão de uma oferta da home: leva ao cartão do formulário de contato (`#contact-form`, com o campo do tipo de projeto
* à vista, também no celular) com o tipo já escolhido. Na própria home o tipo segue por evento e a página rola até o
* cartão (sem navegar pelo Next); o `href` com `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
*
* O normal é o gerenciador de âncoras (fragment-nav + useCinematicScroll) tratar o clique antes deste código, do
* mesmo jeito que trata o link do cabeçalho: rola (com movimento reduzido, em salto), dá o foco ao formulário e
* cancela o clique. Quando ele não o trata (ainda não montou, por exemplo), este botão cancela a navegação, rola e dá
* o foco por conta própria. Em outra página, nada é cancelado: o Link navega para `/?tipo=…#contact-form`.
*/
export default function ContactOfferLink({
kind,
className,
children,
}: {
kind: ContactKind;
className?: string;
children: ReactNode;
}) {
const onClick = (event: MouseEvent<HTMLAnchorElement>) => {
window.dispatchEvent(
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
);
if (window.location.pathname !== "/") return;
const form = elementForHash(CONTACT_FORM_HASH);
if (!form) return;
if (event.defaultPrevented) {
showContactInAddress();
return;
}
// aba nova, clique com tecla modificadora ou botão do meio: o navegador cuida, com o `?tipo=` do href
if (!isPlainPrimaryClick(event.nativeEvent)) return;
event.preventDefault();
showContactInAddress();
form.scrollIntoView({
behavior: prefersReducedMotion() ? "instant" : "smooth",
block: "start",
});
focusFragment(form);
};
return (
<HoverPrefetchLink
href={contactKindHref(kind)}
className={className}
onClick={onClick}
>
{children}
</HoverPrefetchLink>
);
}
-90
View File
@@ -1,90 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import { Faq } from "./Faq";
import HomeFaq from "./HomeFaq";
const items = [
{ question: "Primeira pergunta?", answer: "Primeira resposta." },
{
question: "Segunda pergunta?",
answer: "Segunda resposta.",
link: { href: "/ia", label: "Ver a revisão" },
},
{ question: "Sem resposta ainda?", answer: " " },
];
describe("Faq", () => {
it("path: answered questions -> one closed accordion item per question, under a level 2 heading", () => {
const { container } = render(<Faq items={items} />);
expect(
screen.getByRole("heading", { level: 2, name: "Perguntas frequentes" }),
).toBeInTheDocument();
const summaries = Array.from(container.querySelectorAll("summary")).map(
(summary) => summary.textContent?.replace("+", "").trim(),
);
expect(summaries).toEqual(["Primeira pergunta?", "Segunda pergunta?"]);
for (const details of container.querySelectorAll("details")) {
expect(details.open).toBe(false);
}
});
it("path: a question with no answer -> stays out of the page, like on the home", () => {
render(<Faq items={items} />);
expect(screen.queryByText("Sem resposta ainda?")).toBeNull();
});
it("path: a question with a link -> the link sits inside its answer", () => {
render(<Faq items={items} />);
const details = screen
.getByText("Segunda pergunta?")
.closest("details") as HTMLElement;
expect(within(details).getByText("Segunda resposta.")).toBeInTheDocument();
expect(
within(details).getByRole("link", { name: "Ver a revisão" }),
).toHaveAttribute("href", "/ia");
});
it("path: custom title and anchor -> the heading and the section follow them", () => {
const { container } = render(
<Faq items={items} id="antes" title="Antes de contratar" />,
);
const heading = screen.getByRole("heading", { name: "Antes de contratar" });
expect(heading).toHaveAttribute("id", "antes-title");
expect(container.querySelector("section")).toHaveAttribute("id", "antes");
expect(container.querySelector("section")).toHaveAttribute(
"aria-labelledby",
"antes-title",
);
});
it("path: no items, or none with an answer -> renders nothing, not even the heading", () => {
const empty = render(<Faq />);
expect(empty.container).toBeEmptyDOMElement();
empty.unmount();
const unanswered = render(
<Faq items={[{ question: "Pergunta?", answer: "" }]} />,
);
expect(unanswered.container).toBeEmptyDOMElement();
});
it("path: same pattern as the home FAQ -> same summary and details classes", () => {
const shared = render(<Faq items={items} />);
const home = render(
<HomeFaq
items={[{ question: "Primeira pergunta?", answer: "Resposta." }]}
/>,
);
const classOf = (container: HTMLElement, selector: string) =>
container.querySelector(selector)?.className;
for (const selector of ["details", "summary", "summary > span"]) {
expect(classOf(shared.container, selector)).toBe(
classOf(home.container, selector),
);
}
});
});
-73
View File
@@ -1,73 +0,0 @@
import { textLink } from "./button-styles";
import HoverPrefetchLink from "./HoverPrefetchLink";
export interface FaqEntry {
question: string;
/** Resposta vazia = pergunta preparada, que ainda não é exibida. */
answer: string;
link?: { href: string; label: string };
}
/**
* Perguntas frequentes em acordeão, um formato só no site: pergunta na linha, resposta ao abrir, "+" que gira.
* A home (HomeFaq), as páginas de serviço e a de serviços usam este componente. Só aparecem as perguntas com resposta; sem nenhuma, a seção não existe.
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que o dono não tenha dito.
*/
export function Faq({
items,
id = "faq",
title = "Perguntas frequentes",
eyebrow = "Dúvidas comuns",
}: {
items?: readonly FaqEntry[];
/** Âncora da seção; o título leva `${id}-title`. */
id?: string;
title?: string;
eyebrow?: string;
}) {
const answered = (items ?? []).filter((item) => item.answer.trim() !== "");
if (answered.length === 0) return null;
return (
<section id={id} aria-labelledby={`${id}-title`} className="ds-section">
<div className="ds-section-shell">
<p className="ds-eyebrow">{eyebrow}</p>
<h2
id={`${id}-title`}
className="ds-section-title max-w-[22ch] text-balance"
>
{title}
</h2>
<div className="mt-sp-8 max-w-[860px]">
{answered.map((item) => (
<details
key={item.question}
className="ds-faq-item group border-t border-[var(--border)] last:border-b"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
{item.question}
<span
aria-hidden="true"
className="shrink-0 font-mono text-xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
>
+
</span>
</summary>
<div className="pb-sp-5">
<p className="ds-copy max-w-[65ch]">{item.answer}</p>
{item.link ? (
<HoverPrefetchLink
href={item.link.href}
className={`${textLink} mt-sp-2 text-[var(--ink-80)]`}
>
{item.link.label}
</HoverPrefetchLink>
) : null}
</div>
</details>
))}
</div>
</div>
</section>
);
}
-41
View File
@@ -1,41 +0,0 @@
"use client";
import { useSyncExternalStore } from "react";
import { textLink } from "./button-styles";
import { isFiscalNoticeActive } from "./fiscal-notice";
import HoverPrefetchLink from "./HoverPrefetchLink";
/** A data não muda enquanto a página está aberta: nada a observar. */
const subscribe = () => () => {};
/**
* Faixa datada da nota fiscal, acima dos cartões da home. Quem a coloca na página (WorkModalities) só a renderiza dentro
* do prazo; aqui o navegador confere a data de novo, porque a home é gerada no build: depois do prazo ela some sem um
* build novo. Na hidratação vale o valor do servidor (visível), e só depois a data do navegador: sem aviso de hidratação.
*/
export function FiscalNotice({ href }: Readonly<{ href: string }>) {
const active = useSyncExternalStore(
subscribe,
() => isFiscalNoticeActive(new Date()),
() => true,
);
if (!active) return null;
return (
<div className="mb-sp-6 flex flex-col gap-sp-1 border-y border-[var(--border)] py-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
<strong className="font-semibold text-[var(--ink)]">
Seu sistema emite nota fiscal?
</strong>{" "}
Os optantes do Simples Nacional entram na reforma tributária em 1º de
janeiro de 2027.
</p>
<HoverPrefetchLink
href={href}
className={`${textLink} shrink-0 self-start text-[var(--ink-80)]`}
>
Ver o que muda
</HoverPrefetchLink>
</div>
);
}
@@ -1,263 +0,0 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { resetNavigation, setNavigation } from "@/__mocks__/next/navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Testes de caminhos-base de FloatingWhatsApp (complemento de FloatingWhatsApp.test.tsx):
* cada convite (botão do hero, seção de contato) sozinho na página, relatórios parciais do observador,
* desconexão ao desmontar e a convivência com a barra de cookies.
* A regra "só abaixo de 768 px" é CSS (md:hidden); o jsdom não avalia media queries, então só a classe é conferida.
*/
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
const instances: FakeObserver[] = [];
const originalObserver = global.IntersectionObserver;
class FakeObserver {
observed: Element[] = [];
disconnect = jest.fn();
constructor(private callback: IntersectionObserverCallback) {
instances.push(this);
}
observe = jest.fn((target: Element) => {
this.observed.push(target);
});
unobserve = jest.fn();
takeRecords = () => [];
report(entries: Entry[]) {
act(() =>
this.callback(entries as IntersectionObserverEntry[], this as never),
);
}
}
const hero = () => document.getElementById("hero-contact-cta") as Element;
const contact = () => document.getElementById("contact") as Element;
const floatingLink = () =>
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
beforeEach(() => {
resetNavigation();
instances.length = 0;
global.IntersectionObserver =
FakeObserver as unknown as typeof IntersectionObserver;
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
window.gtag = jest.fn();
});
afterEach(() => {
global.IntersectionObserver = originalObserver;
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
document.documentElement.style.removeProperty("--cookie-bar-height");
document.documentElement.removeAttribute("data-cookie-bar");
});
function renderBoth() {
return render(
<>
<a id="hero-contact-cta" href="/#contact">
Conversar
</a>
<section id="contact" aria-label="Contato" />
<FloatingWhatsApp />
</>,
);
}
describe("FloatingWhatsApp: convites presentes na página", () => {
it("path: only the hero button exists -> observes just it and follows its visibility", () => {
render(
<>
<a id="hero-contact-cta" href="/#contact">
Conversar sobre meu projeto
</a>
<FloatingWhatsApp />
</>,
);
expect(instances).toHaveLength(1);
expect(instances[0].observed).toEqual([hero()]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: hero(), isIntersecting: true }]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: only the contact section exists -> observes just it and follows its visibility", () => {
render(
<>
<section id="contact" aria-label="Contato" />
<FloatingWhatsApp />
</>,
);
expect(instances[0].observed).toEqual([contact()]);
instances[0].report([{ target: contact(), isIntersecting: true }]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: neither invite exists -> no observer is created and the button shows at once", () => {
render(<FloatingWhatsApp />);
expect(instances).toHaveLength(0);
expect(floatingLink()).toBeInTheDocument();
});
it("path: both invites exist -> both are observed", () => {
renderBoth();
expect(instances[0].observed).toEqual([hero(), contact()]);
});
});
describe("FloatingWhatsApp: relatórios do observador", () => {
it("path: a later report about one invite keeps what was said about the other", () => {
renderBoth();
const [observer] = instances;
observer.report([
{ target: hero(), isIntersecting: true },
{ target: contact(), isIntersecting: false },
]);
expect(floatingLink()).toBeNull();
// só o contato é reportado de novo; o hero continua à vista
observer.report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeNull();
observer.report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: both invites on screen at once -> hidden until both leave", () => {
renderBoth();
const [observer] = instances;
observer.report([
{ target: hero(), isIntersecting: true },
{ target: contact(), isIntersecting: true },
]);
expect(floatingLink()).toBeNull();
observer.report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeNull();
observer.report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: an empty report -> nothing is visible, so the button shows", () => {
renderBoth();
instances[0].report([]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: unmount -> the observer is disconnected", () => {
const { unmount } = renderBoth();
expect(instances[0].disconnect).not.toHaveBeenCalled();
unmount();
expect(instances[0].disconnect).toHaveBeenCalledTimes(1);
});
});
describe("FloatingWhatsApp: conteúdo e cliques", () => {
it("path: the shortcut is CSS-limited to small screens and sized as a 56px target", () => {
render(<FloatingWhatsApp />);
const link = floatingLink();
expect(link).toHaveClass("h-14", "w-14");
expect(link?.parentElement).toHaveClass("md:hidden", "fixed", "right-sp-4");
});
it("path: click on the home reports the home subject as the service (the central navigation mock opens on '/')", () => {
render(<FloatingWhatsApp />);
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ service: HOME_SUBJECT, placement: "floating" }),
);
});
it("path: click on a page without a subject reports 'Contato' as the service", () => {
setNavigation({ pathname: "/privacidade" });
render(<FloatingWhatsApp />);
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ service: "Contato", placement: "floating" }),
);
});
});
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
it("path: the bar marks <html> while open, which hides the button by CSS, and drops the mark once a choice is made", async () => {
const user = userEvent.setup();
localStorage.clear();
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => 72,
});
try {
render(
<>
<GoogleAnalytics />
<FloatingWhatsApp />
</>,
);
const refuse = await screen.findByRole("button", { name: "Recusar" });
// a barra publica a altura (o menu do celular a usa) e marca o <html>; o botão flutuante some por CSS com a marca
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("72px");
expect(document.documentElement).toHaveAttribute(
"data-cookie-bar",
"open",
);
expect(floatingLink()?.parentElement).toHaveClass(
"[[data-cookie-bar=open]_&]:hidden",
);
await user.click(refuse);
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
expect(floatingLink()).toBeInTheDocument();
} finally {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
}
});
});
@@ -1,137 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import type { WhatsAppContexts } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp";
/**
* O botão vive em app/layout.tsx e vale em todo o site: a mensagem acompanha a página aberta, e a observação
* do hero e do contato (que só existem na home) recomeça a cada troca de página.
*/
const contexts: WhatsAppContexts = {
// o whatsappSubject do serviço (lib/site-content.ts): uma frase em minúsculas, que cabe em "Quero conversar sobre {assunto}."
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
cases: { "sev-modaintima": "SEV Moda Íntima" },
};
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
const instances: FakeObserver[] = [];
const originalObserver = global.IntersectionObserver;
class FakeObserver {
disconnect = jest.fn();
constructor(private callback: IntersectionObserverCallback) {
instances.push(this);
}
observe = jest.fn();
unobserve = jest.fn();
takeRecords = () => [];
report(entries: Entry[]) {
act(() =>
this.callback(entries as IntersectionObserverEntry[], this as never),
);
}
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
const messageOf = (link: HTMLElement | null) =>
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
beforeEach(() => {
instances.length = 0;
global.IntersectionObserver =
FakeObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
global.IntersectionObserver = originalObserver;
document.getElementById("hero-contact-cta")?.remove();
document.getElementById("contact")?.remove();
});
describe("message by route", () => {
it.each([
[
"/servicos/criacao-de-sites",
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
],
[
"/cases/sev-modaintima",
"Quero conversar sobre um projeto parecido com o case SEV Moda Íntima.",
],
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
// a mesma frase do botão do hero e do cabeçalho de cada página
["/", "Quero conversar sobre um site ou sistema para a minha empresa."],
["/servicos", "Quero conversar sobre sites, sistemas e aplicativos."],
[
"/cases",
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
],
[
"/curriculo",
"Quero conversar sobre um projeto, depois de ver o seu currículo.",
],
["/privacidade", "Quero conversar sobre um projeto."],
["/servicos/inexistente", "Quero conversar sobre um projeto."],
])("on %s", (pathname, sentence) => {
jest.mocked(usePathname).mockReturnValue(pathname);
// sem hero nem contato na página, o botão aparece de imediato
render(<FloatingWhatsApp contexts={contexts} />);
expect(messageOf(floatingLink())).toContain(sentence);
});
it("falls back to a generic message without contexts", () => {
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
render(<FloatingWhatsApp />);
expect(messageOf(floatingLink())).toContain(
"Quero conversar sobre um projeto.",
);
});
});
describe("page changes", () => {
it("observes the hero and the contact again on every page, hiding the button until the new report", () => {
jest.mocked(usePathname).mockReturnValue("/cases/sev-modaintima");
const { rerender } = render(<FloatingWhatsApp contexts={contexts} />);
// páginas de case não têm hero nem contato: o botão está à vista
expect(floatingLink()).toBeInTheDocument();
expect(instances).toHaveLength(0);
// volta à home, que tem os dois convites
document.body.insertAdjacentHTML(
"beforeend",
'<a id="hero-contact-cta" href="/#contact">Conversar</a><section id="contact"></section>',
);
jest.mocked(usePathname).mockReturnValue("/");
rerender(<FloatingWhatsApp contexts={contexts} />);
// sem piscar: some até o observador dizer que o hero saiu da tela
expect(floatingLink()).toBeNull();
expect(instances).toHaveLength(1);
const hero = document.getElementById("hero-contact-cta") as Element;
const contact = document.getElementById("contact") as Element;
instances[0].report([
{ target: hero, isIntersecting: false },
{ target: contact, isIntersecting: false },
]);
expect(floatingLink()).toBeInTheDocument();
expect(messageOf(floatingLink())).toContain(
"Quero conversar sobre um site ou sistema para a minha empresa.",
);
// sai da home para um serviço: o observador antigo é desligado e o botão passa a falar do serviço
document.getElementById("hero-contact-cta")?.remove();
document.getElementById("contact")?.remove();
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
rerender(<FloatingWhatsApp contexts={contexts} />);
expect(instances[0].disconnect).toHaveBeenCalled();
expect(messageOf(floatingLink())).toContain(
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
);
});
});
-150
View File
@@ -1,150 +0,0 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp";
const CONSENT_KEY = "evandro.analytics-consent.v1";
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
let notify: (entries: Entry[]) => void = () => {};
const originalObserver = global.IntersectionObserver;
class FakeObserver {
constructor(callback: IntersectionObserverCallback) {
notify = (entries) =>
callback(entries as IntersectionObserverEntry[], this as never);
}
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
takeRecords = () => [];
}
function Page({ withInvites = true }: { withInvites?: boolean }) {
return (
<>
{withInvites && (
<>
<a id="hero-contact-cta" href="/#contact">
Conversar sobre meu projeto
</a>
<section id="contact" aria-label="Contato" />
</>
)}
<FloatingWhatsApp />
</>
);
}
function setVisibility(hero: boolean, contact: boolean) {
act(() =>
notify([
{
target: document.getElementById("hero-contact-cta") as Element,
isIntersecting: hero,
},
{
target: document.getElementById("contact") as Element,
isIntersecting: contact,
},
]),
);
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
beforeEach(() => {
global.IntersectionObserver =
FakeObserver as unknown as typeof IntersectionObserver;
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
window.gtag = jest.fn();
});
afterEach(() => {
global.IntersectionObserver = originalObserver;
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
it("stays hidden until the observer reports, then shows a 56px WhatsApp shortcut for small screens", () => {
render(<Page />);
expect(floatingLink()).toBeNull();
setVisibility(false, false);
const link = floatingLink();
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
const url = new URL(link?.getAttribute("href") ?? "");
expect(url.origin).toBe("https://wa.me");
// o dublê central de next/navigation abre na home ("/"): o botão fala do assunto da home
expect(url.searchParams.get("text")).toContain(
`Quero conversar sobre ${HOME_SUBJECT}.`,
);
// alvo de 56 px, canto inferior direito, só abaixo de 768 px
expect(link).toHaveClass("h-14", "w-14");
expect(link?.parentElement).toHaveClass("fixed", "right-sp-4", "md:hidden");
});
it("hides while the hero button is on screen", () => {
render(<Page />);
setVisibility(true, false);
expect(floatingLink()).toBeNull();
setVisibility(false, false);
expect(floatingLink()).toBeInTheDocument();
});
it("hides while the contact section is on screen", () => {
render(<Page />);
setVisibility(false, true);
expect(floatingLink()).toBeNull();
setVisibility(false, false);
expect(floatingLink()).toBeInTheDocument();
});
it("shows right away on a page that has neither the hero button nor the contact section", () => {
render(<Page withInvites={false} />);
expect(floatingLink()).toBeInTheDocument();
});
it("hides by CSS while the cookie bar is open (the data-cookie-bar mark on <html>), instead of sitting on top of it", () => {
render(<Page />);
setVisibility(false, false);
const className = floatingLink()?.parentElement?.className ?? "";
expect(className).toContain("[[data-cookie-bar=open]_&]:hidden");
// não sobe mais pela altura da barra: some enquanto ela está aberta
expect(className).not.toContain("--cookie-bar-height");
expect(className).toContain(
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
);
});
it("sits in a named complementary region (the 'region' landmark rule), which only exists while the shortcut shows", () => {
render(<Page />);
expect(screen.queryByRole("complementary")).toBeNull();
setVisibility(false, false);
const region = screen.getByRole("complementary", { name: "Contato rápido" });
expect(region.tagName).toBe("ASIDE");
expect(region).toContainElement(floatingLink());
});
it("reports the click as a floating WhatsApp handoff", () => {
render(<Page />);
setVisibility(false, false);
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "floating" }),
);
});
-76
View File
@@ -1,76 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
/** Link "Chamar no WhatsApp" do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
export const HERO_CTA_ID = "hero-contact-cta";
const CONTACT_SECTION_ID = "contact";
/**
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
* contato da home) e enquanto a barra de cookies está aberta (o `data-cookie-bar` que GoogleAnalytics põe no <html>):
* os dois disputavam a base da tela. O rodapé reserva no fim da página o espaço que ele ocupa.
* Fica num <aside> com nome (região de contato rápido para leitores de tela).
*/
export default function FloatingWhatsApp({
contexts,
}: {
/** Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx). */
contexts?: WhatsAppContexts;
}) {
const pathname = usePathname();
const subject = whatsAppSubjectForPath(pathname, contexts);
// null até o observador responder pela primeira vez: assim o botão não pisca na chegada, quando o hero está à vista
const [inviteVisible, setInviteVisible] = useState<Record<
string,
boolean
> | null>(null);
// o botão vive no layout: a cada página o hero e o contato (que só existem na home) entram ou saem de cena
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname só reinicia a observação a cada página
useEffect(() => {
setInviteVisible(null);
const targets = [HERO_CTA_ID, CONTACT_SECTION_ID]
.map((id) => document.getElementById(id))
.filter((element): element is HTMLElement => element !== null);
if (targets.length === 0) {
setInviteVisible({});
return;
}
const observer = new IntersectionObserver((entries) => {
setInviteVisible((current) => {
const next = { ...current };
for (const entry of entries) {
next[entry.target.id] = entry.isIntersecting;
}
return next;
});
});
for (const target of targets) observer.observe(target);
return () => observer.disconnect();
}, [pathname]);
if (!inviteVisible || Object.values(inviteVisible).some(Boolean)) return null;
return (
<aside
aria-label="Contato rápido"
className="ds-enter-pop fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] md:hidden [[data-cookie-bar=open]_&]:hidden"
>
<WhatsAppLink
placement="floating"
service={subject}
className="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--whatsapp)] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[var(--whatsapp-hover)] motion-reduce:transition-none"
>
<WhatsAppMark className="h-7 w-7" />
<span className="sr-only">Chamar no WhatsApp</span>
</WhatsAppLink>
</aside>
);
}
-188
View File
@@ -1,188 +0,0 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { OPEN_COOKIE_PREFERENCES_EVENT } from "@/lib/analytics";
import { serviceHubLinks } from "@/lib/site-content";
import Footer from "./Footer";
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
describe("Footer contact and navigation", () => {
it("offers a direct mailto link without repeating the contact call to action", () => {
render(<Footer />);
// o convite principal fica na secao Contato, logo acima do rodape
expect(
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
).not.toBeInTheDocument();
const emailLink = screen.getByRole("link", {
name: "[email protected]",
});
expect(emailLink).toHaveAttribute(
"href",
"mailto:[email protected]",
);
expect(emailLink).not.toHaveAttribute("target", "_blank");
});
it("offers WhatsApp in the contact column, in a new tab, named like every control that opens it", () => {
render(<Footer />);
const contact = screen.getByRole("region", { name: "Contato" });
const whatsApp = within(contact).getByRole("link", {
name: "Chamar no WhatsApp",
});
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(whatsApp).toHaveAttribute("target", "_blank");
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
});
it("lists the eight services from the services hub as same-tab links", () => {
render(<Footer serviceLinks={serviceHubLinks} />);
expect(serviceHubLinks).toHaveLength(8);
const services = screen.getByRole("navigation", { name: "Serviços" });
const links = within(services).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(
serviceHubLinks.map((service) => service.href),
);
expect(links.map((link) => link.textContent)).toEqual(
serviceHubLinks.map((service) => service.label),
);
for (const link of links) expect(link).not.toHaveAttribute("target");
});
it("links Cases, the /ia page by its menu label, Privacidade and a discreet Currículo, with no PDF download", () => {
render(<Footer />);
const more = screen.getByRole("navigation", { name: "Mais" });
expect(within(more).getByRole("link", { name: "Cases" })).toHaveAttribute(
"href",
"/cases",
);
expect(
within(more).getByRole("link", {
name: "Revisão de app com IA",
}),
).toHaveAttribute("href", "/ia");
expect(
within(more).getByRole("link", { name: "Privacidade" }),
).toHaveAttribute("href", "/privacidade");
const resume = within(more).getByRole("link", { name: "Currículo" });
expect(resume).toHaveAttribute("href", "/curriculo");
expect(within(more).getAllByRole("link").at(-1)).toBe(resume);
expect(screen.queryByRole("link", { name: /baixar|pdf/i })).toBeNull();
for (const link of screen.getAllByRole("link")) {
expect(link).not.toHaveAttribute("download");
}
});
it("reopens the cookie preferences from a button", () => {
const reopen = jest.fn();
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
render(<Footer />);
fireEvent.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
expect(reopen).toHaveBeenCalledTimes(1);
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
});
it("states the location and rights in Portuguese", () => {
render(<Footer />);
expect(
screen.getByText("Garuva, SC · Atendimento remoto em todo o Brasil"),
).toBeInTheDocument();
expect(
screen.getByText(/Todos os direitos\s+reservados/),
).toBeInTheDocument();
expect(screen.queryByText(/remote worldwide|all rights/i)).toBeNull();
});
});
describe("Footer social links", () => {
it("keeps one LinkedIn profile in a new tab and no GitHub link", () => {
render(<Footer />);
const linkedin = screen.getByRole("link", { name: "LinkedIn" });
expect(linkedin).toHaveAttribute(
"href",
"https://www.linkedin.com/in/evandrodevbr/",
);
expect(linkedin).toHaveAttribute("target", "_blank");
expect(linkedin).toHaveAttribute("rel", "noopener noreferrer");
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
}
expect(screen.queryByRole("link", { name: /github/i })).toBeNull();
});
it("keeps Instagram in the contact column, in a new tab", () => {
render(<Footer />);
const contact = screen.getByRole("region", { name: "Contato" });
const instagram = within(contact).getByRole("link", { name: "Instagram" });
expect(instagram).toHaveAttribute(
"href",
"https://www.instagram.com/evandrodevbr/",
);
expect(instagram).toHaveAttribute("target", "_blank");
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
});
it("names /ia like the menu and the page, and no old 'app feito com IA' name", () => {
render(<Footer />);
expect(
screen.getByRole("link", { name: "Revisão de app com IA" }),
).toHaveAttribute("href", "/ia");
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
});
it("adds the height of the cookie bar to the bottom padding, so the logo and the copyright are not under it at the end of the page", () => {
const { container } = render(<Footer />);
const footer = container.querySelector("footer") as HTMLElement;
// com a barra fechada a variável não existe: o 0px do fallback deixa o rodapé como era
expect(footer.className).toContain(
"pb-[calc(var(--sp-10)+var(--cookie-bar-height,0px))]",
);
// no celular o botão flutuante do WhatsApp (72 px) também continua à parte
expect(footer.className).toContain(
"max-md:pb-[calc(var(--sp-10)+72px+var(--cookie-bar-height,0px))]",
);
expect(footer.className).not.toMatch(/(?:^|\s)py-sp-10(?:\s|$)/);
});
it("pre-fetches the internal links only when the visitor reaches them, never by viewport (services, cases, /ia, privacy, résumé, home)", () => {
render(<Footer serviceLinks={serviceHubLinks} />);
const internal = screen
.getAllByRole("link")
.filter((link) => (link.getAttribute("href") ?? "").startsWith("/"));
// serviços do hub + Cases, /ia, Privacidade, Currículo + o logo para /#home
expect(internal.length).toBeGreaterThanOrEqual(serviceHubLinks.length + 5);
for (const link of internal) {
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
}
});
});
-171
View File
@@ -1,171 +0,0 @@
"use client";
import {
openCookiePreferences,
trackEmailClick,
trackSocialClick,
} from "@/lib/analytics";
import {
CONTACT_EMAIL,
IA_PAGE_LABEL,
INSTAGRAM_URL,
LINKEDIN_URL,
type ServiceLink,
} from "@/lib/site-navigation";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
const headingClassName =
"font-mono text-[13px] font-medium tracking-[0.02em] text-[var(--meta)]";
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
const linkClassName =
"inline-flex min-h-11 items-center text-left font-display text-[15px] leading-6 text-[var(--ink-80)] md:min-h-8 underline-offset-4 hover:text-[var(--ink)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)]";
export default function Footer({
serviceLinks = [],
}: {
/** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */
serviceLinks?: ServiceLink[];
}) {
const currentYear = new Date().getFullYear();
return (
// no celular o botão flutuante do WhatsApp (56 px + 16 px de margem) cobriria a última linha: o rodapé sobe o espaço dele.
// A barra de cookies (fixa no pé da tela) cobriria o logo e o © no fim da página: o rodapé também sobe a altura dela
// (--cookie-bar-height, publicada por GoogleAnalytics enquanto a barra está aberta; sem barra, 0).
<footer className="border-t border-[var(--border)] bg-[var(--paper)] px-[clamp(20px,6vw,64px)] pt-sp-10 pb-[calc(var(--sp-10)+var(--cookie-bar-height,0px))] text-[var(--ink)] max-md:pb-[calc(var(--sp-10)+72px+var(--cookie-bar-height,0px))]">
<div className="ds-section-shell">
<div className="grid gap-sp-8 md:grid-cols-3 md:gap-sp-10">
<section aria-labelledby="footer-contact-title">
<p id="footer-contact-title" className={headingClassName}>
Contato
</p>
<ul className="mt-sp-2">
<li>
<WhatsAppLink
placement="footer"
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
>
Chamar no WhatsApp
</WhatsAppLink>
</li>
<li>
<a
href={`mailto:${CONTACT_EMAIL}`}
onClick={() => trackEmailClick({ placement: "footer" })}
className={`${linkClassName} break-all font-semibold !text-[var(--ink)]`}
>
{CONTACT_EMAIL}
</a>
</li>
<li>
<a
href={LINKEDIN_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackSocialClick({ network: "linkedin" })}
className={linkClassName}
>
LinkedIn
</a>
</li>
<li>
<a
href={INSTAGRAM_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackSocialClick({ network: "instagram" })}
className={linkClassName}
>
Instagram
</a>
</li>
</ul>
<p className="mt-sp-2 font-display text-[15px] leading-6 text-[var(--meta)]">
Garuva, SC · Atendimento remoto em todo o Brasil
</p>
</section>
<nav aria-labelledby="footer-services-title">
<p id="footer-services-title" className={headingClassName}>
Serviços
</p>
<ul className="mt-sp-2">
{serviceLinks.map((service) => (
<li key={service.href}>
<HoverPrefetchLink
href={service.href}
className={linkClassName}
>
{service.label}
</HoverPrefetchLink>
</li>
))}
</ul>
</nav>
<nav aria-labelledby="footer-links-title">
<p id="footer-links-title" className={headingClassName}>
Mais
</p>
<ul className="mt-sp-2">
<li>
<HoverPrefetchLink href="/cases" className={linkClassName}>
Cases
</HoverPrefetchLink>
</li>
<li>
<HoverPrefetchLink href="/ia" className={linkClassName}>
{IA_PAGE_LABEL}
</HoverPrefetchLink>
</li>
<li>
<HoverPrefetchLink
href="/privacidade"
className={linkClassName}
>
Privacidade
</HoverPrefetchLink>
</li>
<li>
<button
type="button"
onClick={openCookiePreferences}
className={`cv-btn-link ${linkClassName}`}
>
Preferências de cookies
</button>
</li>
<li>
<HoverPrefetchLink
href="/curriculo"
className={`${linkClassName} !text-[15px] !text-[var(--meta)]`}
>
Currículo
</HoverPrefetchLink>
</li>
</ul>
</nav>
</div>
<div className="mt-sp-8 flex flex-wrap items-center justify-between gap-x-sp-6 gap-y-sp-3 border-t border-[var(--border)] pt-sp-5">
<HoverPrefetchLink
href="/#home"
aria-label="Evandro.dev.br — início"
className="inline-flex min-h-11 items-center"
>
{/* o ".dev" do logo é limão (#ccff00), quase invisível sobre o papel: o filtro o escurece (de 1,04:1
para 4:1) e o resto do logo, já quase preto, quase não muda */}
<Logo theme="light" className="w-40 [filter:brightness(0.5)]" />
</HoverPrefetchLink>
<p className="font-display text-[15px] leading-6 text-[var(--meta)]">
© {currentYear} Evandro Fonseca Junior. Todos os direitos
reservados.
</p>
</div>
</div>
</footer>
);
}
@@ -1,69 +0,0 @@
/**
* @jest-environment-options {"url": "https://evandro.dev.br/"}
*/
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { openCookiePreferences } from "@/lib/analytics";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Caminho de GoogleAnalytics (expireAnalyticsCookies) quando o site roda no próprio domínio evandro.dev.br:
* host e domínio raiz são o mesmo, então só há um atributo Domain.
*/
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it("path: host is the site domain itself -> expires _ga on the path and once on the domain", async () => {
const user = userEvent.setup();
expect(window.location.hostname).toBe("evandro.dev.br");
setCookie("_ga=1; Path=/");
setCookie("_ga_ABC=1; Path=/");
render(
<>
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const cookieWrites = jest.spyOn(document, "cookie", "set");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await act(async () => {});
await user.click(screen.getByRole("button", { name: "Recusar" }));
const writes = cookieWrites.mock.calls.map(([value]) => String(value));
// dois cookies (_ga e _ga_ABC), cada um: sem Domain e com Domain=evandro.dev.br (host == raiz, sem duplicar)
expect(writes).toHaveLength(4);
expect(writes.filter((w) => w.includes("Domain="))).toEqual([
expect.stringMatching(/^_ga=.*; Domain=evandro\.dev\.br$/),
expect.stringMatching(/^_ga_ABC=.*; Domain=evandro\.dev\.br$/),
]);
});
@@ -1,441 +0,0 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { openCookiePreferences } from "@/lib/analytics";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Testes de caminhos-base de GoogleAnalytics (complemento de GoogleAnalytics.test.tsx):
* modo de desenvolvimento, teclas da barra, reaceite, nova concessão depois de revogar,
* cookies de analytics x outros cookies, reaproveitamento de gtag/dataLayer e recuperação de falha de gravação.
*/
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
type AnalyticsWindow = Window & {
dataLayer?: IArguments[];
gtag?: (...args: unknown[]) => void;
[GA_DISABLE_KEY]?: boolean;
};
const analyticsWindow = window as AnalyticsWindow;
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
function expireCookie(name: string) {
setCookie(`${name}=; Max-Age=0; Path=/`);
}
const COOKIES = ["_ga", "_ga_ABC123", "_gid", "_gat", "portfolio_session"];
function commands() {
return (analyticsWindow.dataLayer ?? []).map((entry) => Array.from(entry));
}
function commandsNamed(...names: string[]) {
return commands().filter((entry) =>
names.every((name, index) => entry[index] === name),
);
}
function OpenPreferencesButton() {
return (
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
);
}
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
document.getElementById("google-analytics-script")?.remove();
localStorage.clear();
for (const name of COOKIES) expireCookie(name);
analyticsWindow.dataLayer = undefined;
analyticsWindow.gtag = undefined;
analyticsWindow[GA_DISABLE_KEY] = undefined;
});
afterEach(() => {
jest.restoreAllMocks();
document.getElementById("google-analytics-script")?.remove();
for (const name of COOKIES) expireCookie(name);
});
describe("GoogleAnalytics: modo e teclas", () => {
it("path: outside production -> the cookie bar and the choice are still there (dev and production behave the same), but the gtag script is never loaded", async () => {
jest.replaceProperty(process.env, "NODE_ENV", "development");
const user = userEvent.setup();
render(<GoogleAnalytics />);
// a barra (e o foco por Tab) é a mesma da produção
const dialog = await screen.findByRole("dialog", {
name: "Cookies e privacidade",
});
expect(dialog).toBeVisible();
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).not.toBe("");
await user.click(screen.getByRole("button", { name: "Aceitar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.queryByRole("dialog")).toBeNull();
// mesmo com o consentimento, fora da produção o gtag não é baixado
await act(async () => {});
expect(screen.queryByTestId("google-analytics-script")).toBeNull();
expect(document.getElementById("google-analytics-script")).toBeNull();
});
it("path: the same consent in production loads the gtag script (the only thing tied to NODE_ENV)", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(
await screen.findByTestId("google-analytics-script"),
).toBeInTheDocument();
});
it("path: Escape on the first-visit bar -> bar stays and no choice is stored", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
const accept = await screen.findByRole("button", { name: "Aceitar" });
accept.focus();
await user.keyboard("{Escape}");
expect(screen.getByRole("dialog")).toBeVisible();
expect(localStorage.getItem(CONSENT_KEY)).toBeNull();
expect(screen.queryByText(/Escolha atual/)).toBeNull();
});
it("path: another key on the reopened bar -> stays open", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await act(async () => {});
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.keyboard("x{Tab}");
expect(screen.queryByRole("dialog")).not.toBeNull();
});
it("path: reopened with a refusal stored -> the bar announces 'recusado'", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await act(async () => {});
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Escolha atual: recusado/,
);
});
});
describe("GoogleAnalytics: aceitar e recusar", () => {
it("path: reopened while already granted, 'Aceitar' only closes the bar", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
analyticsWindow.gtag = jest.fn();
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const gtag = analyticsWindow.gtag as jest.Mock;
const callsAfterMount = gtag.mock.calls.length;
const opener = screen.getByRole("button", {
name: "Preferências de cookies",
});
await user.click(opener);
await user.click(screen.getByRole("button", { name: "Aceitar" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(gtag.mock.calls).toHaveLength(callsAfterMount);
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
expect(opener).toHaveFocus();
});
it("path: granting again after a revocation -> bootstrap runs once, GA is re-enabled", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const opener = screen.getByRole("button", {
name: "Preferências de cookies",
});
await user.click(opener);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
expect(screen.queryByTestId("google-analytics-script")).toBeNull();
await user.click(opener);
await user.click(screen.getByRole("button", { name: "Aceitar" }));
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(false);
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
// "default" e "js" só na primeira ativação; a configuração dos dois destinos roda a cada concessão
expect(commandsNamed("consent", "default")).toHaveLength(1);
expect(commandsNamed("js")).toHaveLength(1);
expect(commandsNamed("config", "G-QTT9QT0T3S")).toHaveLength(2);
expect(commandsNamed("config", "AW-18432694647")).toHaveLength(2);
});
it("path: first visit refused -> nothing is revoked because nothing was granted", async () => {
const user = userEvent.setup();
setCookie("_ga=keep-me; Path=/");
const cookieWrites = jest.spyOn(document, "cookie", "set");
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Recusar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBeUndefined();
expect(analyticsWindow.dataLayer).toBeUndefined();
expect(cookieWrites).not.toHaveBeenCalled();
expect(document.cookie).toContain("_ga=keep-me");
});
it("path: revoking after gtag disappeared -> still persists, disables GA and does not throw", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
delete analyticsWindow.gtag;
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
describe("GoogleAnalytics: cookies na revogação", () => {
it("path: only _ga and _ga_* are expired; look-alikes and other cookies stay", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
for (const name of COOKIES) setCookie(`${name}=1; Path=/`);
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
const remaining = document.cookie.split(";").map((c) => c.trim());
expect(remaining).toEqual(
expect.arrayContaining(["_gid=1", "_gat=1", "portfolio_session=1"]),
);
expect(remaining.some((c) => c.startsWith("_ga="))).toBe(false);
expect(remaining.some((c) => c.startsWith("_ga_ABC123="))).toBe(false);
});
it("path: no analytics cookie present -> revocation writes no cookie", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("portfolio_session=1; Path=/");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const cookieWrites = jest.spyOn(document, "cookie", "set");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(cookieWrites).not.toHaveBeenCalled();
expect(document.cookie).toContain("portfolio_session=1");
});
it("path: host is not the site domain (localhost) -> expiry is written without a Domain attribute", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("_ga=1; Path=/");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const cookieWrites = jest.spyOn(document, "cookie", "set");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(cookieWrites).toHaveBeenCalledTimes(1);
expect(cookieWrites.mock.calls[0][0]).toMatch(/^_ga=; Max-Age=0;/);
expect(cookieWrites.mock.calls[0][0]).not.toContain("Domain=");
});
});
describe("GoogleAnalytics: gtag, dataLayer e carregador", () => {
it("path: dataLayer already exists -> the same array receives the commands", async () => {
const user = userEvent.setup();
const existing: IArguments[] = [];
analyticsWindow.dataLayer = existing;
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(analyticsWindow.dataLayer).toBe(existing);
expect(existing.length).toBeGreaterThan(0);
expect(typeof analyticsWindow.gtag).toBe("function");
});
it("path: a gtag already exists -> it is reused instead of being replaced", async () => {
const user = userEvent.setup();
const gtag = jest.fn();
analyticsWindow.gtag = gtag;
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(analyticsWindow.gtag).toBe(gtag);
expect(gtag).toHaveBeenCalledWith("js", expect.any(Date));
expect(analyticsWindow.dataLayer).toBeDefined();
});
it.each([
["denied", "denied"],
["no choice yet", null],
])(
"path: %s -> a loader left by next/script is removed",
async (_label, stored) => {
if (stored) localStorage.setItem(CONSENT_KEY, stored);
const loader = document.createElement("script");
loader.id = "google-analytics-script";
document.head.append(loader);
render(<GoogleAnalytics />);
await act(async () => {});
expect(document.getElementById("google-analytics-script")).toBeNull();
},
);
it("path: granted -> the loader stays", async () => {
localStorage.setItem(CONSENT_KEY, "granted");
const loader = document.createElement("script");
loader.id = "google-analytics-script";
document.head.append(loader);
render(<GoogleAnalytics />);
await act(async () => {});
expect(document.getElementById("google-analytics-script")).not.toBeNull();
});
});
describe("GoogleAnalytics: foco e falha de gravação", () => {
it("path: opener is not an HTML element (SVG) -> closing the bar has no element to refocus", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(
<>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: elemento SVG de teste só para receber o foco */}
<svg data-testid="svg-opener" tabIndex={0} />
<GoogleAnalytics />
</>,
);
await act(async () => {});
const svg = screen.getByTestId("svg-opener");
svg.focus();
expect(document.activeElement).toBe(svg);
act(() => openCookiePreferences());
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).not.toBe(svg);
});
it("path: acceptance fails to persist, then succeeds -> the alert goes away and analytics turns on", async () => {
const user = userEvent.setup();
const setItem = jest
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(
screen.getByRole("alert", { name: "Falha ao salvar o consentimento" }),
).toHaveTextContent(
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
);
expect(screen.getByRole("dialog")).toBeVisible();
expect(analyticsWindow.dataLayer).toBeUndefined();
setItem.mockRestore();
await user.click(screen.getByRole("button", { name: "Aceitar" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
});
});
-317
View File
@@ -1,317 +0,0 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { openCookiePreferences } from "@/lib/analytics";
import GoogleAnalytics from "./GoogleAnalytics";
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
const GA_SRC = "https://www.googletagmanager.com/gtag/js?id=G-QTT9QT0T3S";
type AnalyticsWindow = Window & {
dataLayer?: IArguments[];
gtag?: (...args: unknown[]) => void;
[GA_DISABLE_KEY]?: boolean;
};
const analyticsWindow = window as AnalyticsWindow;
function consentUpdates(gtag: jest.Mock) {
return gtag.mock.calls.filter(
([command, action]) => command === "consent" && action === "update",
);
}
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
describe("GoogleAnalytics consent", () => {
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
document.getElementById("google-analytics-script")?.remove();
localStorage.clear();
setCookie("_ga=; Max-Age=0; Path=/");
setCookie("_ga_PORTFOLIO=; Max-Age=0; Path=/");
setCookie("portfolio_session=; Max-Age=0; Path=/");
analyticsWindow.dataLayer = undefined;
analyticsWindow.gtag = undefined;
analyticsWindow[GA_DISABLE_KEY] = undefined;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("waits for a decision before loading analytics", async () => {
render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Aceitar" }),
).toBeVisible();
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Métricas de navegação do Google, só se você permitir\./,
);
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
expect(analyticsWindow.dataLayer).toBeUndefined();
});
it("keeps the prompt as a thin bottom bar and publishes its height while it is open", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
const bar = await screen.findByRole("dialog");
expect(bar).toHaveClass("fixed", "inset-x-0", "bottom-0");
expect(document.documentElement).toHaveAttribute("data-cookie-bar", "open");
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toMatch(/^\d+px$/);
// o link vem antes dos botões na ordem de Tab dentro da barra
expect(bar.querySelector("a, button")).toBe(
screen.getByRole("link", { name: "Política de privacidade" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
});
it("persists acceptance and enables analytics without advertising consent", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
"src",
GA_SRC,
);
const commands = analyticsWindow.dataLayer?.map((entry) =>
Array.from(entry),
);
expect(commands).toContainEqual([
"consent",
"default",
{
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(commands).toContainEqual([
"consent",
"update",
{
analytics_storage: "granted",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(commands).toContainEqual([
"config",
"G-QTT9QT0T3S",
{
allow_ad_personalization_signals: false,
anonymize_ip: true,
},
]);
expect(commands).toContainEqual([
"config",
"AW-18432694647",
{ allow_ad_personalization_signals: false },
]);
expect(commands).toContainEqual(["set", "ads_data_redaction", true]);
// depois da escolha nada fica fixo na tela
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("persists refusal and keeps analytics and the bar absent after reload", async () => {
const user = userEvent.setup();
const firstRender = render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Recusar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
firstRender.unmount();
const reloaded = render(<GoogleAnalytics />);
await act(async () => {});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(reloaded.container).toBeEmptyDOMElement();
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
it("reopens from the footer link and fully revokes analytics only", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("_ga=main; Path=/");
setCookie("_ga_PORTFOLIO=property; Path=/");
setCookie("portfolio_session=keep; Path=/");
const gtag = jest.fn();
analyticsWindow.gtag = gtag;
render(
<>
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const opener = screen.getByRole("button", {
name: "Preferências de cookies",
});
await user.click(opener);
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Escolha atual: permitido/,
);
expect(screen.getByRole("button", { name: "Aceitar" })).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
expect(consentUpdates(gtag)).toContainEqual([
"consent",
"update",
{
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(document.cookie).not.toContain("_ga=");
expect(document.cookie).not.toContain("_ga_PORTFOLIO=");
expect(document.cookie).toContain("portfolio_session=keep");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(opener).toHaveFocus();
});
it("restores focus after closing with Escape or applying opened preferences", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(
<>
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
<GoogleAnalytics />
</>,
);
await act(async () => {});
const opener = screen.getByRole("button", {
name: "Preferências de cookies",
});
await user.click(opener);
expect(
screen.getByRole("link", { name: "Política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(opener).toHaveFocus();
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
await user.click(opener);
await user.click(screen.getByRole("button", { name: "Aceitar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(opener).toHaveFocus();
});
it("fails closed when local storage is unavailable", async () => {
const user = userEvent.setup();
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("storage blocked");
});
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
await waitFor(() => {
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
});
it("reports when revocation cannot be persisted", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await screen.findByTestId("google-analytics-script");
act(() => openCookiePreferences());
await user.click(await screen.findByRole("button", { name: "Recusar" }));
expect(
screen.getByRole("alert", {
name: "Falha ao salvar o consentimento",
}),
).toHaveTextContent(
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
expect(analyticsWindow[GA_DISABLE_KEY]).not.toBe(true);
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
});
it("reports when refusal cannot be persisted", async () => {
const user = userEvent.setup();
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Recusar" }));
expect(
screen.getByRole("alert", {
name: "Falha ao salvar o consentimento",
}),
).toHaveTextContent(
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
expect(screen.getByRole("dialog")).toBeVisible();
});
});
-297
View File
@@ -1,297 +0,0 @@
"use client";
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type AnalyticsConsent,
CONSENT_STORAGE_KEY,
COOKIE_BAR_ID,
GA_DISABLE_KEY,
GA_MEASUREMENT_ID,
GOOGLE_ADS_ID,
type Gtag,
OPEN_COOKIE_PREFERENCES_EVENT,
readConsent,
} from "@/lib/analytics";
import "./conversion.css";
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
const SITE_DOMAIN = "evandro.dev.br";
const deniedConsent = {
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
} as const;
function persistConsent(value: AnalyticsConsent): boolean {
try {
localStorage.setItem(CONSENT_STORAGE_KEY, value);
return true;
} catch {
return false;
}
}
function getGtag(): Gtag {
window.dataLayer ??= [];
const dataLayer = window.dataLayer;
if (!window.gtag) {
window.gtag = function gtag() {
// biome-ignore lint/complexity/noArguments: The official gtag bootstrap requires an IArguments entry, not an Array.
dataLayer.push(arguments);
};
}
return window.gtag;
}
function removeAnalyticsLoader() {
document.getElementById("google-analytics-script")?.remove();
}
function writeCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store is not universal and domain-scoped GA cookies must also be expired.
document.cookie = value;
}
function expireAnalyticsCookies() {
const cookieNames = new Set(
document.cookie
.split(";")
.map((cookie) => cookie.split("=")[0]?.trim())
.filter(
(name): name is string =>
name === "_ga" || Boolean(name?.startsWith("_ga_")),
),
);
const { hostname } = window.location;
const domains =
hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
? new Set([hostname, SITE_DOMAIN])
: new Set<string>();
for (const name of cookieNames) {
const expiration = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
writeCookie(expiration);
for (const domain of domains) {
writeCookie(`${expiration}; Domain=${domain}`);
}
}
}
const barButtonClassName =
"cv-btn inline-flex min-h-10 min-w-11 items-center justify-center border px-[10px] py-sp-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] motion-reduce:transition-none";
function GoogleAnalyticsConsent() {
const [consent, setConsent] = useState<AnalyticsConsent | null>();
const [preferencesOpen, setPreferencesOpen] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null);
const configuredRef = useRef(false);
const barRef = useRef<HTMLElement>(null);
const acceptRef = useRef<HTMLButtonElement>(null);
const openerRef = useRef<HTMLElement | null>(null);
const enableAnalytics = useCallback(() => {
const gtag = getGtag();
window[GA_DISABLE_KEY] = false;
if (!configuredRef.current) {
gtag("consent", "default", deniedConsent);
gtag("set", "ads_data_redaction", true);
gtag("js", new Date());
configuredRef.current = true;
}
gtag("consent", "update", {
...deniedConsent,
analytics_storage: "granted",
});
gtag("config", GA_MEASUREMENT_ID, {
allow_ad_personalization_signals: false,
anonymize_ip: true,
});
// One loader serves both destinations; GT IDs are aliases, not extra installs.
gtag("config", GOOGLE_ADS_ID, {
allow_ad_personalization_signals: false,
});
}, []);
useEffect(() => {
const storedConsent = readConsent();
if (storedConsent === "granted") enableAnalytics();
setConsent(storedConsent);
}, [enableAnalytics]);
useEffect(() => {
if (consent !== "granted" && consent !== undefined) {
removeAnalyticsLoader();
}
}, [consent]);
// O link "Preferências de cookies" do rodapé reabre a barra; quem pediu recebe o foco de volta ao fechar.
useEffect(() => {
const reopen = () => {
openerRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setPreferencesOpen(true);
};
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
return () =>
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
}, []);
const barOpen = consent === null || preferencesOpen;
// Enquanto a barra está na tela, publica a altura dela para o botão flutuante do WhatsApp subir.
useEffect(() => {
const bar = barRef.current;
if (!barOpen || !bar) return;
const root = document.documentElement;
const publish = () =>
root.style.setProperty("--cookie-bar-height", `${bar.offsetHeight}px`);
publish();
root.setAttribute("data-cookie-bar", "open");
const observer = new ResizeObserver(publish);
observer.observe(bar);
return () => {
observer.disconnect();
root.style.removeProperty("--cookie-bar-height");
root.removeAttribute("data-cookie-bar");
};
}, [barOpen]);
// Reaberta pelo rodapé, a barra está no começo do documento: leva o foco para ela.
useEffect(() => {
if (preferencesOpen) acceptRef.current?.focus();
}, [preferencesOpen]);
function closePreferences() {
setPreferencesOpen(false);
openerRef.current?.focus();
openerRef.current = null;
}
function acceptAnalytics() {
if (consent === "granted") {
closePreferences();
return;
}
if (!persistConsent("granted")) {
setConsentError(
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
enableAnalytics();
setConsent("granted");
closePreferences();
}
function denyAnalytics() {
const revoking = consent === "granted";
if (!persistConsent("denied")) {
setConsentError(
revoking
? "Não foi possível salvar a revogação. Tente novamente para concluir."
: "Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
if (revoking) {
window[GA_DISABLE_KEY] = true;
window.gtag?.("consent", "update", deniedConsent);
expireAnalyticsCookies();
}
setConsent("denied");
closePreferences();
}
if (consent === undefined) return null;
return (
<>
{consent === "granted" && process.env.NODE_ENV === "production" && (
<Script
id="google-analytics-script"
data-testid="google-analytics-script"
src={GA_SCRIPT_SRC}
strategy="afterInteractive"
/>
)}
{barOpen && (
<section
ref={barRef}
id={COOKIE_BAR_ID}
role="dialog"
aria-modal="false"
aria-label="Cookies e privacidade"
aria-describedby="analytics-consent-description"
onKeyDown={(event) => {
if (event.key === "Escape" && preferencesOpen) closePreferences();
}}
className="ds-enter fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] [--enter-y:16px] sm:px-sp-6"
>
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
{/* Até 72 px de altura em 390 px: três linhas de 13 px ao lado dos botões (15 px de 640 px em diante). */}
<p
id="analytics-consent-description"
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-[15px]"
>
Métricas de navegação do Google, só se você permitir.{" "}
{/* o preenchimento vertical dá ao link 24 px de alvo de toque sem engordar a linha */}
<a
className="whitespace-nowrap py-1 underline underline-offset-2 hover:text-white hover:decoration-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
href="/privacidade"
>
Política de privacidade
</a>
{preferencesOpen && consent && (
<span className="sr-only">
{" "}
Escolha atual:{" "}
{consent === "granted" ? "permitido" : "recusado"}.
</span>
)}
</p>
<div className="flex shrink-0 gap-sp-2">
<button
type="button"
ref={acceptRef}
className={`${barButtonClassName} cv-fill border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] [--block:var(--slash)]`}
onClick={acceptAnalytics}
>
Aceitar
</button>
<button
type="button"
className={`${barButtonClassName} cv-outline-dark border-white/45 bg-transparent text-white`}
onClick={denyAnalytics}
>
Recusar
</button>
</div>
</div>
{consentError && (
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[15px] leading-5 text-[#ff9d94]"
>
{consentError}
</p>
)}
</section>
)}
</>
);
}
/**
* A barra de cookies e o consentimento aparecem em qualquer ambiente: no desenvolvimento a página fica igual à da
* produção (ordem do Tab, altura da barra, rodapé). Só o carregamento do gtag (o <Script> acima) fica preso à produção.
*/
export default function GoogleAnalytics() {
return <GoogleAnalyticsConsent />;
}
@@ -1,67 +0,0 @@
/**
* @jest-environment-options {"url": "https://www.evandro.dev.br/"}
*/
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { openCookiePreferences } from "@/lib/analytics";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Caminho de GoogleAnalytics (expireAnalyticsCookies) quando o site roda em um subdomínio de evandro.dev.br:
* o cookie _ga é expirado no host e também no domínio raiz, cada um com seu atributo Domain.
*/
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it("path: host is a subdomain of the site -> expires _ga on the path, on the host and on the root domain", async () => {
const user = userEvent.setup();
expect(window.location.hostname).toBe("www.evandro.dev.br");
setCookie("_ga=1; Path=/");
render(
<>
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const cookieWrites = jest.spyOn(document, "cookie", "set");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await act(async () => {});
await user.click(screen.getByRole("button", { name: "Recusar" }));
const writes = cookieWrites.mock.calls.map(([value]) => String(value));
expect(writes).toHaveLength(3);
expect(writes[0]).toMatch(/^_ga=; Max-Age=0;/);
expect(writes[0]).not.toContain("Domain=");
expect(writes[1]).toMatch(/; Domain=www\.evandro\.dev\.br$/);
expect(writes[2]).toMatch(/; Domain=evandro\.dev\.br$/);
});
-393
View File
@@ -1,393 +0,0 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { COOKIE_BAR_ID } from "@/lib/analytics";
import { Header } from "./Header";
/**
* O menu do celular aberto (complemento de Header.test.tsx): botão de contato acima da barra de cookies, fecha ao
* trocar de rota e ao tocar no logo, deixa o conteúdo e o rodapé inertes, trava a rolagem, prende o foco e fecha
* com Esc (ouvido no document), devolvendo o foco ao botão do menu.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
/** O cabeçalho com as duas partes da página que o menu torna inertes: o conteúdo e o rodapé (ids do layout). */
function Page() {
return (
<>
<Header />
<div id="main-content" tabIndex={-1}>
<button type="button">Botão do conteúdo</button>
</div>
<div id="site-footer">
<a href="#privacidade">Privacidade</a>
</div>
</>
);
}
const main = () => document.getElementById("main-content") as HTMLElement;
const footer = () => document.getElementById("site-footer") as HTMLElement;
const menuButton = () =>
screen.getByRole("button", { name: /^(Abrir|Fechar) menu$/ });
const openMenu = () =>
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileMenu = () =>
screen.queryByRole("navigation", { name: "Navegação mobile" });
/** Os controles que o Tab percorre com o menu aberto: logo, botão do menu, os cinco links e o botão de contato do painel. */
function trapOrder() {
const panel = within(mobileMenu() as HTMLElement);
return [
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
menuButton(),
...panel.getAllByRole("link"),
];
}
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
document.documentElement.style.removeProperty("overflow");
});
afterEach(() => {
document.documentElement.style.removeProperty("overflow");
});
describe("Header: menu do celular e a barra de cookies", () => {
it("adds the height of the cookie bar to the bottom spacing, so the contact button stays above it", () => {
render(<Page />);
openMenu();
const panel = (mobileMenu() as HTMLElement).parentElement
?.parentElement as HTMLElement;
expect(panel.className).toContain(
"pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))]",
);
});
});
describe("Header: o menu fecha sozinho", () => {
it("closes when the route changes", () => {
const { rerender } = render(<Page />);
openMenu();
expect(mobileMenu()).toBeInTheDocument();
jest.mocked(usePathname).mockReturnValue("/cases");
rerender(<Page />);
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
expect(main()).not.toHaveAttribute("inert");
});
it("stays open on a re-render that keeps the same route", () => {
const { rerender } = render(<Page />);
openMenu();
rerender(<Page />);
expect(mobileMenu()).toBeInTheDocument();
});
it("closes when the logo is tapped", () => {
render(<Page />);
openMenu();
fireEvent.click(
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
);
expect(mobileMenu()).toBeNull();
expect(main()).not.toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("");
});
it("closes when the window grows to the desktop layout, where the menu button no longer exists", () => {
const original = window.matchMedia;
let onChange: ((event: { matches: boolean }) => void) | undefined;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: (_type: string, listener: typeof onChange) => {
onChange = listener;
},
removeEventListener: jest.fn(),
}));
try {
render(<Page />);
openMenu();
expect(window.matchMedia).toHaveBeenCalledWith("(min-width: 1280px)");
act(() => onChange?.({ matches: false }));
expect(mobileMenu()).toBeInTheDocument();
act(() => onChange?.({ matches: true }));
expect(mobileMenu()).toBeNull();
expect(document.documentElement.style.overflow).toBe("");
} finally {
window.matchMedia = original;
}
});
});
describe("Header: página inerte e sem rolagem com o menu aberto", () => {
it("makes the content and the footer inert and locks the page scroll while the menu is open, and undoes both on close", () => {
render(<Page />);
expect(main()).not.toHaveAttribute("inert");
openMenu();
expect(main()).toHaveAttribute("inert");
expect(footer()).toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("hidden");
// o próprio cabeçalho nunca fica inerte
expect(screen.getByRole("banner")).not.toHaveAttribute("inert");
fireEvent.click(menuButton());
expect(main()).not.toHaveAttribute("inert");
expect(footer()).not.toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("");
});
it("gives back the overflow the page already had", () => {
document.documentElement.style.overflow = "scroll";
render(<Page />);
openMenu();
expect(document.documentElement.style.overflow).toBe("hidden");
fireEvent.click(menuButton());
expect(document.documentElement.style.overflow).toBe("scroll");
});
it("works on a page without a footer wrapper (the parts that exist are still made inert)", () => {
render(<Header />);
document.body.insertAdjacentHTML(
"beforeend",
'<div id="main-content"></div>',
);
try {
openMenu();
expect(main()).toHaveAttribute("inert");
} finally {
main().remove();
}
});
it("undoes everything when the header unmounts with the menu open", () => {
const { unmount } = render(<Page />);
openMenu();
unmount();
expect(document.documentElement.style.overflow).toBe("");
});
it("releases the page before any other click handler of the document runs, so a link to #id finds its target focusable", () => {
render(<Page />);
openMenu();
const states: Array<{ inert: boolean; overflow: string }> = [];
const record = () =>
states.push({
inert: main().hasAttribute("inert"),
overflow: document.documentElement.style.overflow,
});
// o gerenciador de âncoras da home escuta o clique no document, na fase de captura
document.addEventListener("click", record, true);
try {
fireEvent.click(
within(mobileMenu() as HTMLElement).getByRole("link", {
name: "Sobre",
}),
);
} finally {
document.removeEventListener("click", record, true);
}
expect(states).toEqual([{ inert: false, overflow: "" }]);
expect(mobileMenu()).toBeNull();
});
it("does not release the page for a click outside the header", () => {
render(<Page />);
openMenu();
// com o conteúdo inerte o clique real nem chega; o teste dispara direto para provar que o ouvinte só reage ao cabeçalho
fireEvent.click(footer().querySelector("a") as HTMLElement);
expect(main()).toHaveAttribute("inert");
expect(mobileMenu()).toBeInTheDocument();
});
});
describe("Header: Esc e foco com o menu aberto", () => {
it("closes with Escape heard on the document, wherever the focus is, and puts the focus back on the menu button", () => {
render(<Page />);
openMenu();
act(() => document.body.focus());
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(false);
expect(mobileMenu()).toBeNull();
expect(main()).not.toHaveAttribute("inert");
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
expect(menuButton()).toHaveFocus();
});
it("ignores Escape while the menu is closed", () => {
render(<Page />);
// sem menu aberto o ouvinte nem existe: o evento segue sem ser cancelado
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(true);
expect(mobileMenu()).toBeNull();
});
it("traps Tab: from the last control it goes to the first, and Shift+Tab from the first goes to the last", () => {
render(<Page />);
openMenu();
const order = trapOrder();
const first = order[0];
const last = order[order.length - 1];
// o botão de contato do painel é o último; o da barra de desktop e a navegação de desktop ficam fora do ciclo
expect(last).toHaveAccessibleName("Falar sobre meu projeto");
expect(order).toHaveLength(1 + 1 + 5 + 1);
act(() => last.focus());
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
expect(first).toHaveFocus();
expect(fireEvent.keyDown(first, { key: "Tab", shiftKey: true })).toBe(
false,
);
expect(last).toHaveFocus();
});
it("lets Tab move normally between two controls of the cycle", () => {
render(<Page />);
openMenu();
const order = trapOrder();
act(() => order[2].focus());
expect(fireEvent.keyDown(order[2], { key: "Tab" })).toBe(true);
expect(fireEvent.keyDown(order[2], { key: "Tab", shiftKey: true })).toBe(
true,
);
});
it("brings the focus back into the cycle when it is outside it, in the direction of the key", () => {
render(<Page />);
openMenu();
const order = trapOrder();
act(() => document.body.focus());
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(false);
expect(order[0]).toHaveFocus();
act(() => document.body.focus());
expect(
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
).toBe(false);
expect(order[order.length - 1]).toHaveFocus();
});
it("does not trap Tab while the menu is closed", () => {
render(<Page />);
act(() => document.body.focus());
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
});
it("closes with the menu button and keeps the focus on it", () => {
render(<Page />);
openMenu();
act(() => menuButton().focus());
fireEvent.click(menuButton());
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveFocus();
});
});
describe("Header: a barra de cookies aberta entra na armadilha de foco", () => {
/** Como no layout (GoogleAnalytics.tsx): a barra vem antes do cabeçalho e não fica inerte; link da política, Aceitar e Recusar. */
function PageWithCookieBar() {
return (
<>
<section id={COOKIE_BAR_ID} role="dialog" aria-label="Cookies">
<p>
Usamos métricas de navegação do Google só se você permitir.{" "}
<a href="/privacidade">Política de privacidade</a>
</p>
<button type="button">Aceitar</button>
<button type="button">Recusar</button>
</section>
<Page />
</>
);
}
it("cycles through the policy link, Aceitar and Recusar before the header, and back from the contact button", () => {
render(<PageWithCookieBar />);
openMenu();
const policy = screen.getByRole("link", {
name: "Política de privacidade",
});
const accept = screen.getByRole("button", { name: "Aceitar" });
const decline = screen.getByRole("button", { name: "Recusar" });
const order = trapOrder();
const last = order[order.length - 1];
expect(policy).not.toHaveAttribute("inert");
expect(accept).not.toHaveAttribute("inert");
// do botão de contato (o último do cabeçalho) o Tab vai para o link da política, em vez de dar a volta no cabeçalho
act(() => last.focus());
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
expect(policy).toHaveFocus();
// entre o link, Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
expect(fireEvent.keyDown(policy, { key: "Tab" })).toBe(true);
act(() => accept.focus());
expect(fireEvent.keyDown(accept, { key: "Tab" })).toBe(true);
act(() => decline.focus());
expect(fireEvent.keyDown(decline, { key: "Tab" })).toBe(true);
// Shift+Tab do link da política (o primeiro do ciclo) volta ao botão de contato
act(() => policy.focus());
expect(fireEvent.keyDown(policy, { key: "Tab", shiftKey: true })).toBe(
false,
);
expect(last).toHaveFocus();
// e Shift+Tab de Aceitar ou de Recusar não é preso: o navegador anda um controle para trás
act(() => accept.focus());
expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
true,
);
});
it("closes with Escape and puts the focus back on the menu button, even from the cookie bar", () => {
render(<PageWithCookieBar />);
openMenu();
act(() => screen.getByRole("button", { name: "Recusar" }).focus());
fireEvent.keyDown(document, { key: "Escape" });
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveFocus();
});
});
-314
View File
@@ -1,314 +0,0 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { Header } from "./Header";
/**
* Testes de caminhos-base de Header (complemento de Header.test.tsx): a barra que escurece com a rolagem,
* as teclas do menu do celular que não o fecham e a rota ausente (fora do roteador).
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
function setScrollY(value: number) {
Object.defineProperty(window, "scrollY", {
value,
configurable: true,
writable: true,
});
}
const header = () => screen.getByRole("banner");
/** Rolada: borda visível e fundo opaco (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
const isDark = () => header().className.includes("border-white/12");
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
setScrollY(0);
});
afterEach(() => {
jest.restoreAllMocks();
setScrollY(0);
});
describe("Header: rolagem", () => {
it("path: page at the top -> transparent border and solid background", () => {
render(<Header />);
expect(isDark()).toBe(false);
expect(header()).toHaveClass("border-transparent");
});
it("path: scrolling down and back to the top -> bordered bar, then the plain one again, never with a backdrop blur", () => {
render(<Header />);
setScrollY(120);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(isDark()).toBe(true);
expect(header()).not.toHaveClass("border-transparent");
expect(header().className).not.toContain("backdrop-blur");
// outro evento de rolagem sem mudar de lado: nada muda
setScrollY(300);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(isDark()).toBe(true);
setScrollY(0);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(isDark()).toBe(false);
});
it("path: the background is fully opaque at the top and scrolled (no 95% fill: the titles showed through)", () => {
render(<Header />);
expect(header().className).toContain("bg-[var(--ink)]");
expect(header().className).not.toContain("bg-[var(--ink)]/");
setScrollY(120);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(header().className).toContain("bg-[var(--ink)]");
expect(header().className).not.toContain("bg-[var(--ink)]/");
});
it("path: page already scrolled when the header mounts -> starts with the bordered bar", () => {
setScrollY(500);
render(<Header />);
expect(isDark()).toBe(true);
});
it("path: unmount -> the scroll listener is removed", () => {
const remove = jest.spyOn(window, "removeEventListener");
const { unmount } = render(<Header />);
unmount();
expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function));
});
});
describe("Header: ícone do menu", () => {
it("path: open -> the outer bars move 10px (2px bar + 8px gap) to the middle and turn into a straight X; closed -> nothing moves", () => {
render(<Header />);
const button = screen.getByRole("button", { name: "Abrir menu" });
const bars = () =>
Array.from(button.querySelectorAll("span")) as HTMLElement[];
// 2 px de altura + 8 px de espaço entre as linhas = 10 px de centro a centro
expect(bars()[0]).toHaveClass("h-0.5");
expect(button).toHaveClass("gap-sp-2");
for (const bar of bars()) expect(bar.className).not.toMatch(/translate-y/);
fireEvent.click(button);
const open = bars();
expect(open).toHaveLength(3);
expect(open[0]).toHaveClass("translate-y-[10px]", "rotate-45");
expect(open[1]).toHaveClass("opacity-0");
expect(open[2]).toHaveClass("-translate-y-[10px]", "-rotate-45");
expect(open[0].className).not.toContain("6px");
expect(open[2].className).not.toContain("6px");
});
});
describe("Header: teclado no menu do celular", () => {
it("path: a key other than Escape inside the open menu -> the menu stays open and the key is not blocked", () => {
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const menu = screen.getByRole("navigation", { name: "Navegação mobile" });
// Tab entre dois controles do meio da lista: segue o caminho normal (o foco só é preso nas pontas)
const middle = within(menu).getByRole("link", { name: "Serviços" });
act(() => middle.focus());
// fireEvent devolve false só quando o evento foi cancelado (preventDefault)
expect(fireEvent.keyDown(middle, { key: "Tab" })).toBe(true);
expect(fireEvent.keyDown(menu, { key: "Enter" })).toBe(true);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-expanded",
"true",
);
expect(
screen.getByRole("navigation", { name: "Navegação mobile" }),
).toBeInTheDocument();
});
it("path: Escape on the menu button while the menu is open -> closes it and keeps the focus on the button", () => {
render(<Header />);
const button = screen.getByRole("button", { name: "Abrir menu" });
fireEvent.click(button);
act(() => button.focus());
expect(fireEvent.keyDown(button, { key: "Escape" })).toBe(false);
const closed = screen.getByRole("button", { name: "Abrir menu" });
expect(closed).toHaveAttribute("aria-expanded", "false");
expect(closed).toHaveFocus();
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).toBeNull();
});
it("path: Escape while the menu is already closed -> nothing opens", () => {
render(<Header />);
const button = screen.getByRole("button", { name: "Abrir menu" });
fireEvent.keyDown(button, { key: "Escape" });
expect(button).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).toBeNull();
});
});
describe("Header: menu aberto, caminhos de borda do foco, do clique e da janela", () => {
/** O cabeçalho com o conteúdo da página que o menu aberto torna inerte (id do layout). */
function Page() {
return (
<>
<Header />
<div id="main-content" tabIndex={-1}>
<button type="button">Botão do conteúdo</button>
</div>
</>
);
}
const openMenu = () =>
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const content = () => document.getElementById("main-content") as HTMLElement;
afterEach(() => {
document.documentElement.style.removeProperty("overflow");
});
it("path: Tab with no control left to focus in the header -> the key is not blocked and the focus does not move", () => {
render(<Page />);
openMenu();
const banner = screen.getByRole("banner");
for (const button of banner.querySelectorAll("button"))
button.setAttribute("disabled", "");
for (const link of banner.querySelectorAll("a"))
link.removeAttribute("href");
act(() => document.body.focus());
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
expect(
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
).toBe(true);
expect(document.body).toHaveFocus();
});
it("path: click whose target is not an element (the document itself) -> the page stays locked", () => {
render(<Page />);
openMenu();
fireEvent.click(document);
expect(content()).toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("hidden");
});
it("path: click on something inside the header that is not a link -> the page stays locked", () => {
render(<Page />);
openMenu();
fireEvent.click(
screen.getByRole("navigation", { name: "Navegação mobile" }),
);
expect(content()).toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("hidden");
});
it("path: click on a link inside the header -> the page is released at once", () => {
render(<Page />);
openMenu();
fireEvent.click(
within(
screen.getByRole("navigation", { name: "Navegação mobile" }),
).getByRole("link", { name: "Cases" }),
);
expect(content()).not.toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("");
});
it("path: no matchMedia at all in the browser -> the menu still opens and closes", () => {
const original = window.matchMedia;
window.matchMedia = undefined as unknown as typeof window.matchMedia;
try {
render(<Page />);
openMenu();
expect(
screen.getByRole("navigation", { name: "Navegação mobile" }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).toBeNull();
expect(content()).not.toHaveAttribute("inert");
} finally {
window.matchMedia = original;
}
});
it("path: matchMedia returns an object with no change listener (older browsers) -> the menu still opens and closes", () => {
const original = window.matchMedia;
window.matchMedia = jest.fn().mockReturnValue({
matches: false,
}) as unknown as typeof window.matchMedia;
try {
render(<Page />);
openMenu();
expect(
screen.getByRole("navigation", { name: "Navegação mobile" }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).toBeNull();
expect(content()).not.toHaveAttribute("inert");
} finally {
window.matchMedia = original;
}
});
});
describe("Header: rota ausente", () => {
it("path: usePathname gives null (outside the router) -> the header button goes to the home form", () => {
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
render(<Header />);
expect(
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
).toHaveAttribute("href", "/#contact");
});
});
-315
View File
@@ -1,315 +0,0 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import type { WhatsAppContexts } from "@/lib/whatsapp";
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
import { Header } from "./Header";
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-next-link="" data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const contexts: WhatsAppContexts = {
// o whatsappSubject do serviço (lib/site-content.ts): uma frase em minúsculas, que cabe em "Quero conversar sobre {assunto}."
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
cases: { "sev-modaintima": "SEV Moda Íntima" },
};
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
});
function whatsAppHref(link: HTMLElement) {
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
}
function HomeWithHeader() {
const onLayoutReady = useHomeFragmentNavigation();
useEffect(() => {
onLayoutReady();
}, [onLayoutReady]);
return (
<>
<Header />
<section id="about" aria-label="Sobre">
<h2>Sobre</h2>
</section>
<section id="contact" aria-label="Contato">
<h2>Contato</h2>
</section>
</>
);
}
describe("Header", () => {
it("links to commercial destinations without promoting the résumé", () => {
render(<Header />);
const navigation = screen.getByRole("navigation", {
name: "Navegação principal",
});
expect(
within(navigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual(["/cases", "/servicos", "/#process", "/#about", "/ia"]);
expect(
within(navigation)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual([
"Cases",
"Serviços",
"Como trabalho",
"Sobre",
"Revisão de app com IA",
]);
// "Projetos" saiu: duplicava "Cases"
expect(
within(navigation).queryByRole("link", { name: "Projetos" }),
).not.toBeInTheDocument();
expect(
within(navigation).queryByRole("link", { name: "Experiência" }),
).not.toBeInTheDocument();
const contactLink = screen
.getAllByRole("link")
.find((link) => link.getAttribute("href") === "/#contact");
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
expect(
screen.queryByRole("link", { name: /currículo|download cv/i }),
).not.toBeInTheDocument();
});
it("announces and controls the mobile menu", () => {
render(<Header />);
const menuButton = screen.getByRole("button", { name: "Abrir menu" });
expect(menuButton).not.toHaveAttribute("aria-controls");
expect(menuButton).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
fireEvent.click(menuButton);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-expanded",
"true",
);
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
expect(
document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
).toBe(mobileNavigation);
expect(
within(mobileNavigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"/cases",
"/servicos",
"/#process",
"/#about",
"/ia",
"/#contact",
]);
expect(
within(mobileNavigation).queryByRole("link", {
name: /currículo|download cv/i,
}),
).not.toBeInTheDocument();
const mobileLink = within(mobileNavigation).getByRole("link", {
name: "Sobre",
});
act(() => mobileLink.focus());
fireEvent.keyDown(mobileLink, { key: "Escape" });
const openButton = screen.getByRole("button", { name: "Abrir menu" });
expect(openButton).toHaveAttribute("aria-expanded", "false");
expect(document.activeElement).toBe(openButton);
});
it.each([
["/#about", "Sobre"],
["/#contact", "Contato"],
])(
"closes the mobile menu and preserves destination focus for %s",
(href, destination) => {
window.history.replaceState(null, "", "/");
render(<HomeWithHeader />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
const mobileLink = within(mobileNavigation)
.getAllByRole("link")
.find((link) => link.getAttribute("href") === href);
if (!mobileLink)
throw new Error(`Missing navigation destination: ${href}`);
act(() => mobileLink.focus());
fireEvent.click(mobileLink);
expect(
screen.getByRole("button", { name: "Abrir menu" }),
).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: destination })).toHaveFocus();
},
);
it("makes the mobile menu a full-screen opaque panel with large links and the contact button at the bottom", () => {
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
const panel = mobileNavigation.parentElement?.parentElement as HTMLElement;
expect(panel).toHaveClass("bg-[var(--ink)]");
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
const links = within(mobileNavigation).getAllByRole("link");
for (const link of links.slice(0, 5)) {
// sob o ponteiro e no foco, a barra que cresce sob o texto (globals.css)
expect(link).toHaveClass("text-[22px]", "min-h-14", "cv-bar");
}
const contact = links[5];
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
expect(contact.parentElement).toHaveClass("mt-auto");
// o botão verde ganha o bloco cor de papel, não só uma troca de cor
expect(contact).toHaveClass("cv-fill");
expect(contact.className).not.toMatch(/hover:bg-/);
});
it("uses next/link for every internal link and only prefetches on hover or focus", () => {
render(<Header />);
const navigation = screen.getByRole("navigation", {
name: "Navegação principal",
});
const internal = [
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
...within(navigation).getAllByRole("link"),
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
];
for (const link of internal) {
expect(link).toHaveAttribute("data-next-link");
expect(link.getAttribute("href")?.startsWith("/")).toBe(true);
}
// cabeçalho e logotipo: nada pré-carregado na abertura da página
const headerLinks = internal.slice(0, 1 + 5);
for (const link of headerLinks) {
expect(link).toHaveAttribute("data-prefetch", "false");
}
fireEvent.mouseEnter(headerLinks[1]);
expect(headerLinks[1]).toHaveAttribute("data-prefetch", "null");
expect(headerLinks[2]).toHaveAttribute("data-prefetch", "false");
fireEvent.focus(headerLinks[3]);
expect(headerLinks[3]).toHaveAttribute("data-prefetch", "null");
});
it("sends the header button to the home form on the home and on pages without a subject", () => {
for (const pathname of ["/", "/privacidade"]) {
jest.mocked(usePathname).mockReturnValue(pathname);
const { unmount } = render(<Header whatsAppContexts={contexts} />);
const contact = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
});
expect(contact).toHaveLength(1);
expect(contact[0]).toHaveAttribute("href", "/#contact");
unmount();
}
});
it.each([
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
[
"/cases",
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
],
["/servicos", "Quero conversar sobre sites, sistemas e aplicativos."],
[
"/curriculo",
"Quero conversar sobre um projeto, depois de ver o seu currículo.",
],
])(
"opens WhatsApp with the page subject in the header button on %s",
(pathname, sentence) => {
jest.mocked(usePathname).mockReturnValue(pathname);
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Chamar no WhatsApp",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
// "Falar sobre meu projeto" é só do formulário: o botão que abre o WhatsApp tem outro nome
expect(
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
).not.toBeInTheDocument();
expect(contact).toHaveAttribute("target", "_blank");
expect(whatsAppHref(contact)).toContain(sentence);
},
);
it("opens WhatsApp with the service in the header button on a service page", () => {
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Chamar no WhatsApp",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(contact).toHaveAttribute("target", "_blank");
expect(whatsAppHref(contact)).toContain(
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
);
expect(whatsAppHref(contact)).not.toContain("sobre Sites");
});
it("opens WhatsApp with the case in the header button on a case page, also from the mobile menu", () => {
jest.mocked(usePathname).mockReturnValue("/cases/sev-modaintima");
render(<Header whatsAppContexts={contexts} />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const buttons = screen.getAllByRole("link", {
name: "Chamar no WhatsApp",
});
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(whatsAppHref(button)).toContain(
"Quero conversar sobre um projeto parecido com o case SEV Moda Íntima.",
);
}
// abrir o WhatsApp pelo menu fecha o menu
fireEvent.click(buttons[1]);
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
});
it("falls back to a generic message on a service page the contexts do not know", () => {
jest.mocked(usePathname).mockReturnValue("/servicos/inexistente");
render(<Header whatsAppContexts={contexts} />);
expect(
whatsAppHref(screen.getByRole("link", { name: "Chamar no WhatsApp" })),
).toContain("Quero conversar sobre um projeto.");
});
});
-303
View File
@@ -1,303 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { COOKIE_BAR_ID } from "@/lib/analytics";
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
import {
headerOpensWhatsApp,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "@/lib/whatsapp";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
const MENU_ID = "site-navigation-menu";
const CTA_LABEL = "Falar sobre meu projeto";
/** Fora da home o botão abre o WhatsApp: "Falar sobre meu projeto" é sempre o formulário. */
const WHATSAPP_LABEL = "Chamar no WhatsApp";
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
const INERT_IDS = ["main-content", "site-footer"];
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
const DESKTOP_QUERY = "(min-width: 1280px)";
/**
* Controles que o Tab percorre com o menu aberto: todos os da barra de cookies, quando ela está aberta (o link "Política
* de privacidade", Aceitar e Recusar; ela vem antes do cabeçalho no layout e não fica inerte), e depois logo, botão do
* menu, links e botão de contato do painel.
* A navegação e o botão da barra de desktop ficam de fora (data-menu-trap-skip): estão escondidos no celular.
*/
function menuFocusables(header: HTMLElement) {
const cookieBar = document.getElementById(COOKIE_BAR_ID);
const roots = cookieBar ? [cookieBar, header] : [header];
return roots
.flatMap((root) =>
Array.from(
root.querySelectorAll<HTMLElement>("a[href], button:not([disabled])"),
),
)
.filter((element) => !element.closest("[data-menu-trap-skip]"));
}
/** Tab (e Shift+Tab) com o menu aberto: o foco dá a volta entre o cabeçalho e a barra de cookies, sem sair para a página inerte. */
function trapTab(event: KeyboardEvent, header: HTMLElement) {
const focusables = menuFocusables(header);
const first = focusables[0];
const last = focusables[focusables.length - 1];
const [edge, opposite] = event.shiftKey ? [first, last] : [last, first];
const active = document.activeElement;
const leaving =
active === edge || !focusables.some((element) => element === active);
if (!leaving || !opposite) return;
event.preventDefault();
opposite.focus();
}
/**
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
* já com o assunto da página, com o nome "Chamar no WhatsApp"; na home e nas demais páginas leva ao formulário da home
* ("Falar sobre meu projeto").
*/
function ContactCta({
className,
placement,
viaWhatsApp,
subject,
onNavigate,
}: {
className: string;
placement: string;
viaWhatsApp: boolean;
subject?: string;
onNavigate?: () => void;
}) {
if (viaWhatsApp) {
return (
<WhatsAppLink
className={className}
service={subject}
placement={placement}
onClick={onNavigate}
>
{WHATSAPP_LABEL}
</WhatsAppLink>
);
}
return (
<HoverPrefetchLink
href="/#contact"
className={className}
onClick={onNavigate}
>
{CTA_LABEL}
</HoverPrefetchLink>
);
}
export function Header({
whatsAppContexts,
}: {
/** Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx), para o WhatsApp falar da página aberta. */
whatsAppContexts?: WhatsAppContexts;
}) {
const pathname = usePathname();
const viaWhatsApp = headerOpensWhatsApp(pathname);
const subject = whatsAppSubjectForPath(pathname, whatsAppContexts);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const headerRef = useRef<HTMLElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
// Trocou a rota: o menu fecha (o cabeçalho vive no layout e não remonta entre as páginas).
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname só dispara o fechamento a cada página
useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
useEffect(() => {
let wasScrolled = false;
const updateScrolled = () => {
const nextScrolled = window.scrollY > 0;
if (nextScrolled === wasScrolled) return;
wasScrolled = nextScrolled;
setScrolled(nextScrolled);
};
updateScrolled();
window.addEventListener("scroll", updateScrolled, { passive: true });
return () => window.removeEventListener("scroll", updateScrolled);
}, []);
const closeMobileMenu = () => setMobileMenuOpen(false);
// Menu aberto: o conteúdo e o rodapé ficam inertes, a página não rola, o Tab não sai do cabeçalho e o Esc fecha.
useEffect(() => {
const header = headerRef.current;
const menuButton = menuButtonRef.current;
if (!mobileMenuOpen || !header || !menuButton) return;
const root = document.documentElement;
const inertTargets = INERT_IDS.flatMap((id) => {
const element = document.getElementById(id);
return element ? [element] : [];
});
for (const element of inertTargets) element.setAttribute("inert", "");
const previousOverflow = root.style.overflow;
root.style.overflow = "hidden";
// Devolve a página ao normal na hora, sem esperar o React: um link do menu para #id precisa achar o destino
// focável quando o gerenciador de âncoras da home (useHomeFragmentNavigation) tenta focá-lo no clique.
let released = false;
const release = () => {
if (released) return;
released = true;
for (const element of inertTargets) element.removeAttribute("inert");
root.style.overflow = previousOverflow;
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Tab") return trapTab(event, header);
if (event.key !== "Escape") return;
event.preventDefault();
release();
setMobileMenuOpen(false);
menuButton.focus();
};
// Na fase de captura da janela, antes do document: roda antes do gerenciador de âncoras da home.
const onLinkClick = (event: MouseEvent) => {
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (link && header.contains(link)) release();
};
const desktop = window.matchMedia?.(DESKTOP_QUERY);
const onViewportChange = (event: MediaQueryListEvent) => {
if (!event.matches) return;
release();
setMobileMenuOpen(false);
};
document.addEventListener("keydown", onKeyDown);
window.addEventListener("click", onLinkClick, true);
desktop?.addEventListener?.("change", onViewportChange);
return () => {
release();
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("click", onLinkClick, true);
desktop?.removeEventListener?.("change", onViewportChange);
};
}, [mobileMenuOpen]);
return (
<header
ref={headerRef}
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
scrolled
? "border-white/12 bg-[var(--ink)]"
: "border-transparent bg-[var(--ink)]"
}`}
>
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
<HoverPrefetchLink
href="/#home"
aria-label="Evandro.dev.br — início"
className="flex min-h-11 shrink-0 items-center no-underline"
onClick={closeMobileMenu}
>
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
</HoverPrefetchLink>
<nav
aria-label="Navegação principal"
data-menu-trap-skip=""
className="hidden items-center xl:flex"
>
<ul className="flex list-none items-center gap-sp-4">
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href}>
<HoverPrefetchLink
href={item.href}
className="cv-bar inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline [--bar-x:var(--sp-3)] hover:text-white"
>
{item.label}
</HoverPrefetchLink>
</li>
))}
</ul>
</nav>
<div className="flex shrink-0 items-center">
<div className="hidden xl:block" data-menu-trap-skip="">
<ContactCta
className="cv-fill inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
placement="header"
viaWhatsApp={viaWhatsApp}
subject={subject}
/>
</div>
<button
ref={menuButtonRef}
type="button"
className="flex h-11 w-11 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] xl:hidden"
// as linhas têm 2 px (h-0.5) e 8 px de espaço (gap-sp-2): de uma a outra são 10 px, e as de cima e de baixo andam 10 px até o meio, para o X fechar sem torcer
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
aria-expanded={mobileMenuOpen}
>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[10px] rotate-45" : ""}`}
/>
<span
className={`h-0.5 w-5 bg-white transition-opacity duration-300 ${mobileMenuOpen ? "opacity-0" : ""}`}
/>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "-translate-y-[10px] -rotate-45" : ""}`}
/>
</button>
</div>
</div>
{mobileMenuOpen ? (
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
<div className="ds-enter absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] [--enter-y:-8px] lg:px-sp-8 xl:hidden">
<div className="ds-section-shell flex flex-1 flex-col">
<nav
id={MENU_ID}
aria-label="Navegação mobile"
className="flex flex-1 flex-col"
>
<ul className="flex list-none flex-col">
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href} className="border-b border-white/10">
<HoverPrefetchLink
href={item.href}
className="cv-bar flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline [--bar-y:100%] hover:text-white"
onClick={closeMobileMenu}
>
{item.label}
</HoverPrefetchLink>
</li>
))}
</ul>
<div className="mt-auto pt-sp-8">
<ContactCta
className="cv-fill flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
placement="header_menu"
viaWhatsApp={viaWhatsApp}
subject={subject}
onNavigate={closeMobileMenu}
/>
</div>
</nav>
</div>
</div>
) : null}
</header>
);
}
-152
View File
@@ -1,152 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import Hero from "./Hero";
/**
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): anos de experiência (sem stats, sem anos, zero anos, com anos),
* links sociais (externo x mailto, com rastreio) e faixa de clientes.
*/
jest.mock("./cinematic/HeroField", () => ({
__esModule: true,
default: () => <div data-testid="hero-field" />,
}));
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
window.gtag = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
describe("Hero: anos de experiência (na linha de clientes)", () => {
it.each([
["no stats at all", undefined],
["stats without years", {}],
["zero years", { years: 0 }],
])("path: %s -> no experience text", (_label, stats) => {
const { container } = render(<Hero stats={stats} />);
expect(container.querySelector("dl")).toBeNull();
expect(screen.queryByText("anos de experiência")).toBeNull();
});
it("path: years given -> the number and its label sit in the same line as the clients", () => {
const { container } = render(
<Hero
stats={{ years: 7 }}
clients={[{ id: "lavacao-047", name: "Lavação 047" }]}
/>,
);
expect(container.querySelector("dl")).toBeNull();
const strip = screen.getByText("Já entregue para:").parentElement;
expect(strip).toHaveTextContent("Lavação 047");
expect(strip).toHaveTextContent("7 anos de experiência");
});
it("path: years given but no clients -> only the experience text, with no 'Já entregue para'", () => {
render(<Hero stats={{ years: 7 }} />);
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
expect(screen.queryByText("Já entregue para:")).toBeNull();
});
});
describe("Hero: links sociais", () => {
it("path: LinkedIn (http link) -> new tab with noopener and a social_click", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: "LinkedIn" });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"social_click",
expect.objectContaining({ network: "linkedin" }),
);
});
it("path: e-mail (mailto link) -> same tab, no rel, and an email_click from the hero", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: "Email" });
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "hero" }),
);
});
});
describe("Hero: faixa de clientes e trabalho em destaque", () => {
it("path: clients given -> the strip links each client to its case", () => {
render(<Hero clients={[{ id: "lavacao-047", name: "Lavação 047" }]} />);
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Lavação 047" })).toHaveAttribute(
"href",
"/cases/lavacao-047",
);
});
it("path: no clients -> no strip", () => {
render(<Hero />);
expect(screen.queryByText("Já entregue para:")).toBeNull();
});
it("path: the WhatsApp link carries the id that hides the floating WhatsApp; the form button does not", () => {
render(<Hero />);
expect(
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
).toHaveAttribute("id", "hero-contact-cta");
expect(
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
).not.toHaveAttribute("id");
});
});
describe("Hero: contato", () => {
it("path: 'Chamar no WhatsApp' -> a whatsapp_open from the hero, with the home subject", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: "Chamar no WhatsApp" });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({
placement: "hero",
service: "um site ou sistema para a minha empresa",
}),
);
});
it("path: 'Falar sobre meu projeto' -> the form, with no WhatsApp event", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: /Falar sobre meu projeto/ });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(link).toHaveAttribute("href", "/#contact-form");
expect(window.gtag).not.toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.anything(),
);
});
});
@@ -1,43 +0,0 @@
import { act } from "@testing-library/react";
import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import Hero from "./Hero";
afterEach(() => {
jest.restoreAllMocks();
});
it.each([false, true])(
"hydrates without attribute mismatches when reduced motion resolves to %s",
async (reducedMotion) => {
jest.spyOn(window, "matchMedia").mockImplementation((media) => ({
media,
matches: media === "(prefers-reduced-motion: reduce)" && reducedMotion,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
const container = document.createElement("div");
container.innerHTML = renderToString(<Hero />);
document.body.append(container);
const consoleErrors = jest.spyOn(console, "error").mockImplementation();
const recoverableErrors: unknown[] = [];
let root: Root | undefined;
try {
await act(async () => {
root = hydrateRoot(container, <Hero />, {
onRecoverableError: (error) => recoverableErrors.push(error),
});
});
expect(recoverableErrors).toEqual([]);
expect(consoleErrors.mock.calls).toEqual([]);
} finally {
await act(async () => root?.unmount());
container.remove();
}
},
);
-262
View File
@@ -1,262 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import Hero from "./Hero";
import { HERO_CAPTIONS } from "./hero-captions";
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const clients = [
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
{ id: "lavacao-047", name: "Lavação 047" },
{ id: "sc-plus", name: "SC Plus Aquicultura" },
{ id: "as-servicos", name: "AS Serviços" },
];
describe("Hero", () => {
beforeEach(() => {
jest.spyOn(window, "open").mockImplementation(() => null);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("sends the main button to the contact form, keeps 'Ver projetos entregues' secondary and opens WhatsApp from a quiet link after them", () => {
render(<Hero />);
const contactLink = screen.getByRole("link", {
name: /falar sobre meu projeto/i,
});
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
expect(contactLink).toHaveAttribute("href", "/#contact-form");
expect(contactLink).not.toHaveAttribute("target");
// o botão primário é o único com o fundo limão (e o bloco cor de papel no ponteiro); o secundário é contorno
expect(contactLink.className).toContain("bg-[var(--slash)]");
expect(contactLink).toHaveClass("cv-fill");
expect(contactLink.className).not.toMatch(/hover:bg-/);
const projects = screen.getByRole("link", { name: /ver projetos/i });
expect(projects).toHaveAttribute("href", "/cases");
expect(projects.className).not.toContain("bg-[var(--slash)]");
expect(projects).toHaveClass("cv-outline-dark");
expect(projects).not.toHaveAttribute("target");
const whatsApp = screen.getByRole("link", { name: "Chamar no WhatsApp" });
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(whatsApp).toHaveAttribute("target", "_blank");
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
const text = new URL(whatsApp.getAttribute("href") ?? "").searchParams.get(
"text",
);
expect(text).toBe(
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
);
expect(whatsApp.className).not.toContain("bg-[var(--slash)]");
expect(whatsApp.querySelector("svg")).toHaveAttribute(
"aria-hidden",
"true",
);
// a ordem do Tab: os dois botões e depois o link
expect(
contactLink.compareDocumentPosition(whatsApp) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
projects.compareDocumentPosition(whatsApp) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the headline as one readable sentence despite the per-word animation", () => {
render(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha.",
);
});
it("highlights only 'sob medida' in lime, kept on one line under one continuous marker block", () => {
render(<Hero />);
const heading = screen.getByRole("heading", { level: 1 });
const highlighted = Array.from(
heading.querySelectorAll<HTMLElement>(".hero-word"),
).filter((word) => word.className.includes("text-[var(--slash)]"));
expect(highlighted.map((word) => word.textContent)).toEqual([
"sob",
"medida",
]);
// um trecho só, que não quebra entre as duas palavras e leva o marcador; cada palavra mantém a sua entrada
const marks = heading.querySelectorAll(".hero-mark");
expect(marks).toHaveLength(1);
expect(marks[0]).toHaveClass("whitespace-nowrap");
expect(marks[0].textContent).toBe("sob medida");
for (const word of highlighted) expect(marks[0]).toContainElement(word);
});
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
render(<Hero />);
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
"href",
"https://www.linkedin.com/in/evandrodevbr/",
);
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
"href",
"mailto:[email protected]",
);
const linkedin = screen.getByRole("link", { name: "LinkedIn" });
expect(linkedin).toHaveAttribute("target", "_blank");
expect(linkedin).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
}
});
it("states who the work is for and how the contact works", () => {
render(<Hero />);
expect(
screen.getByText(
"Do site institucional ao sistema que a sua equipe usa todo dia. Você fala com quem desenvolve.",
),
).toBeInTheDocument();
// o primeiro passo, logo abaixo dos botões
const step = screen.getByText(
"Começa com uma conversa para entender o seu caso. A proposta vem por escrito.",
);
expect(step.className).toContain("text-[15px]");
expect(
screen
.getByRole("link", { name: "Chamar no WhatsApp" })
.compareDocumentPosition(step) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
// o botão flutuante do WhatsApp observa este id: some enquanto o link do hero está à vista
expect(
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
).toHaveAttribute("id", "hero-contact-cta");
});
it("shows only the years of experience, with no repository numbers", () => {
render(<Hero stats={{ years: 5 }} />);
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.queryByRole("term")).toBeNull();
expect(screen.queryByText(/projetos desenvolvidos/i)).toBeNull();
expect(screen.queryByText(/públicos|privados|mais de/i)).toBeNull();
});
it("lists the delivered clients, each linking to its case", () => {
render(<Hero clients={clients} />);
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
for (const { id, name } of clients) {
expect(screen.getByRole("link", { name })).toHaveAttribute(
"href",
`/cases/${id}`,
);
}
});
it("shows the block piece as a decorative poster: hidden from assistive tech, with an empty alt", () => {
const { container } = render(<Hero clients={clients} />);
const piece = container.querySelector(".hero-piece");
expect(piece).not.toBeNull();
expect(piece).toHaveAttribute("aria-hidden", "true");
const image = piece?.querySelector("img");
expect(image?.getAttribute("src")).toContain("hero-poster");
expect(image).toHaveAttribute("alt", "");
});
it("shows a wide card to the SEV case below the buttons on small screens", () => {
render(<Hero clients={clients} />);
const small = screen.getByTestId("hero-work-mobile");
expect(small).toHaveClass("lg:hidden");
expect(small).toHaveAttribute("href", "/cases/sev-modaintima");
expect(small).toHaveAccessibleName("Site da SEV Moda Íntima Ver case");
const buttons = screen.getByRole("link", { name: /ver projetos/i });
expect(
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
const { container } = render(<Hero />);
const label = container.querySelector(".hero-kicker") as HTMLElement;
expect(label.textContent).toBe(
"Evandro Fonseca Junior · Engenharia de software",
);
expect(label.className).toContain("font-display");
expect(label.className).not.toContain("font-mono");
// sem caixa alta nem espaçamento largo
expect(label.className).not.toMatch(/uppercase|tracking-\[0\.1/);
});
it("shows only 'Engenharia de software' in the label below 1024 px, where it shares the line with the piece", () => {
render(<Hero />);
expect(screen.getByText(/Evandro Fonseca Junior/)).toHaveClass(
"max-lg:hidden",
);
expect(screen.getByText(/Engenharia de software/).className).not.toContain(
"hidden",
);
});
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
const { container } = render(
<Hero clients={clients} stats={{ years: 5 }} />,
);
expect(container.querySelector(".font-mono")).toBeNull();
});
it("pre-fetches 'Ver projetos entregues', the clients and the SEV card only when reached (hover, focus or touch), never by viewport", () => {
render(<Hero clients={clients} />);
const links = [
screen.getByRole("link", { name: /ver projetos/i }),
screen.getByRole("link", { name: "SEV Moda Íntima" }),
screen.getByTestId("hero-work-mobile"),
];
for (const link of links) {
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
}
});
it("writes the range of each dive caption (--from and --to, from hero-captions.ts) on its own item, in order", () => {
const { container } = render(<Hero clients={clients} />);
const items = Array.from(
container.querySelectorAll<HTMLElement>(".hero-captions li"),
);
expect(items).toHaveLength(HERO_CAPTIONS.length);
items.forEach((item, index) => {
const { title, text, from, to } = HERO_CAPTIONS[index];
expect(item).toHaveTextContent(`${title}${text}`);
expect(item.style.getPropertyValue("--from")).toBe(String(from));
expect(item.style.getPropertyValue("--to")).toBe(String(to));
});
});
});
-273
View File
@@ -1,273 +0,0 @@
"use client";
import Image from "next/image";
import { type CSSProperties, Fragment } from "react";
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import { textLinkDark } from "./button-styles";
import ClientStrip, { type ClientLink } from "./ClientStrip";
import HeroField from "./cinematic/HeroField";
import { CONTACT_FORM_HASH } from "./contact-subject";
import { HERO_CTA_ID } from "./FloatingWhatsApp";
import HeroWork from "./HeroWork";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { HERO_CAPTIONS } from "./hero-captions";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
const SOCIAL_LINKS = [
{
href: LINKEDIN_URL,
label: "LinkedIn",
onClick: () => trackSocialClick({ network: "linkedin" }),
},
{
href: `mailto:${CONTACT_EMAIL}`,
label: "Email",
onClick: () => trackEmailClick({ placement: "hero" }),
},
];
/**
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json
* e aparecem na mesma linha de "Já entregue para" (ClientStrip).
*/
export type HeroStats = {
years?: number;
};
/** O destaque em limão fica num trecho curto: "sob medida" (palavras 3 e 4). */
const ACCENT = new Set([3, 4]);
type TitleWord = { word: string; key: string; index: number };
/**
* Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. As palavras
* do destaque seguidas viram um trecho só: elas nunca se separam numa quebra de linha, e o bloco do marcador as cobre
* de uma vez.
*/
const TITLE =
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
.split(" ")
.reduce<{ key: string; accent: boolean; words: TitleWord[] }[]>(
(runs, word, index) => {
const accent = ACCENT.has(index);
// a mesma palavra se repete ("e"), então a posição entra na chave
const entry = { word, key: `${word}-${index}`, index };
const last = runs.at(-1);
if (accent && last?.accent) last.words.push(entry);
else runs.push({ key: entry.key, accent, words: [entry] });
return runs;
},
[],
);
function TitleWords({
words,
accent,
}: {
words: TitleWord[];
accent: boolean;
}) {
return words.map(({ word, key, index }, position) => (
<Fragment key={key}>
{position > 0 && " "}
<span
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>
</Fragment>
));
}
export default function Hero({
stats,
clients,
}: {
stats?: HeroStats;
clients?: ClientLink[];
}) {
return (
<section
id="home"
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
>
<div className="hero-stage">
<div className="hero-glow" aria-hidden="true" />
<div className="hero-scrim" aria-hidden="true" />
{/* a cena 3D fica acima do véu (o mesmo brilho do pôster) e abaixo das legendas e do texto */}
<HeroField />
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
<ol className="hero-captions">
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
<li
key={title}
style={
{ "--i": index, "--from": from, "--to": to } as CSSProperties
}
>
<span className="hero-caption-title">{title}</span>
{text}
</li>
))}
</ol>
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)]">
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
{/* Abaixo de 1024 px o rótulo divide a linha com a peça: fica só a profissão, e o nome volta na tela larga. */}
<p className="hero-in hero-kicker mb-sp-4 flex items-center gap-sp-3 font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]">
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 bg-[var(--slash)]"
/>
<span>
<span className="max-lg:hidden">Evandro Fonseca Junior · </span>
Engenharia de software
</span>
</p>
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
{/* No desktop o texto fica na coluna da esquerda (--hero-col, em globals.css); a peça (.hero-piece) vem logo à direita. */}
<h1 className="hero-title hero-col max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em]">
{TITLE.map(({ key, accent, words }) => (
<Fragment key={key}>
{accent ? (
// o marcador (::after) vive no trecho e não em cada palavra: um bloco contínuo sobre "sob medida"
<span
className="hero-mark whitespace-nowrap"
style={{ "--i": words[0].index } as CSSProperties}
>
<TitleWords words={words} accent />
</span>
) : (
<TitleWords words={words} accent={false} />
)}{" "}
</Fragment>
))}
</h1>
<p className="hero-in hero-col mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.4s]">
Do site institucional ao sistema que a sua equipe usa todo dia. Você
fala com quem desenvolve.
</p>
<div className="hero-in hero-col hero-actions mt-sp-5 flex flex-col gap-sp-3 [--d:.48s] sm:flex-row sm:flex-wrap sm:items-center">
{/* O formulário, como o botão do cabeçalho; quem prefere o WhatsApp tem o link logo depois dos botões. */}
<HoverPrefetchLink
href={`/${CONTACT_FORM_HASH}`}
className="cv-fill group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline sm:px-sp-6 sm:text-[16px]"
>
Falar sobre meu projeto
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</HoverPrefetchLink>
<HoverPrefetchLink
href="/cases"
className="cv-outline-dark inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline sm:text-[16px]"
>
Ver projetos entregues
</HoverPrefetchLink>
{/* Abre o WhatsApp com o assunto da home; enquanto está à vista, o botão flutuante (que observa este id) some. */}
<WhatsAppLink
id={HERO_CTA_ID}
service={HOME_SUBJECT}
placement="hero"
className={`${textLinkDark} gap-sp-2 self-start sm:self-auto`}
>
<WhatsAppMark className="h-4 w-4 shrink-0" />
Chamar no WhatsApp
</WhatsAppLink>
</div>
{/* O primeiro passo, dito antes de a pessoa clicar: tira a dúvida de "e depois, o que acontece?". */}
<p className="hero-in hero-col mt-sp-3 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] [--d:.54s]">
Começa com uma conversa para entender o seu caso. A proposta vem por
escrito.
</p>
<ClientStrip
clients={clients}
years={stats?.years}
className="hero-in hero-col mt-sp-3 [--d:.6s]"
/>
<HeroWork />
{/* A marca em blocos: pôster renderizado no Blender, decorativo. Carrega junto com a página (eager, sem prioridade
alta). O título segue como o maior elemento medido (LCP) no celular e em tela larga: a imagem entra com
fade a partir de opacidade zero e some por `visibility` quando a cena assume (globals.css). */}
<div className="hero-piece hero-in [--d:.3s]" aria-hidden="true">
<Image
src="/3d/hero-poster.webp"
alt=""
width={1200}
height={1200}
sizes="(min-width: 1024px) min(56vw, 816px), 30vw"
loading="eager"
className="block h-auto w-full"
/>
</div>
</div>
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5">
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo; no
computador ficam numa linha só, para o palco fixo caber em 1024x768 */}
<div className="flex-wrap items-center gap-x-sp-8 max-lg:hidden lg:flex">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
(service) => (
<li key={service} className="flex items-center gap-sp-2">
<span
aria-hidden="true"
className="h-1 w-1 bg-[var(--slash)]"
/>
{service}
</li>
),
)}
</ul>
<div className="flex flex-wrap items-center gap-x-sp-5">
{SOCIAL_LINKS.map((link) => (
<a
key={link.label}
href={link.href}
target={link.href.startsWith("http") ? "_blank" : undefined}
rel={
link.href.startsWith("http")
? "noopener noreferrer"
: undefined
}
onClick={link.onClick}
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
>
{link.label}
</a>
))}
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
Santa Catarina · Atendimento remoto
</span>
</div>
</div>
</div>
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-display text-[13px] tracking-[0.02em] text-[rgba(244,242,237,.55)] xl:flex"
>
Role para mergulhar
<span className="hero-cue-line" />
</p>
</div>
</div>
</section>
);
}
-95
View File
@@ -1,95 +0,0 @@
import { render, screen } from "@testing-library/react";
import HeroWork from "./HeroWork";
/**
* O pôster do hero: pelo next/image com `sizes` do tamanho em que aparece, lazy e com prioridade baixa (o título é o
* LCP), no cartão largo e clicável da SEV para as telas abaixo de 1024 px. Em tela larga a coluna é da peça 3D.
*/
const original = (image: Element) =>
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
"url",
);
describe("HeroWork: pôsteres no tamanho certo", () => {
it("serves the SEV poster through the image optimizer, with a `sizes` that matches how it shows", () => {
render(<HeroWork />);
// o cartão do celular usa o pôster de celular da SEV (o título em letras grandes cabe na faixa de 72 px), na largura
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
const small = screen
.getByTestId("hero-work-mobile")
.querySelector("img") as HTMLImageElement;
expect(original(small)).toBe("/videos/sev-modaintima/mobile.webp");
expect(small).toHaveAttribute(
"sizes",
"(min-width: 1024px) 1px, calc(100vw - 48px)",
);
expect(small).toHaveAttribute("width", "390");
expect(small).toHaveAttribute("height", "844");
});
it("shows the headline of the SEV poster in the 72px strip of the mobile card: the position moves the strip to the line 'Nova Coleção' instead of the top of the poster", () => {
render(<HeroWork />);
const small = screen
.getByTestId("hero-work-mobile")
.querySelector("img") as HTMLImageElement;
expect(small).toHaveClass("object-cover", "object-[50%_57%]");
expect(small).not.toHaveClass("object-top");
// a faixa continua pequena (o título da página segue como o maior elemento da dobra)
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
});
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
const { container } = render(<HeroWork />);
const images = container.querySelectorAll("img");
expect(images).toHaveLength(1);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("fetchpriority", "low");
}
// sem <link rel="preload">: o next/image só o insere com `preload` (ou fetchPriority="high")
expect(document.head.querySelector('link[rel="preload"]')).toBeNull();
});
});
describe("HeroWork: cartão do celular", () => {
it("is the whole component: a wide card with the SEV poster, only below 1024px", () => {
const { container } = render(<HeroWork />);
const card = screen.getByTestId("hero-work-mobile");
expect(container.firstChild).toBe(card);
expect(container.childElementCount).toBe(1);
expect(card).toHaveClass("lg:hidden", "w-full");
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
expect(card.querySelector(".ds-phone")).toBeNull();
const imageBox = card.querySelector("img")?.parentElement as HTMLElement;
expect(imageBox).toHaveClass("w-full");
expect(imageBox.style.height).toBe("72px");
expect(card).toHaveTextContent("Site da SEV Moda Íntima");
expect(card).toHaveTextContent("Ver case");
});
it("is a link to the SEV case, with a touch target of at least 48px and its own focus ring", () => {
render(<HeroWork />);
const card = screen.getByRole("link", {
name: "Site da SEV Moda Íntima Ver case",
});
expect(card).toBe(screen.getByTestId("hero-work-mobile"));
expect(card).toHaveAttribute("href", "/cases/sev-modaintima");
expect(card).not.toHaveClass("pointer-events-none");
expect(card.className).toContain("focus-visible:outline-2");
expect(card.querySelector("span.min-h-12")).not.toBeNull();
// a imagem é decorativa: o texto do link já diz o que ela mostra
expect(card.querySelector("img")).toHaveAttribute("alt", "");
});
it("draws no other link: the card is the only one in HeroWork", () => {
render(<HeroWork />);
expect(screen.getAllByRole("link")).toHaveLength(1);
});
});
-73
View File
@@ -1,73 +0,0 @@
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import HoverPrefetchLink from "./HoverPrefetchLink";
/** O cartão mostra o site da SEV e leva ao case. */
const SMALL_SCREEN_PROJECT = "sev-modaintima";
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
/**
* Altura da imagem do cartão de celular (px): a largura é a da coluna (cerca de 342 px num celular de 390 px). Com 72 px
* a área da imagem (cerca de 25 mil px²) fica abaixo da do H1 (cerca de 29 mil px² a 390 px): com 96 px ela era maior, e
* numa rede lenta a imagem, que chega depois, passava a ser o LCP no lugar do título (medido com rede e CPU limitadas).
*/
const SMALL_CARD_IMAGE_HEIGHT = 72;
/**
* Faixa de 72 px de altura: o pôster do computador (16:10) cortado nela mostrava só uma fatia do topo, e o título
* "Nova Coleção" saía pela metade. O pôster do celular (390x844, o mesmo site) tem o título em letras maiores: o
* `object-position` leva a faixa até a linha "Nova Coleção", que cabe inteira nela.
*/
const SMALL_CARD_IMAGE_POSITION = "object-[50%_57%]";
/**
* Trabalho real do hero, só abaixo de 1024 px: um cartão largo e clicável (a largura da coluna, cerca de 350 px num
* celular) com o pôster do site da SEV, abaixo dos botões, que leva ao case da SEV; antes era uma moldura de celular
* de 65 por 160 px, pequena demais para ver o que era. Em telas largas a coluna da direita é da peça 3D, e o trabalho
* real começa na seção Projetos.
* O pôster vai pelo next/image com `sizes` do tamanho em que aparece, em `loading="lazy"` e prioridade baixa: o título
* segue como o maior elemento da dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e em tela larga, onde o cartão fica
* escondido pelo breakpoint (display: none), a imagem nem é baixada.
*/
export default function HeroWork() {
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
return (
<HoverPrefetchLink
data-testid="hero-work-mobile"
href={SMALL_SCREEN_HREF}
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
>
<span
className="relative block w-full overflow-hidden bg-[var(--ink)]"
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
>
<Image
src={smallCard.poster}
alt=""
width={smallCard.width}
height={smallCard.height}
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
loading="lazy"
fetchPriority="low"
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
/>
</span>
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
<span className="flex flex-col">
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</span>
</HoverPrefetchLink>
);
}
-112
View File
@@ -1,112 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import HomeFaq, { FAQ_ITEMS } from "./HomeFaq";
describe("HomeFaq", () => {
it("shows the five questions the site already backs up, closed by default", () => {
const { container } = render(<HomeFaq />);
expect(
screen.getByRole("heading", { name: "Perguntas frequentes" }),
).toBeInTheDocument();
const summaries = Array.from(container.querySelectorAll("summary")).map(
(summary) => summary.textContent?.replace("+", "").trim(),
);
expect(summaries).toEqual([
"Como começa um projeto?",
"Quanto custa?",
"Você atende fora de Santa Catarina?",
"Dá para continuar um sistema que já existe ou que foi feito com IA?",
"O que preciso ter para começar?",
]);
for (const details of container.querySelectorAll("details")) {
expect(details.open).toBe(false);
}
});
it("answers from what the site already says: first conversation, remote in all of Brazil, and the AI review", () => {
render(<HomeFaq />);
expect(
screen.getByText(/Com uma conversa para entender o problema/),
).toBeInTheDocument();
expect(
screen.getByText(
/o atendimento é remoto, para empresas de todo o Brasil/,
),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ver a revisão de app feito com IA" }),
).toHaveAttribute("href", "/ia");
});
it("does not render the questions the owner has not answered yet", () => {
const { container } = render(<HomeFaq />);
for (const unanswered of [
"O código e os acessos ficam comigo?",
"Em nome de quem ficam domínio e hospedagem?",
"Como funciona o pagamento?",
]) {
// preparadas na lista, com resposta vazia...
expect(FAQ_ITEMS.find((item) => item.question === unanswered)).toEqual({
question: unanswered,
answer: "",
});
// ...e fora da página
expect(screen.queryByText(unanswered)).toBeNull();
}
expect(container.querySelectorAll("details")).toHaveLength(5);
});
it("answers 'Quanto custa?' with the owner's sentence only: no value, no deadline, and no promise that the first talk is free", () => {
render(<HomeFaq />);
const details = screen
.getByText("Quanto custa?")
.closest("details") as HTMLElement;
expect(
within(details).getByText(
"Depois da primeira conversa você recebe escopo, valor e prazo por escrito, antes de qualquer compromisso.",
),
).toBeInTheDocument();
expect(details.textContent).not.toMatch(
/R\$|\d|grátis|gratuit|sem custo|sem compromisso/i,
);
});
it("starts showing a prepared question as soon as it gets an answer", () => {
render(
<HomeFaq
items={[
{
question: "Como funciona o pagamento?",
answer: "Resposta do dono.",
},
{ question: "Ainda sem resposta?", answer: " " },
]}
/>,
);
const details = screen
.getByText("Como funciona o pagamento?")
.closest("details") as HTMLElement;
expect(within(details).getByText("Resposta do dono.")).toBeInTheDocument();
expect(screen.queryByText("Ainda sem resposta?")).toBeNull();
});
it("renders nothing, not even the heading, when no question has an answer", () => {
const { container } = render(
<HomeFaq items={[{ question: "Pergunta?", answer: "" }]} />,
);
expect(container).toBeEmptyDOMElement();
});
it("states no price, deadline, guarantee or payment policy", () => {
const { container } = render(<HomeFaq />);
expect(container.textContent).not.toMatch(
/R\$|garantia|\d+ dias|em até \d+|parcel|entrada de/i,
);
});
});
-50
View File
@@ -1,50 +0,0 @@
import { Faq, type FaqEntry } from "./Faq";
export type FaqItem = FaqEntry;
/**
* Perguntas da home. As cinco primeiras só dizem o que o site já sustenta (contato, rodapé, /ia) ou o que o dono
* já autorizou ("Quanto custa?": escopo, valor e prazo por escrito depois da primeira conversa, sem número nenhum).
* As três últimas estão preparadas, sem resposta: o dono as responde depois e elas passam a aparecer.
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que ele não tenha dito.
*/
export const FAQ_ITEMS: FaqItem[] = [
{
question: "Como começa um projeto?",
answer:
"Com uma conversa para entender o problema, sem precisar chegar com a solução definida. Depois dela, você recebe o escopo e a proposta por escrito.",
},
{
question: "Quanto custa?",
answer:
"Depois da primeira conversa você recebe escopo, valor e prazo por escrito, antes de qualquer compromisso.",
},
{
question: "Você atende fora de Santa Catarina?",
answer:
"Sim. Minha base é em Garuva, SC, e o atendimento é remoto, para empresas de todo o Brasil.",
},
{
question:
"Dá para continuar um sistema que já existe ou que foi feito com IA?",
answer:
"Sim. Antes de propor qualquer coisa, avalio o que pode ser aproveitado, quais riscos precisam de correção e o que falta para atender usuários em produção. Nem todo problema exige começar de novo.",
link: { href: "/ia", label: "Ver a revisão de app feito com IA" },
},
{
question: "O que preciso ter para começar?",
answer:
"Basta contar o que você quer resolver e, se houver, o prazo que tem em mente. Se já existe um sistema, uma planilha ou um protótipo, mostrar ajuda, mas não é obrigatório.",
},
{ question: "O código e os acessos ficam comigo?", answer: "" },
{ question: "Em nome de quem ficam domínio e hospedagem?", answer: "" },
{ question: "Como funciona o pagamento?", answer: "" },
];
/**
* Perguntas frequentes da home, antes do contato (`#faq`). O desenho é o de `Faq`, o mesmo das páginas de serviço:
* só aparecem as perguntas com resposta; sem nenhuma, a seção não existe.
*/
export default function HomeFaq({ items = FAQ_ITEMS }: { items?: FaqItem[] }) {
return <Faq items={items} />;
}
@@ -1,126 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* Testes de caminhos-base do pré-carregamento de âncoras da home em HoverPrefetchLink (complemento de
* HoverPrefetchLink.test.tsx).
*
* isHomeAnchor (CC 3): href em texto (é âncora da home | não é) | href em objeto (caminho "/" e hash | caminho "/" sem
* hash | outro caminho). O que faltava: o objeto com caminho "/" e SEM hash, que não é âncora e pré-carrega.
* HoverPrefetchLink (CC 5): caminho desconhecido | na home | fora da home com âncora da home | fora da home com outro
* link, cada um ativado ou não. Já cobertos; aqui entram as trocas de página com o link já ativado.
* O dublê de next/navigation sem `usePathname` está em HoverPrefetchLink.nopathname.paths.test.tsx.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const link = () => screen.getByRole("link", { name: "Ir" });
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/privacidade");
});
describe("isHomeAnchor, vista de fora da home", () => {
it.each([
["objeto com caminho / e sem hash", { pathname: "/" }],
["objeto com caminho / e hash vazio", { pathname: "/", hash: "" }],
["objeto com outro caminho e hash", { pathname: "/cases", hash: "#x" }],
["objeto só com hash (a página aberta)", { hash: "#x" }],
["objeto com caminho / e só a busca", { pathname: "/", query: { a: "b" } }],
])(
"path: %s -> não é âncora da home, pré-carrega ao chegar",
(_name, href) => {
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
},
);
it.each([
[
"objeto com caminho /, hash e busca",
{ pathname: "/", hash: "#contact", query: { tipo: "site" } },
],
["objeto com caminho / e hash sem #", { pathname: "/", hash: "contact" }],
["texto com busca e hash", "/?tipo=site#contact"],
["texto com busca de vários parâmetros e hash", "/?a=b&c=d#contact"],
])("path: %s -> é âncora da home, nunca pré-carrega", (_name, href) => {
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
fireEvent.focus(link());
fireEvent.touchStart(link());
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it.each([
["texto com busca e sem hash", "/?tipo=site"],
["texto de outra página com hash", "/cases#x"],
["texto de outra página com busca e hash", "/cases?a=b#x"],
["texto com dois caminhos e hash (//host)", "//cdn.example.com/#x"],
])(
"path: %s -> não é âncora da home, pré-carrega ao chegar",
(_name, href) => {
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
},
);
});
describe("HoverPrefetchLink: quando a página aberta muda", () => {
it("path: leaves the home anchor, comes back to the home and goes away again -> prefetching follows the page, never the link's earlier state", () => {
jest.mocked(usePathname).mockReturnValue("/");
const { rerender } = render(
<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
jest.mocked(usePathname).mockReturnValue("/servicos");
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
jest.mocked(usePathname).mockReturnValue("/");
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "null");
jest.mocked(usePathname).mockReturnValue("/cases/lavacao-047");
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it("path: a home anchor never reached from another page, then the visitor reaches the home -> prefetching only starts with the first touch", () => {
const { rerender } = render(
<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>,
);
expect(link()).toHaveAttribute("data-prefetch", "false");
jest.mocked(usePathname).mockReturnValue("/");
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
fireEvent.touchStart(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
});
});
@@ -1,63 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type HoverPrefetchLinkType from "./HoverPrefetchLink";
/**
* Caminho-base de HoverPrefetchLink que só existe com next/navigation sem `usePathname` (os testes que trocam o
* módulo por um dublê só com `notFound`): o `navigation.usePathname ?? (() => null)` do módulo cai no `null` e
* nada muda no pré-carregamento, nem para uma âncora da home vista de outra página.
* Arquivo à parte: o módulo é carregado isolado, depois de registrar o dublê, e não pode ter sido importado antes.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
function loadWithoutUsePathname() {
const React = jest.requireActual<typeof import("react")>("react");
let loaded: typeof HoverPrefetchLinkType | undefined;
jest.isolateModules(() => {
// o mesmo React do teste, para os hooks do componente isolado
jest.doMock("react", () => React);
jest.doMock("next/navigation", () => ({ notFound: jest.fn() }));
loaded = require("./HoverPrefetchLink").default;
});
jest.dontMock("react");
jest.dontMock("next/navigation");
if (!loaded) throw new Error("o módulo isolado não carregou");
return loaded;
}
describe("HoverPrefetchLink sem usePathname em next/navigation", () => {
it("path: unknown page -> a home anchor waits for the first touch and then prefetches as usual", () => {
const Isolated = loadWithoutUsePathname();
render(<Isolated href="/#contact">Ir</Isolated>);
const link = screen.getByRole("link", { name: "Ir" });
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
});
it("path: unknown page -> a link to another page behaves the same", () => {
const Isolated = loadWithoutUsePathname();
render(<Isolated href="/cases">Ir</Isolated>);
const link = screen.getByRole("link", { name: "Ir" });
fireEvent.focus(link);
expect(link).toHaveAttribute("data-prefetch", "null");
});
});
@@ -1,111 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* Testes de caminhos-base de HoverPrefetchLink (complemento de HoverPrefetchLink.test.tsx):
* o aviso do toque ao dono do link, o pré-carregamento que liga uma vez só e não desliga,
* e as demais propriedades do link, que passam inalteradas.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const link = () => screen.getByRole("link", { name: "Cases" });
describe("HoverPrefetchLink", () => {
it("path: touch with the caller's handler -> the handler runs once, with the event, and prefetching starts", () => {
const onTouchStart = jest.fn();
render(
<HoverPrefetchLink href="/cases" onTouchStart={onTouchStart}>
Cases
</HoverPrefetchLink>,
);
fireEvent.touchStart(link());
expect(onTouchStart).toHaveBeenCalledTimes(1);
expect(onTouchStart.mock.calls[0][0]).toMatchObject({ type: "touchstart" });
expect(link()).toHaveAttribute("data-prefetch", "null");
});
it("path: hover, then focus, then touch -> prefetching stays on and each handler runs every time", () => {
const onMouseEnter = jest.fn();
const onFocus = jest.fn();
const onTouchStart = jest.fn();
render(
<HoverPrefetchLink
href="/cases"
onMouseEnter={onMouseEnter}
onFocus={onFocus}
onTouchStart={onTouchStart}
>
Cases
</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
fireEvent.mouseEnter(link());
fireEvent.focus(link());
fireEvent.touchStart(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
expect(onMouseEnter).toHaveBeenCalledTimes(2);
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onTouchStart).toHaveBeenCalledTimes(1);
});
it("path: leaving the link after reaching it -> prefetching is not switched off again", () => {
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
fireEvent.mouseLeave(link());
fireEvent.blur(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
});
it("path: events the link does not watch (click, key press) -> prefetching stays off", () => {
const onClick = jest.fn();
render(
<HoverPrefetchLink href="/cases" onClick={onClick}>
Cases
</HoverPrefetchLink>,
);
fireEvent.click(link());
fireEvent.keyDown(link(), { key: "Enter" });
expect(onClick).toHaveBeenCalledTimes(1);
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it("path: other props -> reach the link as they were given", () => {
render(
<HoverPrefetchLink
href="/#home"
aria-label="Evandro.dev.br — início"
className="flex min-h-11"
>
Cases
</HoverPrefetchLink>,
);
const home = screen.getByRole("link", { name: "Evandro.dev.br — início" });
expect(home).toHaveAttribute("href", "/#home");
expect(home).toHaveClass("flex", "min-h-11");
});
});
-178
View File
@@ -1,178 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import HoverPrefetchLink from "./HoverPrefetchLink";
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const link = () => screen.getByRole("link", { name: "Cases" });
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
});
it("does not prefetch when the page opens", () => {
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
expect(link()).toHaveAttribute("href", "/cases");
});
it.each([
["mouse", (element: Element) => fireEvent.mouseEnter(element)],
["keyboard focus", (element: Element) => fireEvent.focus(element)],
["touch", (element: Element) => fireEvent.touchStart(element)],
])("starts prefetching when the visitor reaches it by %s", (_label, reach) => {
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
reach(link());
// null = o comportamento padrão do Link (pré-carrega a rota)
expect(link()).toHaveAttribute("data-prefetch", "null");
});
it("keeps the callers' own handlers", () => {
const onMouseEnter = jest.fn();
const onFocus = jest.fn();
const onClick = jest.fn();
render(
<HoverPrefetchLink
href="/cases"
onMouseEnter={onMouseEnter}
onFocus={onFocus}
onClick={onClick}
>
Cases
</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
fireEvent.focus(link());
fireEvent.click(link());
expect(onMouseEnter).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("only prefetches the link that was reached, not its siblings", () => {
render(
<>
<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>
<HoverPrefetchLink href="/servicos">Serviços</HoverPrefetchLink>
</>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
expect(screen.getByRole("link", { name: "Serviços" })).toHaveAttribute(
"data-prefetch",
"false",
);
});
describe("links para um ponto da home", () => {
it.each([["/#contact"], ["/#process"], ["/?tipo=site#contact"]])(
"never prefetches %s from another page, even on hover, focus or touch (it would pull the whole home)",
(href) => {
jest.mocked(usePathname).mockReturnValue("/privacidade");
render(<HoverPrefetchLink href={href}>Cases</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link());
fireEvent.focus(link());
fireEvent.touchStart(link());
expect(link()).toHaveAttribute("data-prefetch", "false");
},
);
it("never prefetches an object href with the home path and a hash from another page", () => {
jest.mocked(usePathname).mockReturnValue("/cases");
render(
<HoverPrefetchLink href={{ pathname: "/", hash: "contact" }}>
Cases
</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it("stops prefetching a home anchor that was reached on the home once the visitor moves to another page (the header does not remount)", () => {
jest.mocked(usePathname).mockReturnValue("/");
const { rerender } = render(
<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
jest.mocked(usePathname).mockReturnValue("/cases");
rerender(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it("keeps the hover prefetch for a home anchor while on the home itself (it is the same page)", () => {
jest.mocked(usePathname).mockReturnValue("/");
render(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
});
it("keeps the hover prefetch for a link to another page, and for the home without a hash, from anywhere", () => {
jest.mocked(usePathname).mockReturnValue("/privacidade");
render(
<>
<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>
<HoverPrefetchLink href="/">Início</HoverPrefetchLink>
<HoverPrefetchLink href="/cases/sev-modaintima#video">
Vídeo
</HoverPrefetchLink>
<HoverPrefetchLink href={{ pathname: "/servicos", hash: "x" }}>
Serviços
</HoverPrefetchLink>
</>,
);
for (const name of ["Cases", "Início", "Vídeo", "Serviços"]) {
const item = screen.getByRole("link", { name });
fireEvent.mouseEnter(item);
expect(item).toHaveAttribute("data-prefetch", "null");
}
});
it("still runs the caller's handlers on a home anchor seen from another page", () => {
jest.mocked(usePathname).mockReturnValue("/privacidade");
const onMouseEnter = jest.fn();
render(
<HoverPrefetchLink href="/#contact" onMouseEnter={onMouseEnter}>
Cases
</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(onMouseEnter).toHaveBeenCalledTimes(1);
});
it("prefetches as usual when the router is unknown (no pathname)", () => {
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
render(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
});
});
-62
View File
@@ -1,62 +0,0 @@
"use client";
import Link from "next/link";
import * as navigation from "next/navigation";
import { type ComponentProps, useState } from "react";
type LinkHref = ComponentProps<typeof Link>["href"];
/**
* O caminho da página aberta. Sem `usePathname` (testes que trocam next/navigation por um dublê só com
* `notFound`, por exemplo) vale `null`: sem caminho conhecido, nada muda no pré-carregamento.
*/
const usePathname: () => string | null = navigation.usePathname ?? (() => null);
/** Link para um ponto da home: `/#contact` ou `/?tipo=site#contact`. */
function isHomeAnchor(href: LinkHref) {
if (typeof href === "string") return /^\/(\?[^#]*)?#/.test(href);
return href.pathname === "/" && Boolean(href.hash);
}
/**
* Link interno que só pré-carrega a rota quando a pessoa chega nele (mouse, teclado ou toque).
* O padrão do Link pré-carrega todo link visível ao abrir a página, e o cabeçalho e a faixa de clientes
* somavam 15 pedidos disputando a rede na abertura. Com `prefetch={false}` o App Router não pré-carrega nem no hover.
* Padrão da documentação do Next: node_modules/next/dist/docs/01-app/02-guides/prefetching.md.
*
* Fora da home, um link para um ponto dela (`/#contact`) não pré-carrega nem no hover: o pré-carregamento puxaria
* a home inteira, com o CSS da vitrine, para quem só passou o mouse (e o navegador avisava "preloaded but not
* used"). Na própria home o link leva à página em que a pessoa já está, e o pré-carregamento segue o padrão. O
* caminho vem de `usePathname`, e não de uma leitura única no hover: o cabeçalho e o rodapé não remontam entre as
* páginas, e um link ativado na home não pode continuar pré-carregando depois que a pessoa sair dela.
*/
export default function HoverPrefetchLink({
onMouseEnter,
onFocus,
onTouchStart,
...props
}: Omit<ComponentProps<typeof Link>, "prefetch">) {
const pathname = usePathname();
const [active, setActive] = useState(false);
const homeAnchorFromElsewhere =
pathname !== null && pathname !== "/" && isHomeAnchor(props.href);
return (
<Link
{...props}
prefetch={active && !homeAnchorFromElsewhere ? null : false}
onMouseEnter={(event) => {
setActive(true);
onMouseEnter?.(event);
}}
onFocus={(event) => {
setActive(true);
onFocus?.(event);
}}
onTouchStart={(event) => {
setActive(true);
onTouchStart?.(event);
}}
/>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { render, screen } from "@testing-library/react";
import { Logo } from "./Logo";
describe("Logo", () => {
it("shows the text cutout with the same alt text and a decorative inline mark", () => {
const { container } = render(<Logo />);
const text = screen.getByRole("img", { name: "Evandro.dev.br" });
expect(text.getAttribute("src")).toContain("wordmark-text-dark.webp");
expect(text).toHaveAttribute("width", "1600");
expect(text).toHaveAttribute("height", "330");
const mark = container.querySelector("svg");
expect(mark).toHaveAttribute("aria-hidden", "true");
expect(mark?.querySelectorAll(".logo-bloco")).toHaveLength(4);
expect(screen.getAllByRole("img")).toHaveLength(1);
});
it("keeps the caller's className and loading", () => {
const { container } = render(
<Logo theme="light" loading="eager" className="w-40 sm:w-44" />,
);
expect(container.firstElementChild).toHaveClass("w-40", "sm:w-44");
const text = screen.getByRole("img", { name: "Evandro.dev.br" });
expect(text.getAttribute("src")).toContain("wordmark-text-light.webp");
expect(text).toHaveAttribute("loading", "eager");
expect(container.querySelector("svg path")).toHaveAttribute(
"fill",
"#00090b",
);
});
});
Loaded 100 of 481 files, more files were not shown because too many files have changed in this diff. Show more