Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93351892b9 |
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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__/
|
||||
@@ -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
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{}
|
||||
-619
@@ -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
@@ -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()
|
||||
@@ -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 -->
|
||||
-11
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[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>
|
||||
@@ -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;`);
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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\)$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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\]/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user