From a4303a4f2abade2d8e775aab456c5baa18898226 Mon Sep 17 00:00:00 2001 From: tony max Date: Sun, 14 Jun 2026 21:55:55 -0300 Subject: [PATCH 01/13] docs: reestruturar documentacao principal e integrar guia de revisao de prs --- CONTRIBUTING.md | 2 +- README.md | 4 +- docs/ARCHITECTURE.md | 9 ++ docs/CODEBASE_MAP.md | 13 +-- docs/PR_REVIEW_GUIDE.md | 183 ++++++++++++++++++++++++++++++++++++++ docs/VISION_MATCH_TECH.md | 17 ++-- 6 files changed, 212 insertions(+), 16 deletions(-) create mode 100644 docs/PR_REVIEW_GUIDE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0010a93..5595bf5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,7 +233,7 @@ Closes #42 # Revisão -Todo Pull Request pode receber comentários e solicitações de alteração. +Todo Pull Request pode receber comentários e solicitações de alteração. Se você for um revisor ou quiser entender como o processo de revisão e aprovação funciona no projeto, consulte o [`docs/PR_REVIEW_GUIDE.md`](docs/PR_REVIEW_GUIDE.md). O processo de revisão tem como objetivo: diff --git a/README.md b/README.md index abdef68..d99e660 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,9 @@ O deploy é automático via **Vercel** ao fazer merge em `main`. ## Contribuindo -Contribuições são bem-vindas! Leia [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) para entender os padrões adotados antes de abrir um PR. +Contribuições são bem-vindas! Leia [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) para entender os padrões de código adotados antes de começar a desenvolver. + +Se você estiver revisando Pull Requests de outros colaboradores ou quiser entender as regras de aceite de código, consulte o [`docs/PR_REVIEW_GUIDE.md`](docs/PR_REVIEW_GUIDE.md). 1. Faça um fork e clone o repositório 2. Crie uma branch: `git checkout -b feat/minha-feature` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de88493..4bad483 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,6 +24,7 @@ Este documento descreve a arquitetura técnica do projeto, os padrões de design 16. [Deploy e CI/CD](#16-deploy-e-cicd) 17. [Docker e Containers](#17-docker-e-containers) 18. [Scripts Utilitários](#18-scripts-utilitários) +19. [Revisão de Código e PRs](#19-revisão-de-código-e-prs) --- @@ -851,3 +852,11 @@ npm run migrate:members-to-profiles -- --execute ``` **Pré-requisito:** a variável `FIREBASE_SERVICE_ACCOUNT` deve conter o JSON da service account do Firebase Admin, ou `GOOGLE_APPLICATION_CREDENTIALS` deve apontar para o arquivo JSON equivalente. + +--- + +## 19. Revisão de Código e PRs + +Para manter a codebase organizada e estável à medida que mais desenvolvedores contribuem, adotamos um processo estruturado de revisão de Pull Requests. + +O fluxo de trabalho, os critérios de aceitação para merges e os checklists de revisão estão documentados em detalhes no [Guia de Revisão de PRs](./PR_REVIEW_GUIDE.md). diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index c806b3b..81a099b 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -150,12 +150,13 @@ match-tech/ │ └── main.tsx # createRoot + global error listeners │ ├── docs/ -│ ├── ARCHITECTURE.md ← Referência técnica principal (atualizada) -│ ├── CODEBASE_MAP.md ← Este arquivo -│ ├── VISION_MATCH_TECH.md ← Visão de produto e design system -│ ├── TODO_MATCH_TECH.md ← Histórico de desenvolvimento (arquivo) -│ ├── FRONTEND_BLUEPRINT.md ← Blueprint original (depreciado → ver ARCHITECTURE.md) -│ └── hackathon_tech_floripa_2026_strategy.md ← Estratégia do evento +│ ├── [ARCHITECTURE.md](./ARCHITECTURE.md) ← Referência técnica principal (atualizada) +│ ├── [CODEBASE_MAP.md](./CODEBASE_MAP.md) ← Este arquivo +│ ├── [VISION_MATCH_TECH.md](./VISION_MATCH_TECH.md) ← Visão de produto e design system +│ ├── [TODO_MATCH_TECH.md](./TODO_MATCH_TECH.md) ← Histórico de desenvolvimento (histórico) +│ ├── [FRONTEND_BLUEPRINT.md](./FRONTEND_BLUEPRINT.md) ← Blueprint original (depreciado → ver ARCHITECTURE.md) +│ ├── [PR_REVIEW_GUIDE.md](./PR_REVIEW_GUIDE.md) ← Guia de revisão de Pull Requests +│ └── [hackathon_tech_floripa_2026_strategy.md](./hackathon_tech_floripa_2026_strategy.md) ← Estratégia do evento │ ├── .github/workflows/ │ └── ci.yml # typecheck → lint → build (Node 22) diff --git a/docs/PR_REVIEW_GUIDE.md b/docs/PR_REVIEW_GUIDE.md new file mode 100644 index 0000000..9cb3151 --- /dev/null +++ b/docs/PR_REVIEW_GUIDE.md @@ -0,0 +1,183 @@ +# 🧭 Guia de Revisão de Pull Requests — Match Tech + +> Para o Tony: dev júnior, criador do projeto, principal mantenedor do `MatchDock/match-tech`. + +--- + +## O que é um Pull Request (PR)? + +É quando alguém do time faz alterações no código e pede para você **revisar antes de aceitar**. Seu papel como mantenedor é funcionar como um porteiro: decidir se aquela mudança entra ou não no projeto. + +> [!NOTE] +> **Você não precisa entender cada linha de código.** O objetivo da revisão é entender *o que* mudou, *por quê*, e se isso pode quebrar algo. + +--- + +## 🔁 Seu Fluxo de Revisão (Passo a Passo) + +### 1. Leia a descrição do PR primeiro + +Antes de ver qualquer código, leia o texto que a pessoa escreveu no PR. Pergunte-se: + +- ✅ O PR descreve o que mudou? +- ✅ Ele referencia uma issue? (ex: `Closes #42`) +- ✅ A branch está correta? (deve ir para `develop`, não para `main`) +- ❌ Se a pessoa só colocou "atualiza código" sem explicar nada → peça uma descrição melhor + +**Link rápido para ver os PRs:** [github.com/MatchDock/match-tech/pulls](https://github.com/MatchDock/match-tech/pulls) + +--- + +### 2. Entenda o escopo — quantas coisas ele mexeu? + +Na aba **"Files changed"** do PR, veja: + +| Sinal | O que significa | +|-------|-----------------| +| Verde (linhas `+`) | Código **adicionado** | +| Vermelho (linhas `-`) | Código **removido** | +| Poucos arquivos alterados | PR pequeno ✅ mais fácil de revisar | +| Muitos arquivos e linhas | PR gigante ⚠️ pode ser difícil de aprovar de uma vez | + +> [!TIP] +> O `CONTRIBUTING.md` do projeto pede PRs pequenos e focados. Se alguém mandou um PR com 50 arquivos alterados que não tem relação entre si, você pode pedir para dividir. + +--- + +### 3. Verifique se o PR segue as regras do projeto + +Baseado no [CONTRIBUTING.md](../CONTRIBUTING.md): + +- [ ] A branch do PR tem nome correto? (`feat/`, `bug/`, `docs/`, `task/`, `refactor/`) +- [ ] O PR está indo para `develop` (não para `main`)? +- [ ] Os commits seguem Conventional Commits? (`feat:`, `fix:`, `docs:`, `refactor:`) +- [ ] O PR faz **uma coisa só** (não mistura feature nova + bugfix + refactor)? + +--- + +### 4. Perguntas práticas para revisar o código + +Você não precisa ser expert. Faça estas perguntas ao olhar o diff: + +#### 🟢 Perguntas básicas (todo PR) +- O que essa mudança adiciona ou resolve? +- Isso pode quebrar algo que já funcionava? +- O nome dos arquivos e funções faz sentido? + +#### 🟡 Para PRs de feature (nova funcionalidade) +- Isso resolve a issue que está referenciada? +- A feature se encaixa no estilo visual do projeto? +- Mexe no Firebase/Firestore de forma que pode perder dados? + +#### 🔴 Para PRs de refatoração (reorganização do código) +- O comportamento da UI continua igual para o usuário? +- Passou no build? (`npm run build` ou CI do GitHub Actions) +- Passou no typecheck do TypeScript? (`tsc --noEmit`) + +#### 🔵 Para PRs de infra/docs +- A documentação faz sentido e está em português? +- Não quebra nenhuma configuração existente? + +--- + +### 5. Como pedir mudanças sem ser rude + +Quando algo está errado ou precisa de ajuste, você pode comentar assim: + +```text +Oi! Ficou bem legal, mas tenho uma dúvida/sugestão: + +❓ Esse arquivo [X] foi modificado, mas não estava relacionado à issue #42. +Você pode remover essa alteração desse PR? + +💡 Sugestão: [explica o que preferia ver] + +Fora isso, está bem feito! 🚀 +``` + +> [!IMPORTANT] +> No GitHub, você pode comentar linha por linha. Clique no `+` que aparece ao lado das linhas no "Files changed" para deixar um comentário específico. + +--- + +### 6. O CI do projeto já faz parte do trabalho por você! + +O repositório tem um **GitHub Actions CI**. Isso significa que automaticamente, em todo PR, o sistema verifica: + +- ✅ O TypeScript compila sem erros (`tsc --noEmit`) +- ✅ O build do Vite funciona (`npm run build`) +- ✅ Todos os testes passam (`npm test`) + +Se o CI falhar (mostrar ❌ vermelho no PR), **você não precisa aprovar**. Peça para o contribuidor corrigir primeiro. + +--- + +## 🎯 Checklist Rápido — Cole nos comentários do PR + +```text +## Checklist de Review ✅ + +- [ ] Descrição clara do que mudou +- [ ] Referencia a issue correta (ex: `Closes #XX`) +- [ ] Branch correta → develop (não main) +- [ ] Nome da branch no padrão (feat/, bug/, docs/...) +- [ ] Commits em Conventional Commits +- [ ] PR focado em uma única coisa +- [ ] CI passou (TypeScript + Build + Testes) ✅ +- [ ] Não quebra funcionalidades existentes +``` + +--- + +## 🚦 Quando aprovar (Merge), pedir mudanças ou fechar + +| Situação | Ação | +|----------|------| +| Tudo certo, CI passou | ✅ **Aprovar e fazer Merge** | +| Tem erros corrigíveis | 🔄 **Request changes** — pedir ajustes | +| PR gigante sem foco | 📝 Pedir para dividir em PRs menores | +| CI falhou (❌ vermelho) | 🚫 Não aprovar até corrigir | +| PR foi para `main` diretamente | 🚫 Fechar e pedir para reabrir para `develop` | +| Não tem relação com nenhuma issue | ❓ Perguntar o contexto antes de decidir | + +--- + +## 📚 Tipos de PR mais comuns no match-tech + +Com base no histórico do projeto, esses são os tipos que você vai ver: + +### PR de Feature (`feat/`) +Ex: adicionar filtro de perfis, nova página, novo componente UI +- Foco: **funciona? se encaixa no design?** + +### PR de Refatoração (`refactor/`) +Ex: PR #56 foi uma refatoração arquitetural gigante (Clean Architecture, Router v7...) +- Foco: **o comportamento mudou? CI passou? tem testes?** + +### PR de Documentação (`docs/`) +Ex: PR #36 atualizou o CONTRIBUTING.md +- Foco: **está em português? é claro? está correto?** + +### PR de Infra (`infra/`) +Ex: PR #57 adicionou GitHub Actions CI +- Foco: **vai rodar na conta da org? tem segredos expostos?** + +--- + +## 💬 Como pedir análise para mim (Antigravity) + +Se receber um PR complicado, pode me mandar assim no chat: + +```text +Me ajuda a revisar o PR #XX: [link] +``` + +Eu leio o diff, o histórico de commits, e te entrego um resumo em português explicando: +- O que mudou +- Se está correto +- O que pedir para o contribuidor ajustar +- Se você pode aprovar ou não + +--- + +> 🚀 **Você está indo bem!** Gerir um repositório open source com contribuidores externos durante um hackathon é bastante coisa. O importante é manter o ritmo, ser justo nas revisões, e não deixar PRs parados por muito tempo. diff --git a/docs/VISION_MATCH_TECH.md b/docs/VISION_MATCH_TECH.md index b03abf3..0686418 100644 --- a/docs/VISION_MATCH_TECH.md +++ b/docs/VISION_MATCH_TECH.md @@ -4,10 +4,11 @@ **Data de Criação:** 07 de Maio de 2026 **Última Atualização:** 07 de Maio de 2026 -> **ATENÇÃO AGENTE DE IA:** Este é o documento de referência PRIMÁRIO do projeto. -> Sempre que você sentir que está "alucinando" ou se perdendo na direção do código, -> volte aqui. Tudo o que este documento diz sobre identidade visual, arquitetura, -> e funcionalidades é LEI. Não invente funcionalidades que não estejam aqui. +> **ATENÇÃO AGENTE DE IA:** Este é o documento de referência PRIMÁRIO de produto do projeto. +> Sempre que você sentir que está "alucinando" ou se perdendo na direção do código ou regras de negócio, +> volte aqui. Tudo o que este documento diz sobre identidade visual, arquitetura de alto nível, +> e funcionalidades é LEI. +> Para a especificação técnica detalhada, organização de pastas pós-refatoração e padrões de código, consulte sempre o [ARCHITECTURE.md](./ARCHITECTURE.md). --- @@ -334,10 +335,10 @@ interface Squad { ## 11. O QUE ESTE DOCUMENTO NÃO COBRE -- Detalhes de implementação de código (veja `FRONTEND_BLUEPRINT.md`). -- Roadmap detalhado com checklist (veja `TODO_MATCH_TECH.md`). -- Regras de Firestore atualizadas (veja `../firestore.rules`). -- Estratégia pessoal do Tony para o hackathon (veja `hackathon_tech_floripa_2026_strategy.md`). +- Detalhes de implementação de código (consulte a referência atualizada em [ARCHITECTURE.md](./ARCHITECTURE.md) ou o histórico em [FRONTEND_BLUEPRINT.md](./FRONTEND_BLUEPRINT.md)). +- Roadmap detalhado com checklist (veja o arquivo histórico [TODO_MATCH_TECH.md](./TODO_MATCH_TECH.md)). +- Regras de Firestore atualizadas (veja [firestore.rules](../firestore.rules)). +- Estratégia pessoal do Tony para o hackathon (veja [hackathon_tech_floripa_2026_strategy.md](./hackathon_tech_floripa_2026_strategy.md)). --- From 7590807f6b35a7c31e975f926de95f73cd0358c7 Mon Sep 17 00:00:00 2001 From: tony max Date: Sun, 14 Jun 2026 22:36:41 -0300 Subject: [PATCH 02/13] feat: implement FirebaseProfileRepository with Zod validation and data mapping for Firestore operations --- .../firebase/profileRepository.ts | 91 +++++++++++++++++-- 1 file changed, 84 insertions(+), 7 deletions(-) diff --git a/src/infrastructure/firebase/profileRepository.ts b/src/infrastructure/firebase/profileRepository.ts index 10c7040..37529ca 100644 --- a/src/infrastructure/firebase/profileRepository.ts +++ b/src/infrastructure/firebase/profileRepository.ts @@ -21,8 +21,55 @@ import { db } from "@/shared/lib/firebase/firebase.client"; * Handles all Firestore operations related to member profiles * Uses Zod for runtime validation */ +function mapFirestoreToMemberData( + id: string, + data: Record | undefined, +): Record | undefined { + if (!data) return data; + + const mapped = { ...data }; + + // Map identification fields + mapped.uid = (data.uid ?? data.userId ?? id) as string; + mapped.displayName = (data.displayName ?? data.name ?? "") as string; + + // Map role + mapped.role = (data.role ?? data.primaryRole ?? "") as string; + + // Map squad status + mapped.squadStatus = (data.squadStatus ?? data.status ?? "open") as string; + + // Map tags from canvas if tags is not present + if (!mapped.tags) { + const tags: { name: string; sentiment: "love" | "ok" | "veto" }[] = []; + const canvas = data.canvas as + | { loves?: string[]; comfort?: string[]; veto?: string[] } + | undefined; + if (canvas) { + if (canvas.loves && Array.isArray(canvas.loves)) { + canvas.loves.forEach((name: string) => { + if (name) tags.push({ name, sentiment: "love" }); + }); + } + if (canvas.comfort && Array.isArray(canvas.comfort)) { + canvas.comfort.forEach((name: string) => { + if (name) tags.push({ name, sentiment: "ok" }); + }); + } + if (canvas.veto && Array.isArray(canvas.veto)) { + canvas.veto.forEach((name: string) => { + if (name) tags.push({ name, sentiment: "veto" }); + }); + } + } + mapped.tags = tags; + } + + return mapped; +} + export class FirebaseProfileRepository implements IProfileRepository { - private collectionName = "members"; + private collectionName = "profiles"; async getProfile(uid: string): Promise { try { @@ -34,7 +81,8 @@ export class FirebaseProfileRepository implements IProfileRepository { } const data = docSnap.data(); - return MemberSchema.parse(data); + const mappedData = mapFirestoreToMemberData(uid, data); + return MemberSchema.parse(mappedData); } catch (error) { if (error instanceof AppError) throw error; if (error instanceof Error) { @@ -58,7 +106,8 @@ export class FirebaseProfileRepository implements IProfileRepository { throw new AppError("UNAUTHORIZED", `Profile ${uid} is not public`); } - return PublicMemberSchema.parse(data); + const mappedData = mapFirestoreToMemberData(uid, data); + return PublicMemberSchema.parse(mappedData); } catch (error) { if (error instanceof AppError) throw error; if (error instanceof Error) { @@ -71,8 +120,35 @@ export class FirebaseProfileRepository implements IProfileRepository { async updateProfile(uid: string, data: Partial): Promise { try { const docRef = doc(this.getCollection(), uid); + + const dbData: Record = { ...data } as Record; + + if (data.displayName !== undefined) { + dbData.name = data.displayName; + delete dbData.displayName; + } + if (data.role !== undefined) { + dbData.primaryRole = data.role; + delete dbData.role; + } + if (data.squadStatus !== undefined) { + dbData.status = data.squadStatus; + delete dbData.squadStatus; + } + if (data.tags !== undefined) { + const loves = data.tags.filter((t) => t.sentiment === "love").map((t) => t.name); + const comfort = data.tags.filter((t) => t.sentiment === "ok").map((t) => t.name); + const veto = data.tags.filter((t) => t.sentiment === "veto").map((t) => t.name); + dbData.canvas = { loves, comfort, veto }; + delete dbData.tags; + } + if (data.uid !== undefined) { + dbData.userId = data.uid; + delete dbData.uid; + } + const updateData = { - ...data, + ...dbData, updatedAt: serverTimestamp(), }; @@ -91,16 +167,17 @@ export class FirebaseProfileRepository implements IProfileRepository { const constraints = [where("visibility", "==", "public")]; if (filters?.role) { - constraints.push(where("role", "==", filters.role)); + constraints.push(where("primaryRole", "==", filters.role)); } const q = query(this.getCollection(), ...constraints); const querySnapshot = await getDocs(q); const profiles: Member[] = []; - querySnapshot.forEach((doc) => { + querySnapshot.forEach((docSnap) => { try { - const parsed = MemberSchema.parse(doc.data()); + const mappedData = mapFirestoreToMemberData(docSnap.id, docSnap.data()); + const parsed = MemberSchema.parse(mappedData); profiles.push(parsed); } catch { // Skip profiles that fail validation From 2cbe6add0e945c2f6bbdd6da17dc6e5d88229b8c Mon Sep 17 00:00:00 2001 From: tony max Date: Mon, 15 Jun 2026 19:16:23 -0300 Subject: [PATCH 03/13] feat(onboarding): adjust onboarding language to matchmaker identity (Issue #43, #44) --- scripts/migrate-members-to-profiles.ts | 15 ++++++++++----- .../onboarding/components/ArsenalCalibration.tsx | 10 +++++----- .../onboarding/components/GuildPassport.tsx | 16 ++++++++-------- .../onboarding/components/IdentityCard.tsx | 12 ++++++------ src/features/onboarding/pages/Onboarding.tsx | 12 ++++++------ 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/scripts/migrate-members-to-profiles.ts b/scripts/migrate-members-to-profiles.ts index 72fd16c..e2f3fd8 100644 --- a/scripts/migrate-members-to-profiles.ts +++ b/scripts/migrate-members-to-profiles.ts @@ -34,12 +34,16 @@ function initAdmin() { const raw = process.env.FIREBASE_SERVICE_ACCOUNT; if (raw) { const parsed = JSON.parse(raw) as { - projectId: string; - clientEmail: string; - privateKey: string; + project_id: string; + client_email: string; + private_key: string; }; return initializeApp({ - credential: cert({ ...parsed, privateKey: parsed.privateKey.replace(/\\n/g, "\n") }), + credential: cert({ + projectId: parsed.project_id, + clientEmail: parsed.client_email, + privateKey: parsed.private_key.replace(/\\n/g, "\n"), + }), }); } @@ -91,7 +95,8 @@ function toProfileDoc(memberId: string, data: MemberDoc): Record

