🎾 Tech Tennis
Todos os documentos
⚙️ Técnico

Technical Architecture & Decisions — Tech Tennis

Decisões técnicas e arquiteturais. Referência para implementação. Última atualização: 2026-03-11

Tech Stack

CamadaTecnologiaVersãoRationale
FrameworkNext.js16.xApp Router, Server Components, melhor para SEO
LanguageTypeScript5.xStrict mode. Prisma gera tipos automaticamente
StylingTailwind CSS4.xCSS variables, dark mode, zero runtime
Componentsshadcn/ui3.8.5Copy-paste components, full control, Radix primitives
IconsLucide React0.575+Integrado com shadcn
DatabaseSupabase (PostgreSQL)Managed, free tier generoso, auth built-in (Phase 2)
ORMPrismaLatestType-safe queries, migrations, studio para debug
MapMapbox GL JS50k free loads, react-map-gl v8
Map Reactreact-map-glv8+Import: react-map-gl/mapbox (v8 breaking change)
Package ManagerpnpmFaster, disk-efficient
DeploymentVercelZero-config para Next.js, free tier
TestingVitestFast, ESM-native, works with Next.js

Architecture Patterns

Data Access Layer (DAL)

Nunca chamar prisma diretamente de Server Components. Usar camada DAL:

src/lib/
├── prisma.ts          ← Singleton (NEVER import PrismaClient elsewhere)
└── dal/
    ├── clubs.ts       ← getClubs(), getClubBySlug(), getClubsByNeighborhood()
    ├── contacts.ts    ← createContactSubmission()
    └── interests.ts   ← createPlayerInterest()

Rationale: Centraliza queries, facilita testes, evita duplicação, permite caching futuro.

Server Components vs Client Components

PadrãoUsar ParaPrisma?
Server ComponentPáginas read-only, SEOSIM — chama DAL direto
Client ComponentMapa, forms, interatividadeNÃO — recebe dados via props
Server ActionMutations (form submit)SIM — chama DAL
Route HandlerAPI externa futura, webhooksSIM — chama DAL

Prisma Singleton Pattern (Obrigatório)

// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Por que: Next.js hot-reload cria nova instância a cada mudança de arquivo. Sem singleton, esgota conexões em minutos.

Database Architecture

Connection Strings (Supabase + Prisma)

# Runtime (Vercel serverless) — Supavisor Transaction Mode
DATABASE_URL="postgres://prisma.[REF]:[PW]@aws-0-region.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"

# Migrations (Prisma CLI) — Direct connection
DIRECT_URL="postgres://prisma.[REF]:[PW]@db.[REF].supabase.co:5432/postgres"

Pontos críticos:

  • ?pgbouncer=true — obrigatório no port 6543 (desabilita prepared statements)
  • &connection_limit=1 — obrigatório para serverless (Vercel)
  • directUrl no schema.prisma — Prisma CLI usa isso para migrations

Prisma DB User (Segurança)

Criar user dedicado no Supabase SQL Editor (NÃO usar postgres superuser):

CREATE USER "prisma" WITH PASSWORD 'strong_password' BYPASSRLS CREATEDB;
GRANT "prisma" TO "postgres";
-- + grants de USAGE, CREATE, ALL em schema public

Multi-Tenancy (Phase 2+)

  • Pattern: Shared tables com club_id como tenant column
  • Isolação: Application-level filtering (Phase 2), RLS como segunda camada (Phase 3)
  • Phase 1: Sem tenancy — tudo é público e read-only

Map Architecture

Mapbox Configuration

ConfigValorRationale
Import pathreact-map-gl/mapboxv8 breaking change — NÃO usar react-map-gl
reuseMapstrueEvita billing events duplicados
CSP headersworker-src blob:, child-src blob:, connect-src *.tiles.mapbox.com api.mapbox.comMapbox usa Web Workers + WASM
TokenNEXT_PUBLIC_MAPBOX_TOKENClient-side, restringir por URL referrer no dashboard Mapbox
Free tier50,000 map loads/mêsCobre ~15-20k MAU

Migration Path

Phase 1:  Mapbox GL JS + react-map-gl/mapbox     → Free 50k loads
Phase 2:  Se custo crescer → MapLibre GL JS       → Troca 1 import
Phase 3:  Se auto-hospedar → Protomaps (PMTiles)  → ~$5/mês qualquer escala

A troca de Mapbox pra MapLibre é literalmente mudar uma linha de import:

// Antes: import Map from 'react-map-gl/mapbox'
// Depois: import Map from 'react-map-gl/maplibre'

