Technical Architecture & Decisions — Tech Tennis
Decisões técnicas e arquiteturais. Referência para implementação. Última atualização: 2026-03-11
Tech Stack
| Camada | Tecnologia | Versão | Rationale |
|---|---|---|---|
| Framework | Next.js | 16.x | App Router, Server Components, melhor para SEO |
| Language | TypeScript | 5.x | Strict mode. Prisma gera tipos automaticamente |
| Styling | Tailwind CSS | 4.x | CSS variables, dark mode, zero runtime |
| Components | shadcn/ui | 3.8.5 | Copy-paste components, full control, Radix primitives |
| Icons | Lucide React | 0.575+ | Integrado com shadcn |
| Database | Supabase (PostgreSQL) | — | Managed, free tier generoso, auth built-in (Phase 2) |
| ORM | Prisma | Latest | Type-safe queries, migrations, studio para debug |
| Map | Mapbox GL JS | — | 50k free loads, react-map-gl v8 |
| Map React | react-map-gl | v8+ | Import: react-map-gl/mapbox (v8 breaking change) |
| Package Manager | pnpm | — | Faster, disk-efficient |
| Deployment | Vercel | — | Zero-config para Next.js, free tier |
| Testing | Vitest | — | Fast, 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ão | Usar Para | Prisma? |
|---|---|---|
| Server Component | Páginas read-only, SEO | SIM — chama DAL direto |
| Client Component | Mapa, forms, interatividade | NÃO — recebe dados via props |
| Server Action | Mutations (form submit) | SIM — chama DAL |
| Route Handler | API externa futura, webhooks | SIM — 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)directUrlno 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_idcomo 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
| Config | Valor | Rationale |
|---|---|---|
| Import path | react-map-gl/mapbox | v8 breaking change — NÃO usar react-map-gl |
| reuseMaps | true | Evita billing events duplicados |
| CSP headers | worker-src blob:, child-src blob:, connect-src *.tiles.mapbox.com api.mapbox.com | Mapbox usa Web Workers + WASM |
| Token | NEXT_PUBLIC_MAPBOX_TOKEN | Client-side, restringir por URL referrer no dashboard Mapbox |
| Free tier | 50,000 map loads/mês | Cobre ~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
| Requisito | Implementação |
|---|---|
| Bairro | Campo obrigatório em Club. Crítico para SEO e navegação local |
| CEP | Formato XXXXX-XXX. API ViaCEP para autocomplete (Phase 2) |
| Telefone | Armazenar E.164 (+5511987654321), exibir (11) 98765-4321 |
Link: https://wa.me/5511987654321 (sem +, sem traços) | |
| Moeda | Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }) → R$ 80,00 |
| Slug | Accent stripping: "São Paulo" → sao-paulo |
| LGPD | Consent 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-LD | TennisComplex (mais específico que SportsActivityLocation) |
Testing Strategy
| Camada | Testar? | Framework | Quando |
|---|---|---|---|
| DAL (queries) | SIM | Vitest | Após implementação |
| Server Actions | SIM | Vitest | Após implementação |
| UI Components | NÃO (Phase 1) | — | QA visual por Playwright |
| Pages | NÃO (Phase 1) | — | QA funcional por agentes |
| E2E | SIM (QA) | Playwright | Via agentes, não como test suite |
Supabase Free Tier Limits
| Recurso | Free | Pro ($25/mo) | Quando Upgrade |
|---|---|---|---|
| DB storage | 500 MB | 8 GB | Improvável na Phase 1 |
| File storage | 1 GB | 100 GB | Quando tiver upload de imagens |
| Bandwidth | 5 GB/mo | 250 GB/mo | Se tráfego crescer muito |
| Auth MAUs | 50,000 | 100,000 | Phase 2 |
| Connections | 60 direct / 200 pooler | Same | Suficiente |
| Pausa por inatividade | 7 dias | Nunca | Upgrade antes de produção real |
| Backups | Nenhum | Diário | Upgrade antes de dados reais de clubes |
Git & Release Workflow
Regras (obrigatórias)
- NUNCA commitar direto no
main— sempre criar feature branch - Branches:
feat/<nome>,fix/<nome>,chore/<nome>,docs/<nome> - Commits atômicos: 1 commit = 1 preocupação. Máximo ~4 arquivos por commit. Splits por diretório/módulo/concern
- Commit style: Semantic English —
feat(scope): message,fix(scope): message, etc. - Commit body: Descreve o "porquê", não o "o quê". Incluir footer Sisyphus + Co-authored-by
- PRs: Título claro, body com
## Summaryem bullets, labels se aplicável - Merge: Squash-and-merge via PR para manter
mainlimpo. Deletar branch após merge - 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
| Tipo | Branch | Commit prefix |
|---|---|---|
| Feature nova | feat/<nome> | feat(scope): |
| Bug fix | fix/<nome> | fix(scope): |
| Refactor | refactor/<nome> | refactor(scope): |
| Docs | docs/<nome> | docs(scope): |
| Config/tooling | chore/<nome> | chore(scope): |
| Performance | perf/<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