- CALIBRAGEM DO ARSENAL + ANÁLISE DE INTERESSES

- Precisamos conhecer seu perfil para gerar sua ID única. + Precisamos conhecer seu perfil para gerar sua identificação de matchmaking.

@@ -55,7 +55,7 @@ export function ArsenalCalibration({ form }: Props) {

POR QUE ISSO? - Para que nossa IA crie um mapeamento justo e te conecte às melhores missões, precisamos + Para que nossa IA crie um mapeamento ideal e te conecte às melhores equipes, precisamos de pelo menos 10 opiniões (Amo ou Veto) sobre as tecnologias abaixo.

@@ -70,8 +70,8 @@ export function ArsenalCalibration({ form }: Props) {

STATUS DO SISTEMA: {isCalibrated - ? "SISTEMA CALIBRADO! VOCÊ JÁ PODE ENTRAR, MAS QUANTO MAIS TAGS MARCAR, MELHOR SERÁ SEU MATCH COM A GUILDA." - : `AGUARDANDO DADOS: MARQUE MAIS ${10 - total} TAGS.`} + ? "PERFIL ALINHADO! VOCÊ JÁ PODE SALVAR, MAS QUANTO MAIS TAGS MARCAR, MELHOR SERÁ SEU MATCH COM OUTROS PARTICIPANTES." + : `AGUARDANDO DADOS: PREENCHA MAIS ${10 - total} TAGS.`}

diff --git a/src/features/onboarding/components/GuildPassport.tsx b/src/features/onboarding/components/GuildPassport.tsx index 4cec925..5d17755 100644 --- a/src/features/onboarding/components/GuildPassport.tsx +++ b/src/features/onboarding/components/GuildPassport.tsx @@ -41,7 +41,7 @@ export function GuildPassport({ {/* Passport header */}
- PASAPORTE_GUILDA + PERFIL_MATCH_TECH Tech_Floripa_2026 @@ -66,7 +66,7 @@ export function GuildPassport({

- NOME_OPERADOR: + NOME_PARTICIPANTE:

{form.name || "Aguardando..."} @@ -74,7 +74,7 @@ export function GuildPassport({

- CLASSE_PRIMÁRIA: + FUNÇÃO_PRINCIPAL:

{form.primaryRole || "AGUARDANDO..."} @@ -83,7 +83,7 @@ export function GuildPassport({ {form.secondaryRoles.length > 0 && (

- CLASSES_SEC: + FUNÇÕES_SECUN:

{form.secondaryRoles.map((r) => ( @@ -104,7 +104,7 @@ export function GuildPassport({

- Análise de Campo + Habilidades

Vibe: {skills.vibe_coding}/10 @@ -143,7 +143,7 @@ export function GuildPassport({ {/* Operator ID row */}

-

ID_OPERADOR

+

ID_USUÁRIO

{user?.uid.slice(0, 10).toUpperCase()}

@@ -170,7 +170,7 @@ export function GuildPassport({ }`} disabled={loading || !isUnlocked} > - {loading ? "PROCESSANDO..." : !isUnlocked ? "BLOQUEADO" : "REGISTRAR OPERADOR"} + {loading ? "PROCESSANDO..." : !isUnlocked ? "BLOQUEADO" : "CRIAR SEU PERFIL"} @@ -200,7 +200,7 @@ export function GuildPassport({
{form.loves.length < 3 && (

- ALERTA: Adicione mais PAIXÕES para calibragem total. + ALERTA: Adicione mais PAIXÕES para alinhamento total.

)}
diff --git a/src/features/onboarding/components/IdentityCard.tsx b/src/features/onboarding/components/IdentityCard.tsx index 80cdcb7..eab6c80 100644 --- a/src/features/onboarding/components/IdentityCard.tsx +++ b/src/features/onboarding/components/IdentityCard.tsx @@ -32,10 +32,10 @@ export function IdentityCard({ form, onChange, onBioChange }: Props) {
-

PONTE DE CONFIANÇA:

+

CONEXÃO DE PONTES:

- O algoritmo da guilda cruza dados do GitHub e LinkedIn para validar xp e sugerir - missões de alto impacto. Sem pontes, você é um fantasma. + O algoritmo cruza dados do GitHub e LinkedIn para validar conexões e sugerir equipes + de alto impacto. Sem pontes, você fica isolado.

@@ -44,7 +44,7 @@ export function IdentityCard({ form, onChange, onBioChange }: Props) { {/* Name */}

- MAPEAR MEMBRO_ + MAPEAR PERFIL_

- Protocolo Floripa 2026 + Match Tech — Floripa 2026

- Nível 01: Identificação + Etapa 1: Mapeamento

@@ -125,10 +125,10 @@ export default function Onboarding() {

- ARSENAL_DE_SKILLS + INTERESSES_E_SKILLS

- Defina seu arsenal: [ ❤️ MEU_FOCO | ✅ OPERO_BEM | 🚫 NEM_FUDENDO ] + Defina seus interesses: [ ❤️ AMO | ✅ CONFORTO | 🚫 VETO ]

From 2e4d8704509b4ed271dc40e1bac14e0df02b7d04 Mon Sep 17 00:00:00 2001 From: tony max Date: Mon, 15 Jun 2026 19:44:28 -0300 Subject: [PATCH 04/13] Revert "feat(onboarding): adjust onboarding language to matchmaker identity (Issue #43, #44)" This reverts commit 2cbe6add0e945c2f6bbdd6da17dc6e5d88229b8c. --- scripts/migrate-members-to-profiles.ts | 15 +++++---------- .../onboarding/components/ArsenalCalibration.tsx | 10 +++++----- .../onboarding/components/GuildPassport.tsx | 16 ++++++++-------- .../onboarding/components/IdentityCard.tsx | 12 ++++++------ src/features/onboarding/pages/Onboarding.tsx | 12 ++++++------ 5 files changed, 30 insertions(+), 35 deletions(-) diff --git a/scripts/migrate-members-to-profiles.ts b/scripts/migrate-members-to-profiles.ts index e2f3fd8..72fd16c 100644 --- a/scripts/migrate-members-to-profiles.ts +++ b/scripts/migrate-members-to-profiles.ts @@ -34,16 +34,12 @@ function initAdmin() { const raw = process.env.FIREBASE_SERVICE_ACCOUNT; if (raw) { const parsed = JSON.parse(raw) as { - project_id: string; - client_email: string; - private_key: string; + projectId: string; + clientEmail: string; + privateKey: string; }; return initializeApp({ - credential: cert({ - projectId: parsed.project_id, - clientEmail: parsed.client_email, - privateKey: parsed.private_key.replace(/\\n/g, "\n"), - }), + credential: cert({ ...parsed, privateKey: parsed.privateKey.replace(/\\n/g, "\n") }), }); } @@ -95,8 +91,7 @@ function toProfileDoc(memberId: string, data: MemberDoc): Record

- ANÁLISE DE INTERESSES + CALIBRAGEM DO ARSENAL

- Precisamos conhecer seu perfil para gerar sua identificação de matchmaking. + Precisamos conhecer seu perfil para gerar sua ID única.

@@ -55,7 +55,7 @@ export function ArsenalCalibration({ form }: Props) {

POR QUE ISSO? - Para que nossa IA crie um mapeamento ideal e te conecte às melhores equipes, precisamos + Para que nossa IA crie um mapeamento justo e te conecte às melhores missões, precisamos de pelo menos 10 opiniões (Amo ou Veto) sobre as tecnologias abaixo.

@@ -70,8 +70,8 @@ export function ArsenalCalibration({ form }: Props) {

STATUS DO SISTEMA: {isCalibrated - ? "PERFIL ALINHADO! VOCÊ JÁ PODE SALVAR, MAS QUANTO MAIS TAGS MARCAR, MELHOR SERÁ SEU MATCH COM OUTROS PARTICIPANTES." - : `AGUARDANDO DADOS: PREENCHA MAIS ${10 - total} TAGS.`} + ? "SISTEMA CALIBRADO! VOCÊ JÁ PODE ENTRAR, MAS QUANTO MAIS TAGS MARCAR, MELHOR SERÁ SEU MATCH COM A GUILDA." + : `AGUARDANDO DADOS: MARQUE MAIS ${10 - total} TAGS.`}

diff --git a/src/features/onboarding/components/GuildPassport.tsx b/src/features/onboarding/components/GuildPassport.tsx index 5d17755..4cec925 100644 --- a/src/features/onboarding/components/GuildPassport.tsx +++ b/src/features/onboarding/components/GuildPassport.tsx @@ -41,7 +41,7 @@ export function GuildPassport({ {/* Passport header */}
- PERFIL_MATCH_TECH + PASAPORTE_GUILDA Tech_Floripa_2026 @@ -66,7 +66,7 @@ export function GuildPassport({

- NOME_PARTICIPANTE: + NOME_OPERADOR:

{form.name || "Aguardando..."} @@ -74,7 +74,7 @@ export function GuildPassport({

- FUNÇÃO_PRINCIPAL: + CLASSE_PRIMÁRIA:

{form.primaryRole || "AGUARDANDO..."} @@ -83,7 +83,7 @@ export function GuildPassport({ {form.secondaryRoles.length > 0 && (

- FUNÇÕES_SECUN: + CLASSES_SEC:

{form.secondaryRoles.map((r) => ( @@ -104,7 +104,7 @@ export function GuildPassport({

- Habilidades + Análise de Campo

Vibe: {skills.vibe_coding}/10 @@ -143,7 +143,7 @@ export function GuildPassport({ {/* Operator ID row */}

-

ID_USUÁRIO

+

ID_OPERADOR

{user?.uid.slice(0, 10).toUpperCase()}

@@ -170,7 +170,7 @@ export function GuildPassport({ }`} disabled={loading || !isUnlocked} > - {loading ? "PROCESSANDO..." : !isUnlocked ? "BLOQUEADO" : "CRIAR SEU PERFIL"} + {loading ? "PROCESSANDO..." : !isUnlocked ? "BLOQUEADO" : "REGISTRAR OPERADOR"} @@ -200,7 +200,7 @@ export function GuildPassport({
{form.loves.length < 3 && (

- ALERTA: Adicione mais PAIXÕES para alinhamento total. + ALERTA: Adicione mais PAIXÕES para calibragem total.

)}
diff --git a/src/features/onboarding/components/IdentityCard.tsx b/src/features/onboarding/components/IdentityCard.tsx index eab6c80..80cdcb7 100644 --- a/src/features/onboarding/components/IdentityCard.tsx +++ b/src/features/onboarding/components/IdentityCard.tsx @@ -32,10 +32,10 @@ export function IdentityCard({ form, onChange, onBioChange }: Props) {
-

CONEXÃO DE PONTES:

+

PONTE DE CONFIANÇA:

- O algoritmo cruza dados do GitHub e LinkedIn para validar conexões e sugerir equipes - de alto impacto. Sem pontes, você fica isolado. + O algoritmo da guilda cruza dados do GitHub e LinkedIn para validar xp e sugerir + missões de alto impacto. Sem pontes, você é um fantasma.

@@ -44,7 +44,7 @@ export function IdentityCard({ form, onChange, onBioChange }: Props) { {/* Name */}

- MAPEAR PERFIL_ + MAPEAR MEMBRO_

- Match Tech — Floripa 2026 + Protocolo Floripa 2026

- Etapa 1: Mapeamento + Nível 01: Identificação

@@ -125,10 +125,10 @@ export default function Onboarding() {

- INTERESSES_E_SKILLS + ARSENAL_DE_SKILLS

- Defina seus interesses: [ ❤️ AMO | ✅ CONFORTO | 🚫 VETO ] + Defina seu arsenal: [ ❤️ MEU_FOCO | ✅ OPERO_BEM | 🚫 NEM_FUDENDO ]

From 8988917a32e2d09874986e3d19392201a2747714 Mon Sep 17 00:00:00 2001 From: tony max Date: Mon, 15 Jun 2026 19:56:50 -0300 Subject: [PATCH 05/13] fix(discover): restaurar design original do ProfileCard com bento layout, avatar, radar chart e grid 3 colunas --- .../discover/components/ProfilesGrid.tsx | 40 +-- src/features/discover/model/discover.types.ts | 14 + src/shared/components/ui/ProfileCard.tsx | 338 ++++++++++++------ 3 files changed, 239 insertions(+), 153 deletions(-) diff --git a/src/features/discover/components/ProfilesGrid.tsx b/src/features/discover/components/ProfilesGrid.tsx index 8fe4a45..fa89f22 100644 --- a/src/features/discover/components/ProfilesGrid.tsx +++ b/src/features/discover/components/ProfilesGrid.tsx @@ -1,6 +1,3 @@ -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useRef } from "react"; - import type { Profile } from "../model/discover.types"; import { EmptyProfilesState } from "./EmptyProfilesState"; @@ -13,44 +10,15 @@ interface ProfilesGridProps { } export function ProfilesGrid({ profiles, onProfileClick }: ProfilesGridProps) { - const parentRef = useRef(null); - - const virtualizer = useVirtualizer({ - count: profiles.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 300, - overscan: 5, - }); - if (profiles.length === 0) { return ; } return ( -
-
- {virtualizer.getVirtualItems().map((item) => ( -
-
- onProfileClick(profiles[item.index])} - /> -
-
- ))} -
+
+ {profiles.map((p, idx) => ( + onProfileClick(p)} /> + ))}
); } diff --git a/src/features/discover/model/discover.types.ts b/src/features/discover/model/discover.types.ts index c34c76d..a43149e 100644 --- a/src/features/discover/model/discover.types.ts +++ b/src/features/discover/model/discover.types.ts @@ -7,16 +7,30 @@ export type ProfileStatus = "looking" | "open" | "complete"; export interface ProfileCanvas { loves?: string[]; comfort?: string[]; + veto?: string[]; vetoes?: string[]; } +export interface ProfileSkills { + frontend: number; + backend: number; + ux_ui: number; + dados: number; + hardware_android: number; + vibe_coding: number; +} + export interface Profile { id: string; + userId?: string; name?: string; + photoURL?: string | null; github?: string; + linkedin?: string; bio?: string; primaryRole?: string; secondaryRoles?: string[]; + skills?: ProfileSkills; status?: ProfileStatus; roast?: string; roastBrutal?: string; diff --git a/src/shared/components/ui/ProfileCard.tsx b/src/shared/components/ui/ProfileCard.tsx index 3599b6b..2dd04ec 100644 --- a/src/shared/components/ui/ProfileCard.tsx +++ b/src/shared/components/ui/ProfileCard.tsx @@ -1,148 +1,252 @@ -import { Github, Heart, Sparkles, UserRound } from "lucide-react"; +import { Github, Linkedin } from "lucide-react"; + +import Avatar from "./Avatar"; +import { Card } from "./Card"; +import SkillRadar from "./SkillRadar"; +import StatusBadge from "./StatusBadge"; +import TagBadge from "./TagBadge"; import type { Profile } from "@/features/discover/model/discover.types"; +import { cn } from "@/shared/lib/utils/cn"; interface ProfileCardProps { profile: Profile; + onClick?: () => void; + compact?: boolean; + className?: string; colorIndex?: number; - onClick: () => void; -} - -const cardAccentStyles = [ - "shadow-[8px_8px_0_0_#000]", - "shadow-[8px_8px_0_0_#B8FF29]", - "shadow-[8px_8px_0_0_#00F0FF]", - "shadow-[8px_8px_0_0_#FF2E93]", - "shadow-[8px_8px_0_0_#FFD84D]", -]; - -const statusLabelMap: Record = { - looking: "BUSCANDO EQUIPE", - open: "ABERTO A PROPOSTAS", - complete: "EQUIPE FORMADA", -}; - -const statusStylesMap: Record = { - looking: "bg-neo-yellow text-neo-black", - open: "bg-neo-cyan text-neo-black", - complete: "bg-neo-lime text-neo-black", -}; - -function getCardAccentStyle(colorIndex = 0) { - return cardAccentStyles[colorIndex % cardAccentStyles.length]; -} - -function getDisplayRoles(profile: Profile) { - const roles = [profile.primaryRole, ...(profile.secondaryRoles ?? [])].filter(Boolean); - - return Array.from(new Set(roles)).slice(0, 3); } -function getDisplayTags(profile: Profile) { - const loves = profile.canvas?.loves ?? []; - const comfort = profile.canvas?.comfort ?? []; - - return Array.from(new Set([...loves, ...comfort])).slice(0, 4); -} +export default function ProfileCard({ + profile, + onClick, + compact = false, + className, + colorIndex = 0, +}: ProfileCardProps) { + const { + name, + github, + linkedin, + bio, + primaryRole, + secondaryRoles = [], + skills, + canvas = { loves: [], comfort: [], veto: [] }, + status = "looking", + } = profile; + + // Neo-Brutalist color configurations for variety + const bgColors = ["bg-neo-lime", "bg-neo-pink", "bg-neo-cyan", "bg-neo-yellow"]; + const headerBg = bgColors[colorIndex % bgColors.length]; + + // Decide text colors based on background + const headerText = headerBg === "bg-neo-pink" ? "text-white" : "text-neo-black"; + + const getGithubUrl = (val?: string) => { + if (!val) return ""; + const clean = val + .trim() + .replace(/^(?:https?:\/\/)?(?:www\.)?github\.com\//i, "") + .replace(/\/$/, "") + .replace(/^@/, ""); + return `https://github.com/${clean}`; + }; + + const getLinkedinUrl = (val?: string) => { + if (!val) return ""; + const clean = val + .trim() + .replace(/^(?:https?:\/\/)?(?:[\w-]+\.)?linkedin\.com\/(?:in|profile)\//i, "") + .replace(/\/$/, "") + .replace(/^@/, ""); + return `https://linkedin.com/in/${clean}`; + }; + + const vetos = canvas.veto ?? canvas.vetoes ?? []; + + if (compact) { + return ( + + {/* Compact Header */} +
+ {primaryRole || "OPERADOR"} +
-export default function ProfileCard({ profile, colorIndex = 0, onClick }: ProfileCardProps) { - const displayRoles = getDisplayRoles(profile); - const displayTags = getDisplayTags(profile); - const statusLabel = statusLabelMap[profile.status ?? ""] ?? "SEM STATUS"; - const statusStyle = statusStylesMap[profile.status ?? ""] ?? "bg-white text-neo-black"; + {/* Compact Body */} +
+ +
+

+ {name || "NOME_NULO"} +

+
+ +
+
+
+
+ ); + } return ( - + ); } From ad2321ad3369e0fa04fb1d018c30b16eb5f89e27 Mon Sep 17 00:00:00 2001 From: tony max Date: Mon, 15 Jun 2026 20:21:48 -0300 Subject: [PATCH 06/13] feat: add profile discovery UI components including roast modal, profile grid, and radar chart --- .../discover/components/ProfilesGrid.tsx | 17 +- src/features/discover/pages/DiscoverPage.tsx | 12 +- src/shared/components/ui/ProfileCard.tsx | 20 ++- src/shared/components/ui/RoastModal.tsx | 2 +- src/shared/components/ui/SkillRadar.tsx | 145 ++++++++++-------- 5 files changed, 124 insertions(+), 72 deletions(-) diff --git a/src/features/discover/components/ProfilesGrid.tsx b/src/features/discover/components/ProfilesGrid.tsx index fa89f22..82c96d9 100644 --- a/src/features/discover/components/ProfilesGrid.tsx +++ b/src/features/discover/components/ProfilesGrid.tsx @@ -7,18 +7,27 @@ import ProfileCard from "@/shared/components/ui/ProfileCard"; interface ProfilesGridProps { profiles: Profile[]; onProfileClick: (profile: Profile) => void; + currentUserId?: string; } -export function ProfilesGrid({ profiles, onProfileClick }: ProfilesGridProps) { +export function ProfilesGrid({ profiles, onProfileClick, currentUserId }: ProfilesGridProps) { if (profiles.length === 0) { return ; } return (
- {profiles.map((p, idx) => ( - onProfileClick(p)} /> - ))} + {profiles.map((p, idx) => { + const isOwn = p.id === currentUserId; + return ( + onProfileClick(p) : undefined} + /> + ); + })}
); } diff --git a/src/features/discover/pages/DiscoverPage.tsx b/src/features/discover/pages/DiscoverPage.tsx index 66ddfe5..ba89828 100644 --- a/src/features/discover/pages/DiscoverPage.tsx +++ b/src/features/discover/pages/DiscoverPage.tsx @@ -1,4 +1,4 @@ -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { AccessDeniedState } from "../components/AccessDeniedState"; import { DiscoverFilters } from "../components/DiscoverFilters"; @@ -40,7 +40,15 @@ export default function DiscoverPage() { ) : (
- + { + if (profile.id === user?.uid) { + roast.openProfile(profile); + } + }} + />
)} diff --git a/src/shared/components/ui/ProfileCard.tsx b/src/shared/components/ui/ProfileCard.tsx index 2dd04ec..0bb6f19 100644 --- a/src/shared/components/ui/ProfileCard.tsx +++ b/src/shared/components/ui/ProfileCard.tsx @@ -36,6 +36,8 @@ export default function ProfileCard({ status = "looking", } = profile; + const isClickable = !!onClick; + // Neo-Brutalist color configurations for variety const bgColors = ["bg-neo-lime", "bg-neo-pink", "bg-neo-cyan", "bg-neo-yellow"]; const headerBg = bgColors[colorIndex % bgColors.length]; @@ -72,7 +74,10 @@ export default function ProfileCard({ padding="none" onClick={onClick} className={cn( - "flex flex-col border-4 neo-shadow-hover h-full cursor-pointer group", + "flex flex-col border-4 h-full group", + isClickable + ? "neo-shadow-hover cursor-pointer" + : "active:translate-0 active:shadow-[6px_6px_0px_0px_#000000] cursor-default", className, )} > @@ -116,7 +121,10 @@ export default function ProfileCard({ padding="none" onClick={onClick} className={cn( - "flex flex-col border-4 neo-shadow-hover h-full cursor-pointer group select-none relative", + "flex flex-col border-4 h-full group select-none relative", + isClickable + ? "neo-shadow-hover cursor-pointer" + : "active:translate-0 active:shadow-[6px_6px_0px_0px_#000000] cursor-default", className, )} > @@ -243,9 +251,11 @@ export default function ProfileCard({ status={status} className="shadow-none border-2 text-[9px] py-1 px-2.5 bg-white" /> - - VER SINA → - + {isClickable && ( + + VER SINA → + + )}
); diff --git a/src/shared/components/ui/RoastModal.tsx b/src/shared/components/ui/RoastModal.tsx index f9e3625..8e8525b 100644 --- a/src/shared/components/ui/RoastModal.tsx +++ b/src/shared/components/ui/RoastModal.tsx @@ -104,7 +104,7 @@ export function RoastModal({
- [SISTEMA ROASTED & TOASTED] © 2026 + [SISTEMA ROASTED & TOASTED] © 2026 STRICTLY CONFIDENTIAL diff --git a/src/shared/components/ui/SkillRadar.tsx b/src/shared/components/ui/SkillRadar.tsx index 442fb72..749d340 100644 --- a/src/shared/components/ui/SkillRadar.tsx +++ b/src/shared/components/ui/SkillRadar.tsx @@ -13,70 +13,95 @@ interface SkillRadarProps { size?: "sm" | "md" | "lg"; } -// Wrapped in memo so the SVG only re-renders when skill values actually change. -// RadarChart is one of the most expensive Recharts components — memoisation is critical -// when multiple cards are visible simultaneously (e.g. guilda grid). -const SkillRadar = memo(function SkillRadar({ skills, size = "md" }: SkillRadarProps) { - const data = useMemo( - () => [ - { subject: "Front", A: skills?.frontend || 0 }, - { subject: "Back", A: skills?.backend || 0 }, - { subject: "UX/UI", A: skills?.ux_ui || 0 }, - { subject: "Dados", A: skills?.dados || 0 }, - { subject: "Hard", A: skills?.hardware_android || 0 }, - { subject: "Vibe AI", A: skills?.vibe_coding || 0 }, - ], - [ - skills?.frontend, - skills?.backend, - skills?.ux_ui, - skills?.dados, - skills?.hardware_android, - skills?.vibe_coding, - ], - ); +const DIMS = { sm: 192, md: 256, lg: 320 } as const; +const OUTER_RADIUS = { sm: "65%", md: "70%", lg: "75%" } as const; - // Fixed dims — no ResponsiveContainer to avoid the width/height = -1 Recharts bug - const dims = { sm: 192, md: 256, lg: 320 }; - const chartSize = dims[size]; - const fontSize = size === "sm" ? 9 : size === "md" ? 10 : 12; - const outerRadius = size === "sm" ? "65%" : size === "md" ? "70%" : "75%"; +interface CustomTickProps { + x?: number; + y?: number; + payload?: { value: string }; + fontSize?: number; +} +const CustomTick = memo(function CustomTick({ x, y, payload, fontSize = 10 }: CustomTickProps) { return ( -
-
- - - - - -
+ + {payload?.value} + ); }); +const SkillRadar = memo( + function SkillRadar({ skills, size = "md" }: SkillRadarProps) { + const data = useMemo( + () => [ + { subject: "Front", A: skills?.frontend || 0 }, + { subject: "Back", A: skills?.backend || 0 }, + { subject: "UX/UI", A: skills?.ux_ui || 0 }, + { subject: "Dados", A: skills?.dados || 0 }, + { subject: "Hard", A: skills?.hardware_android || 0 }, + { subject: "Vibe AI", A: skills?.vibe_coding || 0 }, + ], + [ + skills?.frontend, + skills?.backend, + skills?.ux_ui, + skills?.dados, + skills?.hardware_android, + skills?.vibe_coding, + ], + ); + + const chartSize = DIMS[size]; + const outerRadius = OUTER_RADIUS[size]; + const fontSize = size === "sm" ? 9 : size === "md" ? 10 : 12; + + const tickElement = useMemo(() => , [fontSize]); + + return ( +
+
+ + + + + +
+ ); + }, + (prevProps, nextProps) => { + if (prevProps.size !== nextProps.size) return false; + const p = prevProps.skills; + const n = nextProps.skills; + if (!p && !n) return true; + if (!p || !n) return false; + return ( + p.frontend === n.frontend && + p.backend === n.backend && + p.ux_ui === n.ux_ui && + p.dados === n.dados && + p.hardware_android === n.hardware_android && + p.vibe_coding === n.vibe_coding + ); + }, +); + export default SkillRadar; From 9dd4048305fbe5fffe1fce1aa0a8edd715fd78e3 Mon Sep 17 00:00:00 2001 From: tony max Date: Mon, 15 Jun 2026 20:32:05 -0300 Subject: [PATCH 07/13] fix(discover): resolve user ID mismatch in own profile check --- src/features/discover/components/ProfilesGrid.tsx | 4 ++-- src/features/discover/pages/DiscoverPage.tsx | 2 +- src/shared/hooks/useFirestoreSubscription.ts | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/features/discover/components/ProfilesGrid.tsx b/src/features/discover/components/ProfilesGrid.tsx index 82c96d9..63d2d24 100644 --- a/src/features/discover/components/ProfilesGrid.tsx +++ b/src/features/discover/components/ProfilesGrid.tsx @@ -18,10 +18,10 @@ export function ProfilesGrid({ profiles, onProfileClick, currentUserId }: Profil return (
{profiles.map((p, idx) => { - const isOwn = p.id === currentUserId; + const isOwn = p.id === currentUserId || p.userId === currentUserId; return ( onProfileClick(p) : undefined} diff --git a/src/features/discover/pages/DiscoverPage.tsx b/src/features/discover/pages/DiscoverPage.tsx index ba89828..90e86d6 100644 --- a/src/features/discover/pages/DiscoverPage.tsx +++ b/src/features/discover/pages/DiscoverPage.tsx @@ -44,7 +44,7 @@ export default function DiscoverPage() { profiles={filters.filteredProfiles} currentUserId={user?.uid} onProfileClick={(profile) => { - if (profile.id === user?.uid) { + if (profile.id === user?.uid || profile.userId === user?.uid) { roast.openProfile(profile); } }} diff --git a/src/shared/hooks/useFirestoreSubscription.ts b/src/shared/hooks/useFirestoreSubscription.ts index 4d81222..2d9a409 100644 --- a/src/shared/hooks/useFirestoreSubscription.ts +++ b/src/shared/hooks/useFirestoreSubscription.ts @@ -51,7 +51,10 @@ export function useFirestoreSubscription({ q, (snapshot) => { try { - const docs = snapshot.docs.map((doc) => doc.data() as T); + const docs = snapshot.docs.map((doc) => ({ + id: doc.id, + ...(doc.data() as object), + } as T)); const sorted = sortFn ? docs.sort(sortFn) : docs; setData(sorted); setError(null); From 440e20f88b82f161fea2e8e036e25f87dd4e5820 Mon Sep 17 00:00:00 2001 From: tony max Date: Mon, 15 Jun 2026 21:37:15 -0300 Subject: [PATCH 08/13] feat: create ProfileCard component with neo-brutalist styling and compact mode support --- src/shared/components/ui/ProfileCard.tsx | 91 +++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/src/shared/components/ui/ProfileCard.tsx b/src/shared/components/ui/ProfileCard.tsx index 0bb6f19..4de330a 100644 --- a/src/shared/components/ui/ProfileCard.tsx +++ b/src/shared/components/ui/ProfileCard.tsx @@ -218,7 +218,7 @@ export default function ProfileCard({ PAIXÕES -
+
{canvas.loves.slice(0, 3).map((love) => ( ))} + {canvas.loves.length > 3 && ( +
+ + +{canvas.loves.length - 3} + + + {/* Tooltip Content */} +
+
+ {canvas.loves.slice(3).map((love) => ( + + ))} +
+ {/* Tooltip Arrow (Black) */} +
+
+
+ )} +
+
+ )} + + {canvas.comfort && canvas.comfort.length > 0 && ( +
+ + OPERO BEM + +
+ {canvas.comfort.slice(0, 3).map((comfort) => ( + + ))} + {canvas.comfort.length > 3 && ( +
+ + +{canvas.comfort.length - 3} + + + {/* Tooltip Content */} +
+
+ {canvas.comfort.slice(3).map((comfort) => ( + + ))} +
+ {/* Tooltip Arrow (Black) */} +
+
+
+ )}
)} @@ -236,10 +300,33 @@ export default function ProfileCard({ VETOS -
+
{vetos.slice(0, 3).map((v) => ( ))} + {vetos.length > 3 && ( +
+ + +{vetos.length - 3} + + + {/* Tooltip Content */} +
+
+ {vetos.slice(3).map((v) => ( + + ))} +
+ {/* Tooltip Arrow (Black) */} +
+
+
+ )}
)} From b6ffa66252d619c6419ae6029aa57c3408e3950a Mon Sep 17 00:00:00 2001 From: tony max Date: Mon, 15 Jun 2026 22:24:11 -0300 Subject: [PATCH 09/13] feat: implement real-time messaging system and expand profile schema with visibility support --- firestore.rules | 30 ++- scripts/dump-profiles.ts | 45 ++++ scripts/migrate-members-to-profiles.ts | 26 +- .../discover/components/ProfilesGrid.tsx | 9 +- .../discover/hooks/useProfilesRealtime.ts | 11 +- src/features/discover/pages/DiscoverPage.tsx | 21 ++ .../guilda/components/GuildAvatar.tsx | 37 --- .../guilda/components/GuildHeader.tsx | 31 --- .../guilda/components/GuildMemberCard.tsx | 247 ------------------ .../guilda/components/GuildMembersGrid.tsx | 43 --- .../guilda/components/GuildRoastModal.tsx | 42 --- .../guilda/components/GuildStates.tsx | 19 -- .../guilda/constants/guilda.constants.ts | 13 - .../guilda/hooks/useGuildMembersRealtime.ts | 19 -- src/features/guilda/hooks/useGuildRoast.ts | 85 ------ src/features/guilda/model/guilda.selectors.ts | 26 -- src/features/guilda/model/guilda.types.ts | 46 ---- src/features/guilda/pages/GuildaPage.tsx | 53 ---- .../guilda/services/guilda.repository.ts | 17 -- src/features/guilda/store/guildRoast.ts | 35 --- .../guilda/utils/guilda.formatters.ts | 47 ---- src/features/landing/Landing.tsx | 4 +- .../landing/components/HeroSection.tsx | 6 +- .../messages/components/SendMessageModal.tsx | 175 +++++++++++++ src/features/messages/model/messages.types.ts | 12 + src/features/messages/pages/MessagesPage.tsx | 219 ++++++++++++++++ .../onboarding/hooks/useOnboardingForm.ts | 1 + .../firebase/messageRepository.ts | 122 +++++++++ src/layouts/RootLayout.tsx | 32 ++- src/routes/routes.tsx | 5 +- src/shared/components/ui/ProfileCard.tsx | 20 +- 31 files changed, 713 insertions(+), 785 deletions(-) create mode 100644 scripts/dump-profiles.ts delete mode 100644 src/features/guilda/components/GuildAvatar.tsx delete mode 100644 src/features/guilda/components/GuildHeader.tsx delete mode 100644 src/features/guilda/components/GuildMemberCard.tsx delete mode 100644 src/features/guilda/components/GuildMembersGrid.tsx delete mode 100644 src/features/guilda/components/GuildRoastModal.tsx delete mode 100644 src/features/guilda/components/GuildStates.tsx delete mode 100644 src/features/guilda/constants/guilda.constants.ts delete mode 100644 src/features/guilda/hooks/useGuildMembersRealtime.ts delete mode 100644 src/features/guilda/hooks/useGuildRoast.ts delete mode 100644 src/features/guilda/model/guilda.selectors.ts delete mode 100644 src/features/guilda/model/guilda.types.ts delete mode 100644 src/features/guilda/pages/GuildaPage.tsx delete mode 100644 src/features/guilda/services/guilda.repository.ts delete mode 100644 src/features/guilda/store/guildRoast.ts delete mode 100644 src/features/guilda/utils/guilda.formatters.ts create mode 100644 src/features/messages/components/SendMessageModal.tsx create mode 100644 src/features/messages/model/messages.types.ts create mode 100644 src/features/messages/pages/MessagesPage.tsx create mode 100644 src/infrastructure/firebase/messageRepository.ts diff --git a/firestore.rules b/firestore.rules index 68c4e41..a9e8bf0 100644 --- a/firestore.rules +++ b/firestore.rules @@ -110,7 +110,7 @@ service cloud.firestore { && profileId == request.auth.uid && isValidProfile(incoming()) && incoming().createdAt == request.time - && incoming().keys().hasOnly(['userId', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'skills', 'canvas', 'status', 'squadId', 'eventId', 'roast', 'roastBrutal', 'roastMild', 'createdAt', 'updatedAt']); + && incoming().keys().hasOnly(['userId', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'skills', 'canvas', 'status', 'squadId', 'eventId', 'roast', 'roastBrutal', 'roastMild', 'createdAt', 'updatedAt', 'visibility']); allow update: if isSignedIn() && isValidId(profileId) @@ -118,7 +118,7 @@ service cloud.firestore { && incoming().userId == existing().userId && incoming().createdAt == existing().createdAt && ( - (incoming().diff(existing()).affectedKeys().hasOnly(['skills', 'canvas', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'status', 'squadId', 'eventId', 'updatedAt', 'roast', 'roastBrutal', 'roastMild'])) + (incoming().diff(existing()).affectedKeys().hasOnly(['skills', 'canvas', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'status', 'squadId', 'eventId', 'updatedAt', 'roast', 'roastBrutal', 'roastMild', 'visibility'])) || false ); @@ -152,5 +152,31 @@ service cloud.firestore { allow create: if isSignedIn() && userId == request.auth.uid && isValidLike(incoming()); allow delete: if isSignedIn() && userId == request.auth.uid; } + + function isValidMessage(data) { + return data.keys().hasAll(['senderId', 'senderName', 'receiverId', 'receiverName', 'text', 'createdAt', 'read']) + && data.keys().size() == 7 + && data.senderId == request.auth.uid + && data.senderName is string && data.senderName.size() > 0 && data.senderName.size() <= 100 + && data.receiverId is string && data.receiverId.size() > 0 && data.receiverId.size() <= 128 + && data.receiverName is string && data.receiverName.size() > 0 && data.receiverName.size() <= 100 + && data.text is string && data.text.size() > 0 && data.text.size() <= 5000 + && data.read is bool; + } + + match /messages/{messageId} { + allow create: if isSignedIn() + && isValidId(messageId) + && isValidMessage(incoming()) + && incoming().createdAt == request.time; + + allow read, delete: if isSignedIn() + && (resource.data.receiverId == request.auth.uid || resource.data.senderId == request.auth.uid); + + allow update: if isSignedIn() + && (resource.data.receiverId == request.auth.uid) + && incoming().diff(existing()).affectedKeys().hasOnly(['read']) + && incoming().read is bool; + } } } diff --git a/scripts/dump-profiles.ts b/scripts/dump-profiles.ts new file mode 100644 index 0000000..ab69be2 --- /dev/null +++ b/scripts/dump-profiles.ts @@ -0,0 +1,45 @@ +import { cert, initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const raw = process.env.FIREBASE_SERVICE_ACCOUNT; +if (!raw) { + console.error("Missing FIREBASE_SERVICE_ACCOUNT"); + process.exit(1); +} + +const parsed = JSON.parse(raw) as { + project_id?: string; + projectId?: string; + client_email?: string; + clientEmail?: string; + private_key?: string; + privateKey?: string; +}; + +const projectId = parsed.projectId ?? parsed.project_id; +const clientEmail = parsed.clientEmail ?? parsed.client_email; +const privateKey = parsed.privateKey ?? parsed.private_key; + +const app = initializeApp({ + credential: cert({ + projectId, + clientEmail, + privateKey: privateKey?.replace(/\\n/g, "\n"), + }), +}); + +const db = getFirestore(app, process.env.VITE_FIREBASE_FIRESTORE_DATABASE_ID || "ai-studio-a1333439-9ab3-4356-9f79-ac211cc82b20"); + +async function main() { + const snap = await db.collection("profiles").get(); + console.log(`Encontrados ${snap.size} perfis no Firestore:`); + snap.forEach(doc => { + const data = doc.data(); + console.log(`ID: ${doc.id}, Nome: ${data.name || data.displayName || "Sem nome"}, Visibilidade: ${data.visibility}, Status: ${data.status}`); + }); +} + +main().catch(console.error); diff --git a/scripts/migrate-members-to-profiles.ts b/scripts/migrate-members-to-profiles.ts index 72fd16c..7e0c03c 100644 --- a/scripts/migrate-members-to-profiles.ts +++ b/scripts/migrate-members-to-profiles.ts @@ -34,12 +34,28 @@ function initAdmin() { const raw = process.env.FIREBASE_SERVICE_ACCOUNT; if (raw) { const parsed = JSON.parse(raw) as { - projectId: string; - clientEmail: string; - privateKey: string; + projectId?: string; + project_id?: string; + clientEmail?: string; + client_email?: string; + privateKey?: string; + private_key?: string; }; + + const projectId = parsed.projectId ?? parsed.project_id; + const clientEmail = parsed.clientEmail ?? parsed.client_email; + const privateKey = parsed.privateKey ?? parsed.private_key; + + if (!projectId || !clientEmail || !privateKey) { + throw new Error("Missing projectId, clientEmail, or privateKey in FIREBASE_SERVICE_ACCOUNT env var."); + } + return initializeApp({ - credential: cert({ ...parsed, privateKey: parsed.privateKey.replace(/\\n/g, "\n") }), + credential: cert({ + projectId, + clientEmail, + privateKey: privateKey.replace(/\\n/g, "\n"), + }), }); } @@ -91,7 +107,7 @@ function toProfileDoc(memberId: string, data: MemberDoc): Record void; + onContactClick?: (profile: Profile) => void; currentUserId?: string; } -export function ProfilesGrid({ profiles, onProfileClick, currentUserId }: ProfilesGridProps) { +export function ProfilesGrid({ + profiles, + onProfileClick, + onContactClick, + currentUserId, +}: ProfilesGridProps) { if (profiles.length === 0) { return ; } @@ -25,6 +31,7 @@ export function ProfilesGrid({ profiles, onProfileClick, currentUserId }: Profil profile={p} colorIndex={idx} onClick={isOwn ? () => onProfileClick(p) : undefined} + onContactClick={onContactClick ? () => onContactClick(p) : undefined} /> ); })} diff --git a/src/features/discover/hooks/useProfilesRealtime.ts b/src/features/discover/hooks/useProfilesRealtime.ts index a7a6cba..e2b3fbb 100644 --- a/src/features/discover/hooks/useProfilesRealtime.ts +++ b/src/features/discover/hooks/useProfilesRealtime.ts @@ -10,10 +10,13 @@ export function useProfilesRealtime(currentUserId?: string) { collectionName: "profiles", }); - const profiles = useMemo( - () => (currentUserId ? sortProfiles(data, currentUserId) : []), - [data, currentUserId], - ); + const profiles = useMemo(() => { + console.log("useProfilesRealtime: raw data from Firestore:", data); + console.log("useProfilesRealtime: currentUserId:", currentUserId); + const sorted = currentUserId ? sortProfiles(data, currentUserId) : []; + console.log("useProfilesRealtime: sorted profiles:", sorted); + return sorted; + }, [data, currentUserId]); return { profiles, loading, error }; } diff --git a/src/features/discover/pages/DiscoverPage.tsx b/src/features/discover/pages/DiscoverPage.tsx index 90e86d6..cc2e8d8 100644 --- a/src/features/discover/pages/DiscoverPage.tsx +++ b/src/features/discover/pages/DiscoverPage.tsx @@ -1,4 +1,5 @@ import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; import { AccessDeniedState } from "../components/AccessDeniedState"; import { DiscoverFilters } from "../components/DiscoverFilters"; @@ -12,6 +13,7 @@ import { useRoastProfile } from "../hooks/useRoastProfile"; import { useToast } from "../hooks/useToast"; import { useAuth } from "@/contexts/useAuth"; +import { SendMessageModal } from "@/features/messages/components/SendMessageModal"; export default function DiscoverPage() { const { user } = useAuth(); @@ -23,6 +25,8 @@ export default function DiscoverPage() { const roast = useRoastProfile({ showToast }); + const [contactTarget, setContactTarget] = useState<{ id: string; name: string } | null>(null); + return ( { + setContactTarget({ + id: profile.id || profile.userId!, + name: profile.name || "Operador Anônimo", + }); + }} />
)} @@ -64,6 +74,17 @@ export default function DiscoverPage() { /> )} + + + {contactTarget && ( + setContactTarget(null)} + onSuccess={() => showToast("Sinal enviado com sucesso!", "info")} + /> + )} + ); } diff --git a/src/features/guilda/components/GuildAvatar.tsx b/src/features/guilda/components/GuildAvatar.tsx deleted file mode 100644 index 94c2128..0000000 --- a/src/features/guilda/components/GuildAvatar.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useMemo, useState } from "react"; - -import type { AvatarProps } from "../model/guilda.types"; -import { getAvatarSources } from "../utils/guilda.formatters"; - -export function GuildAvatar({ member, currentUser, getGithubUrl }: AvatarProps) { - const [imageIndex, setImageIndex] = useState(0); - - const photoUrlToUse = - member.photoURL || - (currentUser && currentUser.uid === member.id ? currentUser.photoURL || undefined : undefined); - - const sources = useMemo( - () => getAvatarSources(photoUrlToUse, member.github, getGithubUrl), - [photoUrlToUse, member.github, getGithubUrl], - ); - - const currentSrc = sources[imageIndex]; - - if (!currentSrc) { - return ( -
- {member.name?.[0] || "?"} -
- ); - } - - return ( - {member.name} setImageIndex((index) => index + 1)} - /> - ); -} diff --git a/src/features/guilda/components/GuildHeader.tsx b/src/features/guilda/components/GuildHeader.tsx deleted file mode 100644 index de2c78e..0000000 --- a/src/features/guilda/components/GuildHeader.tsx +++ /dev/null @@ -1,31 +0,0 @@ -interface GuildHeaderProps { - totalMembers: number; -} - -export function GuildHeader({ totalMembers }: GuildHeaderProps) { - return ( -
-
-

- A Guilda_ -

-
-

- Esquadrão Operacional Tech Floripa '26 -

-
- - STATUS: PRONTO PARA COMBATE -
-
-
- -
-
- OPERADORES - [{totalMembers}] -
-
-
- ); -} diff --git a/src/features/guilda/components/GuildMemberCard.tsx b/src/features/guilda/components/GuildMemberCard.tsx deleted file mode 100644 index ea74e98..0000000 --- a/src/features/guilda/components/GuildMemberCard.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import type { User } from "firebase/auth"; -import { Github, Linkedin, Skull } from "lucide-react"; -import { motion } from "motion/react"; -import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from "recharts"; - -import { getCardPalette, getRadarData } from "../model/guilda.selectors"; -import type { GuildMember, RoastStep } from "../model/guilda.types"; -import { getGithubUrl, getLinkedinUrl } from "../utils/guilda.formatters"; - -import { GuildAvatar } from "./GuildAvatar"; - -interface GuildMemberCardProps { - member: GuildMember; - colorIndex: number; - user: User; - roastActiveMember: string | null; - roastStep: RoastStep; - roastLogs: string[]; - onOpenRoastSelection: (memberId: string) => void; - onExecuteRoast: (member: GuildMember, persona: "brutal" | "mild") => void; -} - -export function GuildMemberCard({ - member, - colorIndex, - user, - roastActiveMember, - roastStep, - roastLogs, - onOpenRoastSelection, - onExecuteRoast, -}: GuildMemberCardProps) { - const colors = getCardPalette(colorIndex); - - return ( -
-
-
- - {member.primaryRole || "OPERADOR"} - - {member.secondaryRoles && member.secondaryRoles.length > 0 && ( -
- {member.secondaryRoles.map((role) => ( - - {role} - - ))} -
- )} -
- - ID_{member.id.slice(0, 5)} - -
- -
-
-
-
- -
- -
- -

- {member.name || "NOME_NULO"} -

- -
- {member.github && ( - - HUB - - )} - {member.linkedin && ( - - IN - - )} -
-
- -
-
-
- - - - - -
- -
-
- - PAIXÕES (LOVES) - -
- {member.canvas?.loves && member.canvas.loves.length > 0 ? ( - member.canvas.loves.map((item) => ( - - {item} - - )) - ) : ( - Mistério. - )} -
-
- -
- - OPERO BEM - -
- {member.canvas?.comfort && member.canvas.comfort.length > 0 ? ( - member.canvas.comfort.map((item) => ( - - {item} - - )) - ) : ( - Neutro. - )} -
-
- -
- - NEM F*DENDO - -
- {member.canvas?.veto && member.canvas.veto.length > 0 ? ( - member.canvas.veto.map((item) => ( - - {item} - - )) - ) : ( - Faz qualquer jogo. - )} -
-
-
-
-
- - {user.uid === member.id && ( -
- {roastActiveMember !== member.id ? ( - - ) : roastStep === "selecting" ? ( -
- - -
- ) : ( -
- {roastLogs.map((log, index) => ( - - {">"} {log} - - ))} - - - -
- )} -
- )} -
- ); -} diff --git a/src/features/guilda/components/GuildMembersGrid.tsx b/src/features/guilda/components/GuildMembersGrid.tsx deleted file mode 100644 index 948cea8..0000000 --- a/src/features/guilda/components/GuildMembersGrid.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { User } from "firebase/auth"; - -import type { GuildMember, RoastStep } from "../model/guilda.types"; - -import { GuildMemberCard } from "./GuildMemberCard"; - -interface GuildMembersGridProps { - members: GuildMember[]; - user: User; - roastActiveMember: string | null; - roastStep: RoastStep; - roastLogs: string[]; - onOpenRoastSelection: (memberId: string) => void; - onExecuteRoast: (member: GuildMember, persona: "brutal" | "mild") => void; -} - -export function GuildMembersGrid({ - members, - user, - roastActiveMember, - roastStep, - roastLogs, - onOpenRoastSelection, - onExecuteRoast, -}: GuildMembersGridProps) { - return ( -
- {members.map((member, index) => ( - - ))} -
- ); -} diff --git a/src/features/guilda/components/GuildRoastModal.tsx b/src/features/guilda/components/GuildRoastModal.tsx deleted file mode 100644 index f3cfeaa..0000000 --- a/src/features/guilda/components/GuildRoastModal.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { GuildMember } from "../model/guilda.types"; - -import type { RoastPersona } from "@/domain/entities/Shared"; -import { RoastModal as SharedRoastModal } from "@/shared/components/ui/RoastModal"; - -interface GuildRoastModalProps { - selectedMember: GuildMember; - activePersonaView: RoastPersona | null; - onClose: () => void; - onGeneratePersona: (member: GuildMember, persona: RoastPersona) => void; -} - -export function GuildRoastModal({ - selectedMember, - activePersonaView, - onClose, - onGeneratePersona, -}: GuildRoastModalProps) { - const roastText = - activePersonaView === "brutal" - ? selectedMember.roastBrutal || selectedMember.roast - : selectedMember.roastMild || selectedMember.roastBrutal || selectedMember.roast; - - return ( - { - onClose(); - onGeneratePersona(selectedMember, "brutal"); - }} - onGenerateMild={() => { - onClose(); - onGeneratePersona(selectedMember, "mild"); - }} - /> - ); -} diff --git a/src/features/guilda/components/GuildStates.tsx b/src/features/guilda/components/GuildStates.tsx deleted file mode 100644 index c954eab..0000000 --- a/src/features/guilda/components/GuildStates.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Zap } from "lucide-react"; - -import { AccessDeniedState as SharedAccessDeniedState } from "@/shared/components/states/AccessDeniedState"; - -export function GuildAccessDeniedState() { - return ( - - ); -} - -export function GuildLoadingState() { - return ( -
-

- AGUARDANDO OPERADORES... -

-
- ); -} diff --git a/src/features/guilda/constants/guilda.constants.ts b/src/features/guilda/constants/guilda.constants.ts deleted file mode 100644 index 38455f7..0000000 --- a/src/features/guilda/constants/guilda.constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const GUILD_CARD_PALETTES = [ - { bg: "bg-neo-lime", accent: "bg-neo-pink", text: "text-neo-black" }, - { bg: "bg-neo-pink", accent: "bg-neo-cyan", text: "text-white" }, - { bg: "bg-neo-cyan", accent: "bg-neo-yellow", text: "text-neo-black" }, - { bg: "bg-neo-yellow", accent: "bg-neo-lime", text: "text-neo-black" }, -] as const; - -export const ROAST_LOGS_SEQUENCE = [ - "Iniciando conexão neural...", - "Lendo vetos e paixões...", - "Avaliando nível de vibração AI...", - "Preparando o veredito final...", -] as const; diff --git a/src/features/guilda/hooks/useGuildMembersRealtime.ts b/src/features/guilda/hooks/useGuildMembersRealtime.ts deleted file mode 100644 index ace9771..0000000 --- a/src/features/guilda/hooks/useGuildMembersRealtime.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useMemo } from "react"; - -import { sortMembers } from "../model/guilda.selectors"; -import type { GuildMember } from "../model/guilda.types"; - -import { useFirestoreSubscription } from "@/shared/hooks/useFirestoreSubscription"; - -export function useGuildMembersRealtime(currentUserId?: string) { - const { data, loading, error } = useFirestoreSubscription({ - collectionName: "profiles", - }); - - const members = useMemo( - () => (currentUserId ? sortMembers(data, currentUserId) : []), - [data, currentUserId], - ); - - return { members, loading, error }; -} diff --git a/src/features/guilda/hooks/useGuildRoast.ts b/src/features/guilda/hooks/useGuildRoast.ts deleted file mode 100644 index 2212790..0000000 --- a/src/features/guilda/hooks/useGuildRoast.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; - -import { ROAST_LOGS_SEQUENCE } from "../constants/guilda.constants"; -import type { GuildMember, RoastPersona } from "../model/guilda.types"; -import { saveRoast } from "../services/guilda.repository"; -import { useGuildRoastStore } from "../store/guildRoast"; - -import { requestRoast } from "@/shared/services/roast.service"; - -function getRoastByPersona(member: GuildMember, persona: RoastPersona) { - return persona === "brutal" ? member.roastBrutal : member.roastMild; -} - -export function useGuildRoast() { - const store = useGuildRoastStore(); - const logsIntervalRef = useRef(null); - - const clearLogsInterval = () => { - if (logsIntervalRef.current) { - window.clearInterval(logsIntervalRef.current); - logsIntervalRef.current = null; - } - }; - - useEffect(() => clearLogsInterval, []); - - const startLogs = (persona: RoastPersona) => { - const sequence = [`Selecionando persona: ${persona.toUpperCase()}...`, ...ROAST_LOGS_SEQUENCE]; - let currentLog = 0; - store.setRoastLogs([sequence[0]]); - clearLogsInterval(); - logsIntervalRef.current = window.setInterval(() => { - currentLog += 1; - if (currentLog < sequence.length) { - store.appendRoastLog(sequence[currentLog]); - } - }, 1500); - }; - - const roastMutation = useMutation({ - mutationFn: ({ member, persona }: { member: GuildMember; persona: RoastPersona }) => - requestRoast({ memberId: member.id, memberData: member, persona }), - onMutate: ({ persona }) => { - startLogs(persona); - }, - onSuccess: async (data, { member, persona }) => { - if (!data.roast) { - console.error("Erro no backend:", data); - return; - } - let updateData: Partial & { updatedAt?: Date } = {}; - try { - updateData = await saveRoast(member.id, data.roast, persona); - } catch (dbError) { - console.error("Erro ao salvar sina no banco:", dbError); - } - store.setSelectedMember({ ...member, ...updateData }); - }, - onError: (error) => { - console.error("Erro ao chamar o roast:", error); - }, - onSettled: () => { - clearLogsInterval(); - store.setRoastStep(null); - store.setRoastActiveMember(null); - }, - }); - - function executeRoast(member: GuildMember, persona: RoastPersona) { - store.setActivePersonaView(persona); - const existingRoast = getRoastByPersona(member, persona); - if (existingRoast) { - store.setSelectedMember(member); - return; - } - store.setRoastStep("loading"); - roastMutation.mutate({ member, persona }); - } - - return { - ...store, - executeRoast, - }; -} diff --git a/src/features/guilda/model/guilda.selectors.ts b/src/features/guilda/model/guilda.selectors.ts deleted file mode 100644 index 30a36ca..0000000 --- a/src/features/guilda/model/guilda.selectors.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { GUILD_CARD_PALETTES } from "../constants/guilda.constants"; - -import type { GuildMember, MemberSkills, RadarDatum } from "./guilda.types"; - -import { sortByCurrentUserAndName } from "@/shared/lib/utils/entity"; - -export function sortMembers(members: GuildMember[], currentUserId: string) { - return sortByCurrentUserAndName(members, currentUserId); -} - -export function getCardPalette(index: number) { - return GUILD_CARD_PALETTES[index % GUILD_CARD_PALETTES.length]; -} - -export function getRadarData(skills?: MemberSkills): RadarDatum[] { - if (!skills) return []; - - return [ - { subject: "Front", A: skills.frontend || 0, fullMark: 10 }, - { subject: "Back", A: skills.backend || 0, fullMark: 10 }, - { subject: "UX", A: skills.ux_ui || 0, fullMark: 10 }, - { subject: "Dados", A: skills.dados || 0, fullMark: 10 }, - { subject: "Hard", A: skills.hardware_android || 0, fullMark: 10 }, - { subject: "AI", A: skills.vibe_coding || 0, fullMark: 10 }, - ]; -} diff --git a/src/features/guilda/model/guilda.types.ts b/src/features/guilda/model/guilda.types.ts deleted file mode 100644 index 325aac2..0000000 --- a/src/features/guilda/model/guilda.types.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { User } from "firebase/auth"; - -export type { RoastPersona } from "@/domain/entities/Shared"; -export type RoastStep = "selecting" | "loading" | null; - -export interface MemberSkills { - frontend?: number; - backend?: number; - ux_ui?: number; - dados?: number; - hardware_android?: number; - vibe_coding?: number; -} - -export interface MemberCanvas { - loves?: string[]; - comfort?: string[]; - veto?: string[]; -} - -export interface GuildMember { - id: string; - name?: string; - photoURL?: string; - github?: string; - linkedin?: string; - primaryRole?: string; - secondaryRoles?: string[]; - skills?: MemberSkills; - canvas?: MemberCanvas; - roast?: string; - roastBrutal?: string; - roastMild?: string; -} - -export interface AvatarProps { - member: GuildMember; - currentUser: User | null; - getGithubUrl: (value: string) => string; -} - -export interface RadarDatum { - subject: string; - A: number; - fullMark: number; -} diff --git a/src/features/guilda/pages/GuildaPage.tsx b/src/features/guilda/pages/GuildaPage.tsx deleted file mode 100644 index d59b70e..0000000 --- a/src/features/guilda/pages/GuildaPage.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { AnimatePresence, motion } from "motion/react"; - -import { GuildHeader } from "../components/GuildHeader"; -import { GuildMembersGrid } from "../components/GuildMembersGrid"; -import { GuildRoastModal } from "../components/GuildRoastModal"; -import { GuildAccessDeniedState, GuildLoadingState } from "../components/GuildStates"; -import { useGuildMembersRealtime } from "../hooks/useGuildMembersRealtime"; -import { useGuildRoast } from "../hooks/useGuildRoast"; - -import { useAuth } from "@/contexts/useAuth"; - -export default function GuildaPage() { - const { user } = useAuth(); - const { members } = useGuildMembersRealtime(user?.uid); - const roast = useGuildRoast(); - - return ( - - - - {!user ? ( - - ) : members.length === 0 ? ( - - ) : ( - - )} - - - {roast.selectedMember && ( - - )} - - - ); -} diff --git a/src/features/guilda/services/guilda.repository.ts b/src/features/guilda/services/guilda.repository.ts deleted file mode 100644 index f217ee8..0000000 --- a/src/features/guilda/services/guilda.repository.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { doc, updateDoc } from "firebase/firestore"; - -import type { GuildMember } from "../model/guilda.types"; - -import type { RoastPersona } from "@/domain/entities/Shared"; -import { db } from "@/shared/lib/firebase/firebase.client"; - -export async function saveRoast(memberId: string, roast: string, persona: RoastPersona) { - const updateData: Partial & { updatedAt: Date } = { - updatedAt: new Date(), - ...(persona === "brutal" ? { roastBrutal: roast } : { roastMild: roast }), - }; - - await updateDoc(doc(db, "profiles", memberId), updateData); - - return updateData; -} diff --git a/src/features/guilda/store/guildRoast.ts b/src/features/guilda/store/guildRoast.ts deleted file mode 100644 index 5a02692..0000000 --- a/src/features/guilda/store/guildRoast.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { create } from "zustand"; - -import type { GuildMember, RoastPersona, RoastStep } from "../model/guilda.types"; - -interface GuildRoastState { - selectedMember: GuildMember | null; - roastActiveMember: string | null; - roastStep: RoastStep; - roastLogs: string[]; - activePersonaView: RoastPersona | null; - setSelectedMember: (member: GuildMember | null) => void; - setRoastActiveMember: (id: string | null) => void; - setRoastStep: (step: RoastStep) => void; - appendRoastLog: (log: string) => void; - setRoastLogs: (logs: string[]) => void; - setActivePersonaView: (persona: RoastPersona | null) => void; - closeSelectedMember: () => void; - openRoastSelection: (memberId: string) => void; -} - -export const useGuildRoastStore = create((set) => ({ - selectedMember: null, - roastActiveMember: null, - roastStep: null, - roastLogs: [], - activePersonaView: null, - setSelectedMember: (selectedMember) => set({ selectedMember }), - setRoastActiveMember: (roastActiveMember) => set({ roastActiveMember }), - setRoastStep: (roastStep) => set({ roastStep }), - setRoastLogs: (roastLogs) => set({ roastLogs }), - appendRoastLog: (log) => set((state) => ({ roastLogs: [...state.roastLogs, log].slice(-3) })), - setActivePersonaView: (activePersonaView) => set({ activePersonaView }), - closeSelectedMember: () => set({ selectedMember: null }), - openRoastSelection: (roastActiveMember) => set({ roastActiveMember, roastStep: "selecting" }), -})); diff --git a/src/features/guilda/utils/guilda.formatters.ts b/src/features/guilda/utils/guilda.formatters.ts deleted file mode 100644 index 0cab613..0000000 --- a/src/features/guilda/utils/guilda.formatters.ts +++ /dev/null @@ -1,47 +0,0 @@ -export function getGithubUrl(value: string) { - if (!value) return ""; - - const clean = value - .trim() - .replace(/^(?:https?:\/\/)?(?:www\.)?github\.com\//i, "") - .replace(/\/$/, "") - .replace(/^@/, ""); - - return `https://github.com/${clean}`; -} - -export function getLinkedinUrl(value: string) { - if (!value) return ""; - - const clean = value - .trim() - .replace(/^(?:https?:\/\/)?(?:[\w-]+\.)?linkedin\.com\/(?:in|profile)\//i, "") - .replace(/\/$/, "") - .replace(/^@/, ""); - - return `https://linkedin.com/in/${clean}`; -} - -export function getAvatarSources( - photoURL: string | undefined, - github: string | undefined, - githubUrlFormatter: (value: string) => string, -) { - const sources: string[] = []; - - if (photoURL) { - let photo = photoURL; - if (photo.includes("googleusercontent.com") && photo.includes("=s96-c")) { - photo = photo.replace("=s96-c", "=s400-c"); - } else if (photo.includes("googleusercontent.com") && !photo.includes("=")) { - photo = `${photo}=s400-c`; - } - sources.push(photo); - } - - if (github) { - sources.push(`${githubUrlFormatter(github)}.png`); - } - - return sources; -} diff --git a/src/features/landing/Landing.tsx b/src/features/landing/Landing.tsx index afcc1d7..2bc095c 100644 --- a/src/features/landing/Landing.tsx +++ b/src/features/landing/Landing.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { CtaSection } from "./components/CtaSection"; import { Footer } from "./components/Footer"; @@ -19,7 +19,7 @@ export default function Landing() { navigate("/onboarding")} - onNavigateGuilda={() => navigate("/guilda")} + onNavigateDiscover={() => navigate("/discover")} /> navigate("/onboarding")} /> diff --git a/src/features/landing/components/HeroSection.tsx b/src/features/landing/components/HeroSection.tsx index 7916663..d05b241 100644 --- a/src/features/landing/components/HeroSection.tsx +++ b/src/features/landing/components/HeroSection.tsx @@ -7,10 +7,10 @@ interface Props { // eslint-disable-next-line user: any; // TODO onNavigateOnboarding: () => void; - onNavigateGuilda: () => void; + onNavigateDiscover: () => void; } -export function HeroSection({ user, onNavigateOnboarding, onNavigateGuilda }: Props) { +export function HeroSection({ user, onNavigateOnboarding, onNavigateDiscover }: Props) { return (
{user && ( - )} diff --git a/src/features/messages/components/SendMessageModal.tsx b/src/features/messages/components/SendMessageModal.tsx new file mode 100644 index 0000000..ca7425d --- /dev/null +++ b/src/features/messages/components/SendMessageModal.tsx @@ -0,0 +1,175 @@ +import { Send, AlertTriangle } from "lucide-react"; +import { motion } from "motion/react"; +import { useState } from "react"; + +import { useAuth } from "@/contexts/useAuth"; +import { messageRepository } from "@/infrastructure/firebase/messageRepository"; + +interface SendMessageModalProps { + receiverId: string; + receiverName: string; + onClose: () => void; + onSuccess?: () => void; +} + +export function SendMessageModal({ + receiverId, + receiverName, + onClose, + onSuccess, +}: SendMessageModalProps) { + const { user } = useAuth(); + const [text, setText] = useState(""); + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(null); + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) { + setError("Você precisa estar autenticado para enviar mensagens."); + return; + } + if (!text.trim()) { + setError("Digite sua mensagem antes de enviar."); + return; + } + if (text.length > 2000) { + setError("Mensagem muito longa! Limite de 2000 caracteres."); + return; + } + + try { + setIsSending(true); + setError(null); + + const senderName = user.displayName || "Operador Anônimo"; + + await messageRepository.sendMessage({ + senderId: user.uid, + senderName: senderName, + receiverId, + receiverName, + text: text.trim(), + }); + + setText(""); + if (onSuccess) onSuccess(); + onClose(); + } catch (err) { + setError("Erro ao enviar mensagem. Tente novamente."); + console.error(err); + } finally { + setIsSending(false); + } + }; + + return ( +
+ {/* Backdrop */} + + +
+ {/* Modal Container */} + + {/* Close button */} +
+ +
+ +
+ {/* Header */} +
+
+ +
+

+ Mandar Sinal_ +

+

+ DESTINATÁRIO: {receiverName} +

+
+
+ + {/* Body Form */} +
+
+ + + Sua mensagem será enviada diretamente para a caixa de entrada interna deste + operador. + +
+ + {error && ( +
+ {error} +
+ )} + +
+ +