Next.js 16 Gotchas

params é Promise (Breaking Change)

// CORRETO (Next.js 16)
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
}

// ERRADO (Next.js 14 syntax — runtime error)
export default function Page({ params }: { params: { slug: string } }) {
  const { slug } = params; // TypeError
}

Static Generation + Dynamic Params

// Gera páginas estáticas para clubes conhecidos
export async function generateStaticParams() {
  const clubs = await getPublishedClubs();
  return clubs.map((club) => ({ slug: club.slug }));
}

// dynamicParams = true (default) → novos clubes geram SSR on-demand

Brazil-Specific Requirements

RequisitoImplementação
BairroCampo obrigatório em Club. Crítico para SEO e navegação local
CEPFormato XXXXX-XXX. API ViaCEP para autocomplete (Phase 2)
TelefoneArmazenar E.164 (+5511987654321), exibir (11) 98765-4321
WhatsAppLink: https://wa.me/5511987654321 (sem +, sem traços)
MoedaIntl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' })R$ 80,00
SlugAccent stripping: "São Paulo" → sao-paulo
LGPDConsent checkbox + timestamp em todos os forms
Privacidade/politica-de-privacidade com 8 itens obrigatórios
OG Images< 300KB para WhatsApp não dropar preview
JSON-LDTennisComplex (mais específico que SportsActivityLocation)

Testing Strategy

CamadaTestar?FrameworkQuando
DAL (queries)SIMVitestApós implementação
Server ActionsSIMVitestApós implementação
UI ComponentsNÃO (Phase 1)QA visual por Playwright
PagesNÃO (Phase 1)QA funcional por agentes
E2ESIM (QA)PlaywrightVia agentes, não como test suite

Supabase Free Tier Limits

RecursoFreePro ($25/mo)Quando Upgrade
DB storage500 MB8 GBImprovável na Phase 1
File storage1 GB100 GBQuando tiver upload de imagens
Bandwidth5 GB/mo250 GB/moSe tráfego crescer muito
Auth MAUs50,000100,000Phase 2
Connections60 direct / 200 poolerSameSuficiente
Pausa por inatividade7 diasNuncaUpgrade antes de produção real
BackupsNenhumDiárioUpgrade antes de dados reais de clubes

Git & Release Workflow

Regras (obrigatórias)

  1. NUNCA commitar direto no main — sempre criar feature branch
  2. Branches: feat/<nome>, fix/<nome>, chore/<nome>, docs/<nome>
  3. Commits atômicos: 1 commit = 1 preocupação. Máximo ~4 arquivos por commit. Splits por diretório/módulo/concern
  4. Commit style: Semantic English — feat(scope): message, fix(scope): message, etc.
  5. Commit body: Descreve o "porquê", não o "o quê". Incluir footer Sisyphus + Co-authored-by
  6. PRs: Título claro, body com ## Summary em bullets, labels se aplicável
  7. Merge: Squash-and-merge via PR para manter main limpo. Deletar branch após merge
  8. Mínimo de commits: ceil(arquivos_mudados / 3). 5 arquivos = no mínimo 2 commits. 10 = no mínimo 4

Exemplo de workflow

# 1. Criar branch
git checkout -b feat/nova-feature

# 2. Trabalhar e commitar atomicamente
git add src/lib/novo-util.ts
git commit -m "feat(lib): add novo-util helper" \
  -m "Centraliza lógica de X para reutilizar em Y e Z." \
  -m "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" \
  -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"

git add src/components/novo-componente.tsx
git commit -m "feat(components): add novo-componente"

# 3. Push e abrir PR
git push -u origin feat/nova-feature
gh pr create --title "feat: descrição curta" --body "## Summary\n- ..."

# 4. Após aprovação: squash-and-merge, deletar branch

Naming conventions

TipoBranchCommit prefix
Feature novafeat/<nome>feat(scope):
Bug fixfix/<nome>fix(scope):
Refactorrefactor/<nome>refactor(scope):
Docsdocs/<nome>docs(scope):
Config/toolingchore/<nome>chore(scope):
Performanceperf/<nome>perf(scope):

Decisões Pendentes

  • PostGIS: adicionar na Phase 2 quando tiver mais clubes e precisar de busca por raio
  • Image hosting: Supabase Storage vs URLs externas vs S3
  • Caching strategy: ISR vs on-demand revalidation
  • Analytics: Vercel Analytics vs Google Analytics vs Plausible
  • Error monitoring: Sentry? (Phase 2)

Referências: .sisyphus/plans/b2c-directory.md