Compare commits
137
Commits
feat-marketing
...
main
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 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"
|
||||||
+25
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -39,3 +41,26 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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__/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+619
@@ -0,0 +1,619 @@
|
|||||||
|
"""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
@@ -0,0 +1,341 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- 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
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 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
@@ -0,0 +1,53 @@
|
|||||||
|
# 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,36 +1,169 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
<div align="center">
|
||||||
|
|
||||||
## Getting Started
|
<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">
|
||||||
|
|
||||||
First, run the development server:
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
```bash
|
[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)
|
||||||
npm run dev
|
|
||||||
# or
|
</div>
|
||||||
yarn dev
|
|
||||||
# or
|
| SEV Moda Íntima | NutriScan |
|
||||||
pnpm dev
|
|---|---|
|
||||||
# or
|
| <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"> |
|
||||||
bun dev
|
| **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)
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
</details>
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## Início rápido
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
Requisitos: Node.js `22.x` (`engines`), pnpm `9.15.9` (`packageManager`) e, só para `pnpm test:e2e`, o Chromium do Playwright.
|
||||||
|
|
||||||
## Learn More
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
A instalação é sempre com `--frozen-lockfile`, igual ao CI e ao Nixpacks.
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
> [!IMPORTANT]
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
> 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`.
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
Validação completa, na mesma ordem do CI:
|
||||||
|
|
||||||
## Deploy on Vercel
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
Testes de ponta a ponta: `pnpm build && pnpm start -p 3001` e, em outro terminal, `BASE_URL=http://localhost:3001 pnpm test:e2e`.
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
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>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 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;`);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,165 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,795 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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\)$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+120
-177
@@ -1,194 +1,137 @@
|
|||||||
"use client";
|
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";
|
||||||
|
|
||||||
import React from "react";
|
/**
|
||||||
import { motion } from "framer-motion";
|
* 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
|
||||||
import { LucideIcon, Brain, Zap, Target, Rocket, Users, Lightbulb, Code, Shield, Terminal, Cpu } from "lucide-react";
|
* aparece só no primeiro cartão: antes ela se repetia em cinco textos ("Sem intermediários", "Uma pessoa cuida",
|
||||||
import Squares from "@/components/Squares";
|
* "Quem responde pelo projeto", "Você acompanha as decisões" e "Você fala com quem desenvolve", que segue só no hero).
|
||||||
|
*/
|
||||||
// --- Types & Interfaces (Tipagem Forte para Previsibilidade) ---
|
const STORY_DATA = [
|
||||||
interface StoryStep {
|
|
||||||
icon: LucideIcon;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
gradient: string;
|
|
||||||
accent: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CoreValue {
|
|
||||||
icon: LucideIcon;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Data (Desacoplado para facilitar manutenção/CMS) ---
|
|
||||||
const STORY_DATA: StoryStep[] = [
|
|
||||||
{
|
{
|
||||||
id: "KERNEL_INIT",
|
label: "Quem desenvolve",
|
||||||
icon: Brain,
|
title: "Quem conversa com você escreve o código",
|
||||||
title: "Engenharia de Fundamentos",
|
content:
|
||||||
content: "Minha mentalidade é pautada pela redução de entropia. Não apenas escrevo código; projeto sistemas onde a escalabilidade é uma propriedade intrínseca da arquitetura, não um ajuste posterior.",
|
"A mesma pessoa entende o problema, decide a estrutura e desenvolve o sistema, do começo ao fim.",
|
||||||
gradient: "from-blue-600 to-cyan-500",
|
proof:
|
||||||
accent: "text-blue-400",
|
"Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "RUNTIME_EXEC",
|
label: "Sem começar do zero",
|
||||||
icon: Zap,
|
title: "Aproveito o que você já tem",
|
||||||
title: "Entrega sob Restrições",
|
content:
|
||||||
content: "Em 3 anos de atuação profissional, especializei-me em cenários de alta criticidade. Atuei na ponte entre sistemas legados e microserviços, garantindo a resolução do problema.",
|
"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.",
|
||||||
gradient: "from-purple-600 to-pink-600",
|
proof:
|
||||||
accent: "text-purple-400",
|
"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.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const VALUES_DATA: CoreValue[] = [
|
/** Atraso (s) de cada cartão: entram em escada, como na vitrine de serviços. */
|
||||||
{
|
const cardDelay = (index: number) => 0.08 * (index + 1);
|
||||||
icon: Code,
|
|
||||||
title: "Pragmatismo Arquitetural",
|
|
||||||
description: "Priorizo a manutenibilidade e a resiliência. Código limpo é o meio, sistemas que geram ROI são o fim."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Brain,
|
|
||||||
title: "Fundacionalismo Técnico",
|
|
||||||
description: "Domínio profundo de estruturas de dados e protocolos. Tecnologias mudam, fundamentos são perenes."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
title: "Propriedade e Ownership",
|
|
||||||
description: "Comunicação de baixa latência e responsabilidade radical sobre cada entrega e decisão técnica."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
title: "Security by Design",
|
|
||||||
description: "Integridade e conformidade técnica (GDPR/LGPD) tratadas como requisitos funcionais, não opcionais."
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- Micro-componentes (Feynman Technique: Simplicidade e Reuso) ---
|
|
||||||
|
|
||||||
const SectionHeader = () => (
|
|
||||||
<div className="text-center md:text-left mb-24 relative z-10">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-950/30 border border-blue-500/20 text-blue-400 font-mono text-xs tracking-wider mb-6"
|
|
||||||
>
|
|
||||||
<Terminal className="w-3 h-3" />
|
|
||||||
<span>WHOAMI.sys</span>
|
|
||||||
</motion.div>
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="text-4xl md:text-6xl font-black text-white tracking-tighter"
|
|
||||||
>
|
|
||||||
Engenharia <br />
|
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-cyan-400">Nuclear</span>
|
|
||||||
</motion.h2>
|
|
||||||
<p className="mt-6 text-lg text-slate-400 max-w-2xl leading-relaxed font-light border-l-2 border-slate-800 pl-6">
|
|
||||||
"Transformando complexidade técnica em valor de negócio através da engenharia."
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StoryCard = ({ step, index }: { step: StoryStep; index: number }) => {
|
|
||||||
const isEven = index % 2 === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: isEven ? -30 : 30 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
className={`flex flex-col ${isEven ? "md:flex-row" : "md:flex-row-reverse"} items-stretch gap-8 mb-20 relative`}
|
|
||||||
>
|
|
||||||
<div className="flex-1 w-full">
|
|
||||||
<div className="group relative h-full p-8 rounded-3xl bg-zinc-900/40 border border-white/5 hover:border-blue-500/30 transition-all duration-500 shadow-xl backdrop-blur-xl overflow-hidden">
|
|
||||||
{/* Background Glow */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<div className="p-3 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 group-hover:scale-110 transition-transform duration-300">
|
|
||||||
<step.icon className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-mono text-slate-500 uppercase tracking-widest">
|
|
||||||
{step.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-2xl font-bold text-white mb-4 group-hover:text-blue-400 transition-colors">{step.title}</h3>
|
|
||||||
<p className="text-slate-400 leading-relaxed text-sm">{step.content}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Line & Node */}
|
|
||||||
<div className="hidden md:flex flex-col items-center justify-center w-12 relative">
|
|
||||||
<div className="absolute top-0 bottom-0 w-[1px] bg-slate-800 group-hover:bg-blue-500/50 transition-colors" />
|
|
||||||
<div className="w-4 h-4 rounded-full bg-slate-900 border-2 border-slate-700 z-10 group-hover:border-blue-500 transition-colors" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 hidden md:block" />
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
return (
|
return (
|
||||||
<section id="about" className="relative py-32">
|
<Section
|
||||||
{/* Background Gradients */}
|
id="about"
|
||||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
|
eyebrow="Quem conduz o projeto"
|
||||||
<div className="absolute top-[-10%] right-[-10%] w-[600px] h-[600px] bg-blue-900/10 rounded-full blur-[120px]" />
|
title="Evandro Fonseca Junior"
|
||||||
<div className="absolute bottom-[-10%] left-[-10%] w-[600px] h-[600px] bg-purple-900/10 rounded-full blur-[120px]" />
|
description="Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas."
|
||||||
</div>
|
disableChildrenAnimation
|
||||||
|
aside={
|
||||||
<div className="container max-w-6xl mx-auto px-6 relative z-10">
|
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
|
||||||
<SectionHeader />
|
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-slash" />
|
||||||
|
Garuva, SC · Atendimento remoto
|
||||||
<div className="relative mt-20">
|
|
||||||
{/* Center Line for Mobile/Desktop */}
|
|
||||||
<div className="absolute left-6 md:left-1/2 top-0 bottom-0 w-[1px] bg-slate-800 md:-translate-x-1/2 hidden md:block" />
|
|
||||||
|
|
||||||
{STORY_DATA.map((step, idx) => (
|
|
||||||
<StoryCard key={idx} step={step} index={idx} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
{/* Grid de Valores com Pareto 80/20 */}
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-32">
|
{/* 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 */}
|
||||||
{VALUES_DATA.map((val, i) => (
|
<div className="ds-grid-2 items-start">
|
||||||
<motion.div
|
<div className="flex flex-col gap-sp-6">
|
||||||
key={i}
|
{STORY_DATA.map((step, index) => (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<Reveal
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
key={step.title}
|
||||||
viewport={{ once: true }}
|
y={20}
|
||||||
transition={{ delay: i * 0.1 }}
|
duration={0.8}
|
||||||
className="p-6 rounded-2xl bg-zinc-900/30 border border-white/5 hover:border-blue-500/20 hover:bg-zinc-900/50 transition-all duration-300 group"
|
delay={cardDelay(index)}
|
||||||
|
className="flex flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div className="mb-4 p-2 w-fit rounded-lg bg-slate-800/50 text-slate-400 group-hover:text-blue-400 group-hover:bg-blue-500/10 transition-colors">
|
<article className="ds-card cv-press cv-lift flex-1">
|
||||||
<val.icon className="w-5 h-5" />
|
<div className="ds-kicker">{step.label}</div>
|
||||||
</div>
|
<DrawLine delay={0.03} />
|
||||||
<h4 className="text-white font-bold mb-2 text-sm uppercase tracking-wide group-hover:text-blue-200 transition-colors">{val.title}</h4>
|
<h3 className="ds-card-title">{step.title}</h3>
|
||||||
<p className="text-slate-500 text-xs leading-relaxed group-hover:text-slate-400 transition-colors">{val.description}</p>
|
<p className="ds-copy" data-reel="right">
|
||||||
</motion.div>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* SEO Semantic Content - Visible but subtle */}
|
<Reveal
|
||||||
<div className="mt-24 pt-8 border-t border-slate-900">
|
y={20}
|
||||||
<div className="flex items-center justify-center gap-2 text-slate-700 text-xs font-mono uppercase tracking-widest mb-4">
|
duration={0.8}
|
||||||
<Cpu className="w-3 h-3" />
|
delay={cardDelay(STORY_DATA.length)}
|
||||||
<span>System_Signature</span>
|
className="lg:sticky lg:top-28"
|
||||||
</div>
|
>
|
||||||
<p className="text-[10px] text-slate-700 text-center max-w-2xl mx-auto leading-relaxed font-mono">
|
<article className="ds-card cv-press cv-lift">
|
||||||
Evandro Fonseca Junior :: Engenheiro Full-Stack <br />
|
<figure>
|
||||||
Next.js / React / Angular / Node.js / Tauri / PostgreSQL / IoT <br />
|
<LazyRevealImage
|
||||||
Garuva, Santa Catarina, Brasil :: Lat: -26.0264, Long: -48.8544
|
src={profilePhoto}
|
||||||
</p>
|
alt="Retrato de Evandro Fonseca Junior"
|
||||||
</div>
|
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>
|
</div>
|
||||||
</section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
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>"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+424
-184
@@ -1,223 +1,463 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import type { FormEvent, MouseEvent, ReactNode } from "react";
|
||||||
import { Send, User, Mail, MessageSquare, Sparkles } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
import { ShimmerButton as MagicShimmerButton } from "@/components/magicui/shimmer-button";
|
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
|
||||||
import Squares from "@/components/Squares";
|
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";
|
||||||
|
|
||||||
// --- CONFIGURAÇÃO DE ENGENHARIA ---
|
type ContactField = "name" | "email" | "message";
|
||||||
// Substitua pelo seu número real: CódigoPaís + DDD + Número (sem espaços ou símbolos)
|
type FieldErrors = Partial<Record<ContactField, string>>;
|
||||||
const WHATSAPP_NUMBER = "5547992742365";
|
|
||||||
|
|
||||||
interface FormData {
|
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;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
message: 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper Component: Resolve o erro de tipagem TS2322 (IntrinsicAttributes)
|
* Abre o WhatsApp em outra aba. Sem "noopener" na chamada, porque com ele o window.open sempre devolve null
|
||||||
* Estendemos a interface para aceitar propriedades de estilo customizadas.
|
* 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.
|
||||||
*/
|
*/
|
||||||
interface ExtendedShimmerProps extends React.ComponentProps<typeof MagicShimmerButton> {
|
function openWhatsApp(url: string) {
|
||||||
shimmerColor?: string;
|
const tab = tryOpenTab(url);
|
||||||
shimmerSize?: string;
|
if (tab) {
|
||||||
type?: "button" | "submit" | "reset";
|
if (detachOpener(tab)) return;
|
||||||
disabled?: boolean;
|
tab.close();
|
||||||
|
}
|
||||||
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomShimmerButton = ({
|
/** 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,
|
children,
|
||||||
className,
|
}: {
|
||||||
shimmerColor = "#3b82f6",
|
id: string;
|
||||||
shimmerSize = "0.1em",
|
label: string;
|
||||||
...props
|
error?: string;
|
||||||
}: ExtendedShimmerProps) => {
|
/** 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 (
|
return (
|
||||||
<MagicShimmerButton
|
<div>
|
||||||
{...props}
|
<label htmlFor={id} className={labelClassName}>
|
||||||
className={className}
|
{label}
|
||||||
// @ts-ignore - Ignora restrição de tipos da lib original se necessário
|
</label>
|
||||||
shimmerColor={shimmerColor}
|
{children({
|
||||||
shimmerSize={shimmerSize}
|
id,
|
||||||
>
|
"aria-invalid": error ? true : undefined,
|
||||||
{children}
|
"aria-describedby": description || undefined,
|
||||||
</MagicShimmerButton>
|
})}
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className={errorClassName}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||||
const [formData, setFormData] = useState<FormData>({
|
useEffect(() => {
|
||||||
|
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
subject: "full-stack",
|
subject: DEFAULT_CONTACT_SUBJECT as string,
|
||||||
message: "",
|
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);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra
|
||||||
e.preventDefault();
|
// página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada.
|
||||||
setIsSubmitting(true);
|
useEffect(() => {
|
||||||
|
const choose = (kind: string | null | undefined) => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
const subject = contactSubjectForKind(kind);
|
||||||
|
if (!subject) return;
|
||||||
// Definimos os emojis via Unicode Escape para máxima compatibilidade
|
setFormData((current) => ({ ...current, subject }));
|
||||||
const icons = {
|
setChosenOffer(contactKindForSubject(subject) ?? null);
|
||||||
rocket: "\u{1F680}",
|
|
||||||
person: "\u{1F464}",
|
|
||||||
email: "\u{1F4E7}",
|
|
||||||
tools: "\u{1F6E0}",
|
|
||||||
chat: "\u{1F4AC}"
|
|
||||||
};
|
};
|
||||||
|
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 messageTemplate = `*NOVA SOLICITAÇÃO DE PROJETO*
|
const updateField = (field: ContactField | "subject", value: string) => {
|
||||||
|
setFormData((current) => ({ ...current, [field]: value }));
|
||||||
*Origem:* Portfólio Profissional
|
if (field === "subject") setChosenOffer(null);
|
||||||
*Prioridade:* Alta (Lead Qualificado)
|
if (field !== "subject" && errors[field]) {
|
||||||
|
setErrors((current) => ({ ...current, [field]: undefined }));
|
||||||
---
|
}
|
||||||
*CLIENTE:* ${formData.name}
|
|
||||||
*EMAIL:* ${formData.email}
|
|
||||||
*ÁREA:* ${formData.subject.toUpperCase()}
|
|
||||||
|
|
||||||
*DESCRIÇÃO:*
|
|
||||||
"${formData.message}"
|
|
||||||
---
|
|
||||||
_Mensagem gerada via Evandro.dev | Garuva, SC_`;
|
|
||||||
|
|
||||||
const encodedMessage = encodeURIComponent(messageTemplate);
|
|
||||||
const whatsappUrl = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodedMessage}`;
|
|
||||||
|
|
||||||
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
|
||||||
setIsSubmitting(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyles =
|
/** Mostra os erros, leva o foco ao primeiro campo inválido e diz se há algum. */
|
||||||
"w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-slate-200 placeholder:text-slate-600 focus:border-blue-500/50 focus:ring-2 focus:ring-blue-500/10 focus:bg-black/60 outline-none transition-all duration-300 font-mono text-sm backdrop-blur-sm group-hover:border-white/10";
|
const hasInvalidField = (form: HTMLFormElement | null) => {
|
||||||
const labelStyles =
|
const found = validate(formData);
|
||||||
"text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 flex items-center gap-2 transition-colors group-hover:text-slate-400";
|
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 (
|
return (
|
||||||
<section id="contact" className="w-full py-24 relative">
|
<section
|
||||||
|
id="contact"
|
||||||
|
aria-labelledby="contact-title"
|
||||||
<div className="w-full max-w-4xl mx-auto px-6 relative z-10">
|
className="ds-section ds-section-dark"
|
||||||
<motion.div
|
>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<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">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
<div className="min-w-0">
|
||||||
transition={{ duration: 0.8 }}
|
<p className="ds-eyebrow">Contato</p>
|
||||||
className="relative bg-zinc-900/40 backdrop-blur-xl border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl overflow-hidden"
|
<h2
|
||||||
>
|
id="contact-title"
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500/0 via-blue-500/50 to-blue-500/0" />
|
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
|
||||||
|
>
|
||||||
<div className="mb-10 text-center space-y-2">
|
Vamos conversar sobre o que sua empresa precisa.
|
||||||
<h2 className="text-3xl md:text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-b from-white to-white/60">
|
</h2>
|
||||||
Vamos Construir o Futuro
|
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
|
||||||
</h2>
|
Conte o que você quer resolver. Pode ser um projeto novo, uma
|
||||||
<p className="text-slate-400 text-sm md:text-base max-w-lg mx-auto">
|
melhoria ou uma integração. Não precisa chegar com a solução
|
||||||
Descreva seu desafio técnico. Retorno com uma arquitetura sólida em até 24h.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
|
||||||
<form onSubmit={handleSubmit} className="space-y-8 relative">
|
<WhatsAppLink
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
placement="contact_direct"
|
||||||
<div className="space-y-3 group">
|
service={directSubject}
|
||||||
<label className={labelStyles}>
|
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
||||||
<User className="w-3 h-3 text-blue-500" /> NOME COMPLETO
|
>
|
||||||
</label>
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[var(--whatsapp)]/10 text-[var(--whatsapp)]">
|
||||||
<input
|
<WhatsAppMark className="h-6 w-6" />
|
||||||
required
|
</span>
|
||||||
type="text"
|
<span className="min-w-0 flex-1 font-display">
|
||||||
placeholder="Ex: Alan Turing"
|
<span className="block text-[15px] font-semibold text-white group-hover:text-[var(--whatsapp)]">
|
||||||
className={inputStyles}
|
Prefere conversar direto?
|
||||||
value={formData.name}
|
</span>
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
<span className="mt-1 block text-[15px] text-white/65">
|
||||||
/>
|
WhatsApp · {whatsAppDisplay}
|
||||||
</div>
|
</span>
|
||||||
|
</span>
|
||||||
<div className="space-y-3 group">
|
<ArrowUpRight
|
||||||
<label className={labelStyles}>
|
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[var(--whatsapp)]"
|
||||||
<Mail className="w-3 h-3 text-blue-500" /> E-MAIL CORPORATIVO
|
aria-hidden="true"
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="email"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
className={inputStyles}
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 group">
|
|
||||||
<label className={labelStyles}>
|
|
||||||
<Sparkles className="w-3 h-3 text-blue-500" /> TIPO DE PROJETO
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
className={`${inputStyles} appearance-none cursor-pointer`}
|
|
||||||
value={formData.subject}
|
|
||||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="full-stack">Desenvolvimento Full-Stack</option>
|
|
||||||
<option value="cloud">Cloud & Infrastructure</option>
|
|
||||||
<option value="iot">Internet of Things (IoT)</option>
|
|
||||||
<option value="consultancy">Consultoria Técnica Sênior</option>
|
|
||||||
</select>
|
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 group">
|
|
||||||
<label className={labelStyles}>
|
|
||||||
<MessageSquare className="w-3 h-3 text-blue-500" /> DETALHES DO PROJETO
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
required
|
|
||||||
rows={5}
|
|
||||||
placeholder="Descreva os requisitos técnicos e objetivos do sistema..."
|
|
||||||
className={`${inputStyles} resize-none`}
|
|
||||||
value={formData.message}
|
|
||||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.01 }} whileTap={{ scale: 0.99 }} className="pt-6">
|
<form
|
||||||
<CustomShimmerButton
|
id={CONTACT_FORM_ID}
|
||||||
type="submit"
|
onSubmit={handleSubmit}
|
||||||
className="w-full h-14 rounded-xl shadow-lg shadow-blue-500/20"
|
noValidate
|
||||||
shimmerColor="#60a5fa"
|
aria-labelledby="contact-form-title"
|
||||||
shimmerSize="0.1em"
|
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
||||||
disabled={isSubmitting}
|
>
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-center gap-3 w-full h-full">
|
Prepare sua mensagem
|
||||||
{isSubmitting ? (
|
</h3>
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<p className="mt-1 font-display text-[15px] leading-6 text-white/65">
|
||||||
) : (
|
Você revisa no WhatsApp antes de enviar.
|
||||||
<>
|
|
||||||
<span className="text-white font-bold tracking-widest uppercase text-xs md:text-sm">
|
|
||||||
Iniciar Protocolo de Contato
|
|
||||||
</span>
|
|
||||||
<Send className="w-4 h-4 text-white" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CustomShimmerButton>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<footer className="pt-2">
|
|
||||||
<p className="text-[10px] text-center text-slate-600 font-mono uppercase tracking-widest opacity-60">
|
|
||||||
Secured by End-to-End Encryption
|
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</motion.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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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,925 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
|
|
||||||
interface ContactFormProps {
|
|
||||||
formType: "simple" | "advanced";
|
|
||||||
setFormType: (type: "simple" | "advanced") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactForm({ formType, setFormType }: ContactFormProps) {
|
|
||||||
const { data } = usePortfolioData();
|
|
||||||
const [contactMethod, setContactMethod] = useState<"whatsapp" | "email">(
|
|
||||||
"whatsapp"
|
|
||||||
);
|
|
||||||
const [userName, setUserName] = useState<string>("");
|
|
||||||
const [userEmail, setUserEmail] = useState<string>("");
|
|
||||||
const [userPhone, setUserPhone] = useState<string>("");
|
|
||||||
const [userCompany, setUserCompany] = useState<string>("");
|
|
||||||
const [howFound, setHowFound] = useState<string>("");
|
|
||||||
const [projectType, setProjectType] = useState<string>("");
|
|
||||||
const [otherProjectType, setOtherProjectType] = useState<string>(""); // Novo estado para "Outro" tipo de projeto
|
|
||||||
const [desiredDeadline, setDesiredDeadline] = useState<string>("");
|
|
||||||
const [budgetValue, setBudgetValue] = useState<string>(""); // Novo estado para o valor do orçamento
|
|
||||||
const [technicalComplexity, setTechnicalComplexity] = useState<string>("");
|
|
||||||
const [hasInternalTeam, setHasInternalTeam] = useState<string>("");
|
|
||||||
const [customMessage, setCustomMessage] = useState<string>("");
|
|
||||||
const [isDecisor, setIsDecisor] = useState<string>("");
|
|
||||||
const [projectApproved, setProjectApproved] = useState<string>("");
|
|
||||||
const [urgency, setUrgency] = useState<string>("");
|
|
||||||
const [preferredModality, setPreferredModality] = useState<string>("");
|
|
||||||
const [isExpanded, setIsExpanded] = useState<boolean>(false); // Novo estado para controlar a expansão do formulário avançado
|
|
||||||
const [nameError, setNameError] = useState<string | null>(null);
|
|
||||||
const [emailError, setEmailError] = useState<string | null>(null);
|
|
||||||
const [phoneError, setPhoneError] = useState<string | null>(null);
|
|
||||||
const [otherProjectTypeError, setOtherProjectTypeError] = useState<
|
|
||||||
string | null
|
|
||||||
>(null); // Novo estado de erro
|
|
||||||
const [honeypot, setHoneypot] = useState<string>(""); // Estado para o campo honeypot
|
|
||||||
const [startTime, setStartTime] = useState<number>(0); // Estado para o tempo de início do formulário
|
|
||||||
const [nameFocused, setNameFocused] = useState<boolean>(false);
|
|
||||||
const [emailFocused, setEmailFocused] = useState<boolean>(false);
|
|
||||||
const [phoneFocused, setPhoneFocused] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStartTime(Date.now()); // Registra o tempo de carregamento do componente
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null; // Ou um componente de carregamento/erro
|
|
||||||
}
|
|
||||||
|
|
||||||
const getGreetingMessageWithUserName = () => {
|
|
||||||
const hour = new Date().getHours();
|
|
||||||
const dayPart =
|
|
||||||
hour >= 8 && hour < 12
|
|
||||||
? "manhã"
|
|
||||||
: hour >= 12 && hour < 18
|
|
||||||
? "tarde"
|
|
||||||
: "noite";
|
|
||||||
|
|
||||||
return `Olá Evandro, espero que esta ${dayPart} esteja sendo produtiva.
|
|
||||||
|
|
||||||
Meu nome é ${userName} e entrei em contato pois vi seu portfólio e identifiquei que suas habilidades como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.
|
|
||||||
|
|
||||||
Gostaria de agendar uma conversa para discutirmos detalhes e entender como podemos trabalhar juntos de forma eficiente e produtiva.
|
|
||||||
|
|
||||||
Fico no aguardo do seu contato para marcarmos um horário que seja conveniente para ambos.
|
|
||||||
|
|
||||||
Atenciosamente,
|
|
||||||
${userName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateAdvancedMessage = () => {
|
|
||||||
if (customMessage) return customMessage;
|
|
||||||
|
|
||||||
// Saudação profissional
|
|
||||||
let message = `Prezado Evandro,\n\n`;
|
|
||||||
|
|
||||||
// Informações do contato
|
|
||||||
message += `Meu nome é ${userName}`;
|
|
||||||
if (userCompany) {
|
|
||||||
message += `, represento a empresa ${userCompany}`;
|
|
||||||
}
|
|
||||||
message += `.\n`;
|
|
||||||
|
|
||||||
if (howFound) {
|
|
||||||
message += `Conheci seu trabalho através de ${howFound}.\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message += `\n`;
|
|
||||||
|
|
||||||
// Detalhes do projeto
|
|
||||||
message += `=== DETALHES DO PROJETO ===\n`;
|
|
||||||
message += `Tipo de projeto: `;
|
|
||||||
if (projectType === "outro" && otherProjectType) {
|
|
||||||
message += `${otherProjectType}\n`;
|
|
||||||
} else if (projectType) {
|
|
||||||
message += `${projectType}\n`;
|
|
||||||
} else {
|
|
||||||
message += `Não especificado\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desiredDeadline) {
|
|
||||||
message += `Prazo desejado: ${desiredDeadline}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (budgetValue) {
|
|
||||||
message += `Orçamento disponível: ${budgetValue}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (technicalComplexity) {
|
|
||||||
message += `Complexidade técnica estimada: ${technicalComplexity}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasInternalTeam) {
|
|
||||||
message += `Equipe técnica interna: ${
|
|
||||||
hasInternalTeam === "sim"
|
|
||||||
? "Sim"
|
|
||||||
: hasInternalTeam === "nao"
|
|
||||||
? "Não"
|
|
||||||
: "Parcialmente"
|
|
||||||
}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message += `\n`;
|
|
||||||
|
|
||||||
// Contatos
|
|
||||||
message += `=== INFORMAÇÕES PARA CONTATO ===\n`;
|
|
||||||
if (userEmail) {
|
|
||||||
message += `📧 Email: ${userEmail}\n`;
|
|
||||||
}
|
|
||||||
if (userPhone) {
|
|
||||||
message += `📱 Telefone/WhatsApp: ${userPhone}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message += `\n`;
|
|
||||||
|
|
||||||
// Mensagem final
|
|
||||||
message += `Estou entrando em contato pois acredito que seus conhecimentos como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.\n\n`;
|
|
||||||
message += `Fico no aguardo do seu contato para discutirmos detalhes e definirmos os próximos passos.\n\n`;
|
|
||||||
message += `Atenciosamente,\n`;
|
|
||||||
message += `${userName}`;
|
|
||||||
|
|
||||||
return message;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUserName(e.target.value);
|
|
||||||
if (e.target.value.trim() !== "") setNameError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUserEmail(e.target.value);
|
|
||||||
if (e.target.value.trim() !== "") setEmailError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhoneChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUserPhone(e.target.value);
|
|
||||||
if (e.target.value.trim() !== "") setPhoneError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameFocus = () => setNameFocused(true);
|
|
||||||
const handleEmailFocus = () => setEmailFocused(true);
|
|
||||||
const handlePhoneFocus = () => setPhoneFocused(true);
|
|
||||||
|
|
||||||
const handleCompanyChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUserCompany(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHowFoundChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setHowFound(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProjectTypeChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setProjectType(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDesiredDeadlineChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setDesiredDeadline(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBudgetValueChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setBudgetValue(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTechnicalComplexityChange = (
|
|
||||||
e: ChangeEvent<HTMLSelectElement>
|
|
||||||
) => {
|
|
||||||
setTechnicalComplexity(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHasInternalTeamChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setHasInternalTeam(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMessageChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
setCustomMessage(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateWhatsAppUrl = () => {
|
|
||||||
const message = encodeURIComponent(
|
|
||||||
formType === "advanced"
|
|
||||||
? generateAdvancedMessage()
|
|
||||||
: customMessage || getGreetingMessageWithUserName()
|
|
||||||
);
|
|
||||||
const phone = data.contact.whatsapp.replace(/\D/g, ""); // Remove caracteres não numéricos
|
|
||||||
return `https://wa.me/${phone}?text=${message}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateEmailUrl = () => {
|
|
||||||
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
|
|
||||||
const body = encodeURIComponent(
|
|
||||||
formType === "advanced"
|
|
||||||
? generateAdvancedMessage()
|
|
||||||
: `Nome: ${userName}\nEmail: ${userEmail}\nTelefone: ${userPhone}\nEmpresa/Posição: ${userCompany}\nComo conheceu: ${howFound}\n\nTipo de Projeto: ${projectType}\nPrazo Desejado: ${desiredDeadline}\nOrçamento Disponível: ${budgetValue}\nComplexidade Técnica: ${technicalComplexity}\nTem equipe interna: ${hasInternalTeam}\n\nMensagem: ${
|
|
||||||
customMessage || getGreetingMessageWithUserName()
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
return `mailto:${data.contact.email}?subject=${subject}&body=${body}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
let hasError = false;
|
|
||||||
if (userName.trim() === "") {
|
|
||||||
setNameError("Por favor, insira seu nome.");
|
|
||||||
hasError = true;
|
|
||||||
} else {
|
|
||||||
setNameError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(userEmail)) {
|
|
||||||
setEmailError("Por favor, insira um email válido.");
|
|
||||||
hasError = true;
|
|
||||||
} else {
|
|
||||||
setEmailError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userPhone.trim() === "") {
|
|
||||||
setPhoneError("Por favor, insira seu telefone.");
|
|
||||||
hasError = true;
|
|
||||||
} else {
|
|
||||||
setPhoneError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectType === "outro" && otherProjectType.trim() === "") {
|
|
||||||
setOtherProjectTypeError("Por favor, especifique o tipo de projeto.");
|
|
||||||
hasError = true;
|
|
||||||
} else {
|
|
||||||
setOtherProjectTypeError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validação Honeypot
|
|
||||||
if (honeypot) {
|
|
||||||
console.log("Honeypot preenchido, provável bot.");
|
|
||||||
return; // Ignora a submissão
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validação de tempo de preenchimento (mínimo de 3 segundos)
|
|
||||||
const timeElapsed = Date.now() - startTime;
|
|
||||||
if (timeElapsed < 3000) {
|
|
||||||
console.log("Formulário preenchido muito rápido, provável bot.");
|
|
||||||
return; // Ignora a submissão
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasError) return;
|
|
||||||
|
|
||||||
// Verificação de interação mínima
|
|
||||||
if (!nameFocused || !emailFocused || !phoneFocused) {
|
|
||||||
console.log("Campos principais não foram focados, provável bot.");
|
|
||||||
// Opcional: exibir uma mensagem de erro para o usuário
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contactMethod === "whatsapp") {
|
|
||||||
window.open(generateWhatsAppUrl(), "_blank");
|
|
||||||
} else {
|
|
||||||
window.open(generateEmailUrl(), "_blank");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCtaMessage = () => {
|
|
||||||
if (projectType === "consultoria") {
|
|
||||||
return "Agendar Consultoria Gratuita";
|
|
||||||
}
|
|
||||||
if (budgetValue && parseFloat(budgetValue.replace(/\D/g, "")) < 2000) {
|
|
||||||
return "Discutir Soluções de Baixo Custo";
|
|
||||||
}
|
|
||||||
if (projectType && desiredDeadline && budgetValue) {
|
|
||||||
return "Receber Proposta Personalizada";
|
|
||||||
}
|
|
||||||
return "Enviar Formulário";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEstimatedValue = () => {
|
|
||||||
let hours = 0;
|
|
||||||
let hourlyRate = 55; // Taxa base R$ 55/h
|
|
||||||
|
|
||||||
// Estimativa de horas baseada no tipo de projeto
|
|
||||||
switch (projectType) {
|
|
||||||
case "web-app":
|
|
||||||
hours += 80; // Aplicação web completa
|
|
||||||
break;
|
|
||||||
case "landing-page":
|
|
||||||
hours += 16; // Landing page simples
|
|
||||||
break;
|
|
||||||
case "mvp":
|
|
||||||
hours += 40; // MVP básico
|
|
||||||
break;
|
|
||||||
case "iot-app":
|
|
||||||
hours += 120; // Aplicação IoT complexa
|
|
||||||
break;
|
|
||||||
case "integracao":
|
|
||||||
hours += 32; // Integração de sistemas
|
|
||||||
break;
|
|
||||||
case "manutencao":
|
|
||||||
hours += 8; // Manutenção pontual
|
|
||||||
break;
|
|
||||||
case "consultoria":
|
|
||||||
hours += 4; // Consultoria inicial
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajuste de horas baseado no prazo
|
|
||||||
switch (desiredDeadline) {
|
|
||||||
case "1-3-weeks":
|
|
||||||
hours *= 1.3; // Urgência aumenta horas
|
|
||||||
break;
|
|
||||||
case "1-2-months":
|
|
||||||
hours *= 1.1;
|
|
||||||
break;
|
|
||||||
case "6-plus-months":
|
|
||||||
hours *= 0.9; // Prazo longo pode reduzir horas
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajuste de horas baseado na complexidade
|
|
||||||
switch (technicalComplexity) {
|
|
||||||
case "alta":
|
|
||||||
hours *= 1.4;
|
|
||||||
hourlyRate = 65; // Taxa maior para alta complexidade
|
|
||||||
break;
|
|
||||||
case "muito-alta":
|
|
||||||
hours *= 1.7;
|
|
||||||
hourlyRate = 70; // Taxa máxima para muito alta complexidade
|
|
||||||
break;
|
|
||||||
case "media":
|
|
||||||
hourlyRate = 50; // Taxa média
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasInternalTeam === "nao") {
|
|
||||||
hours *= 1.2; // Sem equipe interna = mais horas necessárias
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalValue = hours * hourlyRate;
|
|
||||||
|
|
||||||
if (totalValue > 0) {
|
|
||||||
return `Estimativa: ${Math.round(
|
|
||||||
hours
|
|
||||||
)}h × R$ ${hourlyRate}/h = R$ ${totalValue.toFixed(0)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Estimativa: Solicite orçamento personalizado";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 30 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<h3 className="text-2xl font-semibold mb-6 bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">
|
|
||||||
Preencha o Formulário
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex justify-center gap-4 mb-8">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormType("simple")}
|
|
||||||
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
|
|
||||||
formType === "simple"
|
|
||||||
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
|
||||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Formulário Simples
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormType("advanced")}
|
|
||||||
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
|
|
||||||
formType === "advanced"
|
|
||||||
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
|
||||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Formulário Avançado
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formType === "simple" ? (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Campo Honeypot */}
|
|
||||||
<div style={{ display: "none" }}>
|
|
||||||
<label htmlFor="honeypot">Não preencha este campo</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="honeypot"
|
|
||||||
name="honeypot"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Formulário Simples: Apenas Nome */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="simple-user-name"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Seu Nome:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="simple-user-name"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
nameError ? "border-red-500" : "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="Seu nome"
|
|
||||||
value={userName}
|
|
||||||
onChange={handleNameChange}
|
|
||||||
onFocus={handleNameFocus}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{nameError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">{nameError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Botão de Contato para o formulário simples */}
|
|
||||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-green-600 hover:bg-green-700 text-white shadow-lg shadow-green-600/25"
|
|
||||||
onClick={() => window.open(generateWhatsAppUrl(), "_blank")}
|
|
||||||
>
|
|
||||||
Enviar Mensagem via WhatsApp
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Campo Honeypot */}
|
|
||||||
<div style={{ display: "none" }}>
|
|
||||||
<label htmlFor="honeypot">Não preencha este campo</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="honeypot"
|
|
||||||
name="honeypot"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dados Básicos */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xl font-semibold mb-4 text-gray-200">
|
|
||||||
Dados Básicos
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="user-name"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Nome Completo:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="user-name"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
nameError ? "border-red-500" : "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="Seu nome completo"
|
|
||||||
value={userName}
|
|
||||||
onChange={handleNameChange}
|
|
||||||
onFocus={handleNameFocus}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{nameError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">{nameError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="user-email"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Email Corporativo:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="user-email"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
emailError ? "border-red-500" : "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="[email protected]"
|
|
||||||
value={userEmail}
|
|
||||||
onChange={handleEmailChange}
|
|
||||||
onFocus={handleEmailFocus}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{emailError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">{emailError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="user-phone"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
WhatsApp/Telefone:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
id="user-phone"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
phoneError ? "border-red-500" : "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="(XX) XXXXX-XXXX"
|
|
||||||
value={userPhone}
|
|
||||||
onChange={handlePhoneChange}
|
|
||||||
onFocus={handlePhoneFocus}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{phoneError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">{phoneError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="user-company"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Empresa/Posição:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="user-company"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
|
||||||
placeholder="Nome da empresa, sua posição"
|
|
||||||
value={userCompany}
|
|
||||||
onChange={handleCompanyChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="how-found"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Como conheceu meu trabalho?
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="how-found"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={howFound}
|
|
||||||
onChange={handleHowFoundChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione uma opção
|
|
||||||
</option>
|
|
||||||
<option value="linkedin" className="bg-gray-800 text-white">
|
|
||||||
LinkedIn
|
|
||||||
</option>
|
|
||||||
<option value="github" className="bg-gray-800 text-white">
|
|
||||||
GitHub
|
|
||||||
</option>
|
|
||||||
<option value="indicacao" className="bg-gray-800 text-white">
|
|
||||||
Indicação
|
|
||||||
</option>
|
|
||||||
<option value="pesquisa" className="bg-gray-800 text-white">
|
|
||||||
Pesquisa Online
|
|
||||||
</option>
|
|
||||||
<option value="outro" className="bg-gray-800 text-white">
|
|
||||||
Outro
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tile de Expandir/Recolher */}
|
|
||||||
<div
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="p-4 rounded-lg bg-white/5 border border-white/10 text-white cursor-pointer hover:bg-white/10 transition-colors duration-300 mb-6 flex justify-between items-center"
|
|
||||||
>
|
|
||||||
<h4 className="text-xl font-semibold text-gray-200">
|
|
||||||
{isExpanded
|
|
||||||
? "Detalhes do Projeto (Recolher)"
|
|
||||||
: "Detalhes do Projeto (Expandir)"}
|
|
||||||
</h4>
|
|
||||||
<svg
|
|
||||||
className={`w-6 h-6 transition-transform duration-300 ${
|
|
||||||
isExpanded ? "rotate-180" : ""
|
|
||||||
}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden transition-all duration-500 ease-in-out ${
|
|
||||||
isExpanded ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
|
|
||||||
} space-y-6`}
|
|
||||||
>
|
|
||||||
{/* Conteúdo do Formulário Avançado */}
|
|
||||||
{/* Sobre o Projeto */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xl font-semibold mb-4 text-gray-200">
|
|
||||||
Sobre o Projeto
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="project-type"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Tipo de Projeto:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="project-type"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={projectType}
|
|
||||||
onChange={handleProjectTypeChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione o tipo
|
|
||||||
</option>
|
|
||||||
<option value="web-app" className="bg-gray-800 text-white">
|
|
||||||
Aplicação Web Completa
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="landing-page"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Landing Page
|
|
||||||
</option>
|
|
||||||
<option value="mvp" className="bg-gray-800 text-white">
|
|
||||||
MVP (Produto Mínimo Viável)
|
|
||||||
</option>
|
|
||||||
<option value="iot-app" className="bg-gray-800 text-white">
|
|
||||||
Aplicação IoT
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="integracao"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Integração de Sistemas
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="manutencao"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Manutenção/Ajustes
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="consultoria"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Consultoria/Mentoria
|
|
||||||
</option>
|
|
||||||
<option value="outro" className="bg-gray-800 text-white">
|
|
||||||
Outro
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{projectType === "outro" && (
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="other-project-type"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Qual outro tipo de projeto?
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="other-project-type"
|
|
||||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
|
||||||
otherProjectTypeError
|
|
||||||
? "border-red-500"
|
|
||||||
: "border-white/10"
|
|
||||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
|
||||||
placeholder="Ex: Desenvolvimento de Plugin, Automação de Processos"
|
|
||||||
value={otherProjectType}
|
|
||||||
onChange={(e) => {
|
|
||||||
setOtherProjectType(e.target.value);
|
|
||||||
if (e.target.value.trim() !== "")
|
|
||||||
setOtherProjectTypeError(null);
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{otherProjectTypeError && (
|
|
||||||
<p className="text-red-400 text-xs mt-1">
|
|
||||||
{otherProjectTypeError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="desired-deadline"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Prazo Desejado:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="desired-deadline"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={desiredDeadline}
|
|
||||||
onChange={handleDesiredDeadlineChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione o prazo
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="1-3-weeks"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
1-3 Semanas
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="1-2-months"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
1-2 Meses
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="2-4-months"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
2-4 Meses
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="4-6-months"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
4-6 Meses
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="6-plus-months"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Mais de 6 Meses
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="budget-value"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Orçamento Disponível (R$):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="budget-value"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
|
||||||
placeholder="Ex: 5000, 10000, A Combinar"
|
|
||||||
value={budgetValue}
|
|
||||||
onChange={handleBudgetValueChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="technical-complexity"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Complexidade Técnica Estimada:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="technical-complexity"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={technicalComplexity}
|
|
||||||
onChange={handleTechnicalComplexityChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione a complexidade
|
|
||||||
</option>
|
|
||||||
<option value="baixa" className="bg-gray-800 text-white">
|
|
||||||
Baixa
|
|
||||||
</option>
|
|
||||||
<option value="media" className="bg-gray-800 text-white">
|
|
||||||
Média
|
|
||||||
</option>
|
|
||||||
<option value="alta" className="bg-gray-800 text-white">
|
|
||||||
Alta
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="muito-alta"
|
|
||||||
className="bg-gray-800 text-white"
|
|
||||||
>
|
|
||||||
Muito Alta
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="has-internal-team"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Já tem equipe técnica interna?
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="has-internal-team"
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "16px",
|
|
||||||
}}
|
|
||||||
value={hasInternalTeam}
|
|
||||||
onChange={handleHasInternalTeamChange}
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Selecione
|
|
||||||
</option>
|
|
||||||
<option value="sim" className="bg-gray-800 text-white">
|
|
||||||
Sim
|
|
||||||
</option>
|
|
||||||
<option value="nao" className="bg-gray-800 text-white">
|
|
||||||
Não
|
|
||||||
</option>
|
|
||||||
<option value="parcial" className="bg-gray-800 text-white">
|
|
||||||
Parcialmente
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Message Input */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<label
|
|
||||||
htmlFor="custom-message"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Mensagem Adicional (Opcional):
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="custom-message"
|
|
||||||
rows={4}
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
|
||||||
placeholder="Descreva seu projeto ou suas necessidades aqui..."
|
|
||||||
value={customMessage}
|
|
||||||
onChange={handleMessageChange}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Button */}
|
|
||||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
|
||||||
>
|
|
||||||
{getCtaMessage()}
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
{projectType && desiredDeadline && budgetValue && (
|
|
||||||
<p className="text-center text-lg font-semibold text-cyan-400 mt-4">
|
|
||||||
{getEstimatedValue()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { Mail, MessageCircle } from "lucide-react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
|
|
||||||
interface ContactInfoProps {
|
|
||||||
onWhatsAppClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
|
||||||
const { data } = usePortfolioData();
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null; // Ou um componente de carregamento/erro
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -30 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-6 text-blue-400">
|
|
||||||
Entre em Contato
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<a
|
|
||||||
href={`mailto:${data.contact.email}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-full bg-blue-500/20">
|
|
||||||
<Mail className="w-6 h-6 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">Email</p>
|
|
||||||
<p className="text-gray-400">{data.contact.email}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onWhatsAppClick}
|
|
||||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-full bg-green-500/20">
|
|
||||||
<MessageCircle className="w-6 h-6 text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">WhatsApp</p>
|
|
||||||
<p className="text-gray-400">{data.contact.whatsapp}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={data.contact.linkedin}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-full bg-blue-500/20">
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6 text-blue-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">LinkedIn</p>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
{data.contact.linkedin.replace("https://www.", "")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={data.contact.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<div className="p-3 rounded-full bg-gray-500/20">
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6 text-gray-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">GitHub</p>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
{data.contact.github.replace("https://github.com/", "")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
|
|
||||||
Localização
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300">📍 {data.contact.location}</p>
|
|
||||||
<p className="text-gray-400 mt-2">
|
|
||||||
Disponível para projetos remotos e trabalho presencial na região.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline de Resposta (Placeholder) */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
|
|
||||||
Timeline de Resposta
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300">
|
|
||||||
Em breve, uma timeline detalhada do processo de contato e resposta.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"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,194 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
Terminal,
|
|
||||||
Cpu,
|
|
||||||
Network,
|
|
||||||
Code2
|
|
||||||
} from "lucide-react";
|
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
||||||
|
|
||||||
// --- Tipagem e Configuração (Otimização Pareto 80/20) ---
|
|
||||||
const CONFIG = {
|
|
||||||
"innovo-tecnologia": { color: "text-blue-400", bg: "bg-blue-400/10", border: "border-blue-400/20", icon: Cpu },
|
|
||||||
"apolo-energia": { color: "text-emerald-400", bg: "bg-emerald-400/10", border: "border-emerald-400/20", icon: Network },
|
|
||||||
default: { color: "text-slate-400", bg: "bg-slate-400/10", border: "border-slate-400/20", icon: Code2 }
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Experience() {
|
|
||||||
const { data, loading, error } = usePortfolioData();
|
|
||||||
|
|
||||||
// Memória: Evita re-processamento de regex em cada re-render (Princípio de Eficiência)
|
|
||||||
const sortedExperiences = useMemo(() => {
|
|
||||||
if (!data?.experiences) return [];
|
|
||||||
return [...data.experiences]
|
|
||||||
.filter((exp) => exp.featured)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const yearA = parseInt(a.period.match(/\d{4}/)?.[0] || "0");
|
|
||||||
const yearB = parseInt(b.period.match(/\d{4}/)?.[0] || "0");
|
|
||||||
return yearB - yearA; // Da mais recente para a mais antiga (Padrão Senior)
|
|
||||||
});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (loading) return <ExperienceSkeleton />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="experience" className="relative py-32">
|
|
||||||
{/* Background Gradients */}
|
|
||||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
|
|
||||||
<div className="absolute top-[20%] left-[-10%] w-[500px] h-[500px] bg-blue-900/10 rounded-full blur-[100px]" />
|
|
||||||
<div className="absolute bottom-[20%] right-[-10%] w-[500px] h-[500px] bg-indigo-900/10 rounded-full blur-[100px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6 relative z-10">
|
|
||||||
|
|
||||||
{/* Header com Identidade Visual */}
|
|
||||||
<div className="mb-24 text-center md:text-left">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-950/30 border border-blue-500/20 text-blue-400 font-mono text-xs tracking-wider mb-6"
|
|
||||||
>
|
|
||||||
<Terminal className="w-3 h-3" />
|
|
||||||
<span>SYSTEM_LOGS.history</span>
|
|
||||||
</motion.div>
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="text-4xl md:text-6xl font-black text-white tracking-tighter"
|
|
||||||
>
|
|
||||||
Trajetória <span className="bg-gradient-to-r from-blue-500 to-indigo-500 bg-clip-text text-transparent">Profissional</span>
|
|
||||||
</motion.h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{/* Timeline Desktop (Linha Central com Gradiente) */}
|
|
||||||
<div className="absolute left-[50%] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500/50 via-indigo-500/20 to-transparent hidden lg:block translate-x-[-50%]" />
|
|
||||||
|
|
||||||
<div className="space-y-24">
|
|
||||||
{sortedExperiences.map((exp, index) => (
|
|
||||||
<ExperienceCard key={exp.id} exp={exp} index={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sub-componente Card (Encapsulamento de Lógica de Transição) ---
|
|
||||||
function ExperienceCard({ exp, index }: { exp: any, index: number }) {
|
|
||||||
const isEven = index % 2 === 0;
|
|
||||||
const config = (CONFIG as any)[exp.company.toLowerCase().replace(/\s+/g, "-")] || CONFIG.default;
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative flex flex-col ${isEven ? 'lg:flex-row' : 'lg:flex-row-reverse'} items-center gap-8 lg:gap-16`}>
|
|
||||||
|
|
||||||
{/* Node Central (O "Ponto" da Timeline) */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
|
||||||
whileInView={{ scale: 1, opacity: 1 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.2, duration: 0.5 }}
|
|
||||||
className="hidden lg:flex absolute left-[50%] top-0 translate-x-[-50%] w-4 h-4 rounded-full bg-blue-500 shadow-[0_0_20px_rgba(59,130,246,0.5)] z-20 items-center justify-center"
|
|
||||||
>
|
|
||||||
<div className="w-2 h-2 rounded-full bg-white animate-pulse" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Indicador de Data na Timeline */}
|
|
||||||
<div className="hidden lg:flex flex-1 justify-end items-start pt-2">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: isEven ? -20 : 20 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
className={`${isEven ? 'text-right' : 'text-left'} flex-1`}
|
|
||||||
>
|
|
||||||
<span className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-b from-slate-700 to-slate-800 tracking-tighter opacity-50 select-none">
|
|
||||||
{exp.period.split(' ')[0]}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card Principal */}
|
|
||||||
<div className="flex-1 w-full relative group perspective-1000">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: isEven ? 50 : -50 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.1 * index }}
|
|
||||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
|
||||||
className={`p-8 rounded-3xl bg-zinc-900/40 border border-white/5 backdrop-blur-md relative overflow-hidden transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.1)] hover:border-blue-500/20`}
|
|
||||||
>
|
|
||||||
{/* Background Glow Effect on Hover */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
|
|
||||||
{/* Badge de Empresa & Header */}
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex flex-wrap justify-between items-start gap-4 mb-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg ${config.bg} ${config.color}`}>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<span className={`text-xs font-bold uppercase tracking-widest ${config.color}`}>
|
|
||||||
{exp.company}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-bold text-white group-hover:text-blue-400 transition-colors">
|
|
||||||
{exp.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-3 py-1 rounded-full bg-white/5 border border-white/10 text-[10px] font-mono font-bold text-slate-400 uppercase tracking-wide">
|
|
||||||
{exp.period}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-slate-400 leading-relaxed mb-8 text-sm">
|
|
||||||
{exp.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Conquistas (Checklist Técnico) */}
|
|
||||||
<div className="grid grid-cols-1 gap-3 mb-8">
|
|
||||||
{exp.achievements?.map((ach: string, i: number) => (
|
|
||||||
<div key={i} className="flex gap-3 text-sm text-slate-300 items-start">
|
|
||||||
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
|
|
||||||
<span>{ach}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags de Tech */}
|
|
||||||
<div className="flex flex-wrap gap-2 pt-6 border-t border-white/5">
|
|
||||||
{exp.technologies?.map((tech: string) => (
|
|
||||||
<span key={tech} className="px-3 py-1 bg-black/20 border border-white/5 text-slate-500 text-[10px] rounded-full font-mono hover:text-blue-400 hover:border-blue-400/30 transition-all cursor-default">
|
|
||||||
{tech}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spacer para Timeline Desktop */}
|
|
||||||
<div className="hidden lg:block flex-1" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExperienceSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="h-96 flex flex-col items-center justify-center gap-4 text-slate-600 font-mono animate-pulse">
|
|
||||||
<Cpu className="w-8 h-8 animate-spin" />
|
|
||||||
<span>BOOTING_SYSTEM_HISTORY...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @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$/),
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
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,32 +1,297 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Script from 'next/script';
|
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_MEASUREMENT_ID = 'G-QTT9QT0T3S';
|
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;
|
||||||
|
|
||||||
export default function GoogleAnalytics() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script
|
{consent === "granted" && process.env.NODE_ENV === "production" && (
|
||||||
src="/sys/libs/vendor-utils.js"
|
<Script
|
||||||
strategy="afterInteractive"
|
id="google-analytics-script"
|
||||||
/>
|
data-testid="google-analytics-script"
|
||||||
<Script id="google-analytics" strategy="afterInteractive">
|
src={GA_SCRIPT_SRC}
|
||||||
{`
|
strategy="afterInteractive"
|
||||||
window.dataLayer = window.dataLayer || [];
|
/>
|
||||||
function gtag(){dataLayer.push(arguments);}
|
)}
|
||||||
gtag('js', new Date());
|
{barOpen && (
|
||||||
|
<section
|
||||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
ref={barRef}
|
||||||
transport_url: window.location.origin + '/sys/api/vitals',
|
id={COOKIE_BAR_ID}
|
||||||
cookie_flags: 'SameSite=None;Secure',
|
role="dialog"
|
||||||
allow_ad_personalization_signals: false,
|
aria-modal="false"
|
||||||
anonymize_ip: true,
|
aria-label="Cookies e privacidade"
|
||||||
page_location: window.location.href,
|
aria-describedby="analytics-consent-description"
|
||||||
page_title: document.title
|
onKeyDown={(event) => {
|
||||||
});
|
if (event.key === "Escape" && preferencesOpen) closePreferences();
|
||||||
`}
|
}}
|
||||||
</Script>
|
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 />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* @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$/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
+283
-151
@@ -1,171 +1,303 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import { usePathname } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { MenuIcon, XIcon } from "lucide-react";
|
import { COOKIE_BAR_ID } from "@/lib/analytics";
|
||||||
import Image from "next/image";
|
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
|
headerOpensWhatsApp,
|
||||||
|
type WhatsAppContexts,
|
||||||
|
whatsAppSubjectForPath,
|
||||||
|
} from "@/lib/whatsapp";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
// Definição de Tipos para consistência de dados
|
const MENU_ID = "site-navigation-menu";
|
||||||
interface NavItem {
|
const CTA_LABEL = "Falar sobre meu projeto";
|
||||||
href: string;
|
/** Fora da home o botão abre o WhatsApp: "Falar sobre meu projeto" é sempre o formulário. */
|
||||||
label: string;
|
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]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
/** 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. */
|
||||||
{ href: "#home", label: "Home" },
|
function trapTab(event: KeyboardEvent, header: HTMLElement) {
|
||||||
{ href: "#about", label: "Sobre" },
|
const focusables = menuFocusables(header);
|
||||||
{ href: "#skills", label: "Habilidades" },
|
const first = focusables[0];
|
||||||
{ href: "#experience", label: "Experiência" },
|
const last = focusables[focusables.length - 1];
|
||||||
{ href: "#projects", label: "Projetos" },
|
const [edge, opposite] = event.shiftKey ? [first, last] : [last, first];
|
||||||
{ href: "#contact", label: "Contato" },
|
const active = document.activeElement;
|
||||||
];
|
const leaving =
|
||||||
|
active === edge || !focusables.some((element) => element === active);
|
||||||
|
if (!leaving || !opposite) return;
|
||||||
|
|
||||||
export function Header() {
|
event.preventDefault();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
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 [scrolled, setScrolled] = useState(false);
|
||||||
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
|
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Hook de detecção de scroll para mudança de estado do Header
|
// 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(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
setMobileMenuOpen(false);
|
||||||
setScrolled(window.scrollY > 20);
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let wasScrolled = false;
|
||||||
|
const updateScrolled = () => {
|
||||||
|
const nextScrolled = window.scrollY > 0;
|
||||||
|
if (nextScrolled === wasScrolled) return;
|
||||||
|
wasScrolled = nextScrolled;
|
||||||
|
setScrolled(nextScrolled);
|
||||||
};
|
};
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
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 (
|
return (
|
||||||
<>
|
<header
|
||||||
<motion.header
|
ref={headerRef}
|
||||||
initial={{ y: -100 }}
|
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 ${
|
||||||
animate={{ y: 0 }}
|
scrolled
|
||||||
className={cn(
|
? "border-white/12 bg-[var(--ink)]"
|
||||||
"fixed top-0 left-0 right-0 z-[100] transition-all duration-500 ease-in-out px-6 py-4",
|
: "border-transparent bg-[var(--ink)]"
|
||||||
scrolled
|
}`}
|
||||||
? "bg-black/40 backdrop-blur-xl border-b border-white/5 py-3"
|
>
|
||||||
: "bg-transparent py-6"
|
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
|
||||||
)}
|
<HoverPrefetchLink
|
||||||
>
|
href="/#home"
|
||||||
<nav className="max-w-7xl mx-auto flex items-center justify-between">
|
aria-label="Evandro.dev.br — início"
|
||||||
{/* Logo com Identidade Visual de Engenheiro */}
|
className="flex min-h-11 shrink-0 items-center no-underline"
|
||||||
<motion.a
|
onClick={closeMobileMenu}
|
||||||
href="#home"
|
>
|
||||||
onClick={(e) => {
|
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
|
||||||
e.preventDefault();
|
</HoverPrefetchLink>
|
||||||
const element = document.querySelector("#home");
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/edev_logo.svg"
|
|
||||||
alt="Evandro.dev Logo"
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="w-8 h-8"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-black tracking-tighter text-white">
|
|
||||||
EVANDRO<span className="text-blue-500">.DEV</span>
|
|
||||||
</span>
|
|
||||||
</motion.a>
|
|
||||||
|
|
||||||
{/* Desktop Navigation - Focada em Espaçamento Ótimo */}
|
<nav
|
||||||
<div className="hidden md:flex items-center gap-1 bg-white/5 p-1 rounded-full border border-white/10 backdrop-blur-md">
|
aria-label="Navegação principal"
|
||||||
{navItems.map((item) => (
|
data-menu-trap-skip=""
|
||||||
<a
|
className="hidden items-center xl:flex"
|
||||||
key={item.href}
|
>
|
||||||
href={item.href}
|
<ul className="flex list-none items-center gap-sp-4">
|
||||||
onClick={(e) => {
|
{NAVIGATION_ITEMS.map((item) => (
|
||||||
e.preventDefault();
|
<li key={item.href}>
|
||||||
const element = document.querySelector(item.href);
|
<HoverPrefetchLink
|
||||||
if (element) {
|
href={item.href}
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
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}
|
||||||
className="px-5 py-2 text-xs font-bold uppercase tracking-widest text-slate-300 hover:text-white hover:bg-white/10 rounded-full transition-all duration-300 cursor-pointer"
|
</HoverPrefetchLink>
|
||||||
>
|
</li>
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
{/* Botão de Ação CTA (Call to Action) */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<a
|
|
||||||
href="/Profile.pdf"
|
|
||||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
|
||||||
className="px-6 py-2 bg-white text-black text-xs font-bold uppercase tracking-widest rounded-full hover:bg-blue-500 hover:text-white transition-all duration-500 inline-block"
|
|
||||||
>
|
|
||||||
Download CV
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
||||||
className="md:hidden p-2 text-white bg-white/5 rounded-lg border border-white/10"
|
|
||||||
>
|
|
||||||
{isMenuOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
</motion.header>
|
|
||||||
|
|
||||||
{/* Mobile Menu Overlay com Framer Motion para fluidez */}
|
<div className="flex shrink-0 items-center">
|
||||||
<AnimatePresence>
|
<div className="hidden xl:block" data-menu-trap-skip="">
|
||||||
{isMenuOpen && (
|
<ContactCta
|
||||||
<motion.div
|
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"
|
||||||
initial={{ opacity: 0, x: "100%" }}
|
placement="header"
|
||||||
animate={{ opacity: 1, x: 0 }}
|
viaWhatsApp={viaWhatsApp}
|
||||||
exit={{ opacity: 0, x: "100%" }}
|
subject={subject}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
/>
|
||||||
className="fixed inset-0 z-[110] bg-black/95 backdrop-blur-2xl md:hidden flex flex-col items-center justify-center gap-8"
|
</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}
|
||||||
>
|
>
|
||||||
<button
|
<span
|
||||||
onClick={() => setIsMenuOpen(false)}
|
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[10px] rotate-45" : ""}`}
|
||||||
className="absolute top-8 right-8 p-4 text-white/50 hover:text-white"
|
/>
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<XIcon size={32} />
|
<ul className="flex list-none flex-col">
|
||||||
</button>
|
{NAVIGATION_ITEMS.map((item) => (
|
||||||
|
<li key={item.href} className="border-b border-white/10">
|
||||||
{navItems.map((item, i) => (
|
<HoverPrefetchLink
|
||||||
<motion.a
|
href={item.href}
|
||||||
key={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"
|
||||||
href={item.href}
|
onClick={closeMobileMenu}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{item.label}
|
||||||
transition={{ delay: i * 0.1 }}
|
</HoverPrefetchLink>
|
||||||
onClick={(e) => {
|
</li>
|
||||||
e.preventDefault();
|
))}
|
||||||
setIsMenuOpen(false);
|
</ul>
|
||||||
const element = document.querySelector(item.href);
|
<div className="mt-auto pt-sp-8">
|
||||||
if (element) {
|
<ContactCta
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
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}
|
||||||
className="text-3xl font-black text-white uppercase tracking-tighter hover:text-blue-500 transition-colors cursor-pointer"
|
subject={subject}
|
||||||
>
|
onNavigate={closeMobileMenu}
|
||||||
{item.label}
|
/>
|
||||||
</motion.a>
|
</div>
|
||||||
))}
|
</nav>
|
||||||
<motion.a
|
</div>
|
||||||
href="/Profile.pdf"
|
</div>
|
||||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
) : null}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
</header>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: navItems.length * 0.1 }}
|
|
||||||
className="mt-8 px-8 py-4 bg-white text-black text-xl font-black uppercase tracking-tighter rounded-full hover:bg-blue-500 hover:text-white transition-all duration-300"
|
|
||||||
>
|
|
||||||
Download CV
|
|
||||||
</motion.a>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+255
-127
@@ -1,145 +1,273 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { AuroraText } from "@/components/magicui/aurora-text";
|
import { type CSSProperties, Fragment } from "react";
|
||||||
import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text";
|
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
||||||
import { Github, Linkedin, MessageCircle, Mail, MapPin, Terminal } from "lucide-react";
|
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
||||||
import TextType from "@/components/TextType";
|
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";
|
||||||
|
|
||||||
export default function Hero() {
|
const SOCIAL_LINKS = [
|
||||||
const getGreetingMessage = () => {
|
{
|
||||||
const hour = new Date().getHours();
|
href: LINKEDIN_URL,
|
||||||
if (hour >= 8 && hour < 12) return "Olá! Vi seu portfólio. Podemos conversar?";
|
label: "LinkedIn",
|
||||||
if (hour >= 13 && hour < 18) return "Boa tarde! Vi seu portfólio. Podemos conversar?";
|
onClick: () => trackSocialClick({ network: "linkedin" }),
|
||||||
return "Olá! Vi seu portfólio. Podemos conversar quando estiver disponível?";
|
},
|
||||||
};
|
{
|
||||||
|
href: `mailto:${CONTACT_EMAIL}`,
|
||||||
|
label: "Email",
|
||||||
|
onClick: () => trackEmailClick({ placement: "hero" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const generateWhatsAppUrl = () => {
|
/**
|
||||||
const message = encodeURIComponent(getGreetingMessage());
|
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json
|
||||||
return `https://wa.me/5547992742365?text=${message}`;
|
* 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 (
|
return (
|
||||||
<section id="home" className="relative min-h-screen flex items-center justify-center overflow-hidden pt-28 md:pt-20">
|
<section
|
||||||
{/* 1. Background Engineering: Grid Pattern & Radial Shading */}
|
id="home"
|
||||||
<div className="absolute inset-0 z-0 pointer-events-none">
|
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||||
<div className="absolute inset-0 bg-radial-at-t from-blue-500/10 via-transparent to-transparent" />
|
>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-6 grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
{/* 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)]">
|
||||||
{/* Lado Esquerdo: Conteúdo Textual (80/20 do Impacto) */}
|
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
|
||||||
<div className="lg:col-span-7 text-left">
|
{/* 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. */}
|
||||||
<motion.div
|
<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)]">
|
||||||
initial={{ opacity: 0, x: -20 }}
|
<span
|
||||||
animate={{ opacity: 1, x: 0 }}
|
aria-hidden="true"
|
||||||
transition={{ duration: 0.6 }}
|
className="h-2 w-2 shrink-0 bg-[var(--slash)]"
|
||||||
>
|
/>
|
||||||
<AnimatedGradientText className="mb-6 inline-flex items-center gap-2 px-4 py-2 rounded-full border border-blue-500/20 bg-blue-500/5 backdrop-blur-md">
|
<span>
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="max-lg:hidden">Evandro Fonseca Junior · </span>
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
Engenharia de software
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span>
|
</span>
|
||||||
</span>
|
</p>
|
||||||
<span className="text-sm font-medium text-cyan-300 tracking-wide uppercase">
|
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
||||||
Systems Engineer available
|
{/* No desktop o texto fica na coluna da esquerda (--hero-col, em globals.css); a peça (.hero-piece) vem logo à direita. */}
|
||||||
</span>
|
<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]">
|
||||||
</AnimatedGradientText>
|
{TITLE.map(({ key, accent, words }) => (
|
||||||
|
<Fragment key={key}>
|
||||||
<h1 className="text-5xl md:text-7xl font-black text-white mb-4 tracking-tight leading-none">
|
{accent ? (
|
||||||
Evandro <br />
|
// o marcador (::after) vive no trecho e não em cada palavra: um bloco contínuo sobre "sob medida"
|
||||||
<AuroraText className="italic">Fonseca Junior</AuroraText>
|
<span
|
||||||
</h1>
|
className="hero-mark whitespace-nowrap"
|
||||||
|
style={{ "--i": words[0].index } as CSSProperties}
|
||||||
<div className="h-10 flex items-center gap-2 mb-6">
|
|
||||||
<Terminal className="w-5 h-5 text-blue-400" />
|
|
||||||
<TextType
|
|
||||||
text={["Engenheiro de Software", "Next.js & Tauri Specialist", "High Performance Systems Architect"]}
|
|
||||||
className="text-2xl font-mono text-slate-400"
|
|
||||||
typingSpeed={60}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-lg text-slate-400 max-w-xl mb-10 leading-relaxed">
|
|
||||||
Engenheiro de Software especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com <span className="text-white font-medium">Next.js 15, React 19, Tauri</span> e arquitetura escalável. Transformo requisitos complexos em software robusto.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 items-center">
|
|
||||||
<motion.a
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
href={generateWhatsAppUrl()}
|
|
||||||
className="px-8 py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-600/20 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<MessageCircle className="w-5 h-5" />
|
|
||||||
Iniciar Projeto
|
|
||||||
</motion.a>
|
|
||||||
|
|
||||||
<div className="flex gap-4 px-4">
|
|
||||||
{[
|
|
||||||
{ icon: Github, link: "https://github.com/evandrodevbr", rel: "me" },
|
|
||||||
{ icon: Linkedin, link: "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/", rel: "me" },
|
|
||||||
{ icon: Mail, link: "mailto:[email protected]" }
|
|
||||||
].map((social, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={social.link}
|
|
||||||
rel={social.rel ? `${social.rel} noopener noreferrer` : "noopener noreferrer"}
|
|
||||||
target={social.link.startsWith("mailto:") ? undefined : "_blank"}
|
|
||||||
className="p-3 rounded-lg border border-slate-800 text-slate-400 hover:text-white hover:border-slate-600 transition-all"
|
|
||||||
>
|
>
|
||||||
<social.icon className="w-5 h-5" />
|
<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>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
|
||||||
|
Santa Catarina · Atendimento remoto
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-8 flex items-center gap-2 text-slate-500 text-sm">
|
<p
|
||||||
<MapPin className="w-4 h-4" />
|
aria-hidden="true"
|
||||||
<span>Garuva, SC | Remote Worldwide</span>
|
className="hero-cue hidden items-center gap-sp-3 font-display text-[13px] tracking-[0.02em] text-[rgba(244,242,237,.55)] xl:flex"
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lado Direito: Visual (A "Âncora" do Design) */}
|
|
||||||
<div className="lg:col-span-5 relative flex justify-center">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
|
||||||
className="relative w-72 h-72 md:w-96 md:h-96"
|
|
||||||
>
|
>
|
||||||
{/* Elementos Orbitais (Vibe Engenharia) */}
|
Role para mergulhar
|
||||||
<div className="absolute inset-0 rounded-full border border-dashed border-blue-500/30 animate-[spin_20s_linear_infinite]" />
|
<span className="hero-cue-line" />
|
||||||
<div className="absolute inset-10 rounded-full border border-blue-500/10 animate-[spin_15s_linear_infinite_reverse]" />
|
</p>
|
||||||
|
|
||||||
{/* Foto de Perfil com Masking de Engenharia */}
|
|
||||||
<div className="relative z-10 w-full h-full rounded-[2.5rem] overflow-hidden border-2 border-slate-800 shadow-2xl">
|
|
||||||
<div className="absolute inset-0 bg-blue-500/10 z-10 hover:opacity-0 transition-opacity duration-500" />
|
|
||||||
<Image
|
|
||||||
src="/edev.jpeg"
|
|
||||||
alt="Evandro Fonseca Junior"
|
|
||||||
fill
|
|
||||||
className="object-cover grayscale hover:grayscale-0 transition-all duration-700 scale-105 hover:scale-100"
|
|
||||||
priority
|
|
||||||
fetchPriority="high"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floating Stats Badge */}
|
|
||||||
<div className="absolute -bottom-6 -right-6 bg-slate-900 border border-slate-800 p-4 rounded-2xl shadow-2xl z-20 backdrop-blur-xl">
|
|
||||||
<p className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Exp. Level</p>
|
|
||||||
<p className="text-xl font-mono text-blue-400">Mid-Level Engineer</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll Indicator (Refinado) */}
|
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2">
|
|
||||||
<span className="text-[10px] text-slate-600 uppercase tracking-[0.2em]">Scroll</span>
|
|
||||||
<div className="w-[1px] h-12 bg-gradient-to-b from-blue-500 to-transparent" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 500 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user