diff --git a/firestore.rules b/firestore.rules index 01f5dfd..47f8320 100644 --- a/firestore.rules +++ b/firestore.rules @@ -59,6 +59,8 @@ service cloud.firestore { && (!('roast' in data) || (data.roast is string && data.roast.size() <= 8000)) && (!('roastBrutal' in data) || (data.roastBrutal is string && data.roastBrutal.size() <= 8000)) && (!('roastMild' in data) || (data.roastMild is string && data.roastMild.size() <= 8000)) + // extraTagsByCategory: mapa de categoria → lista de nomes de skills criadas pelo usuário + && (!('extraTagsByCategory' in data) || data.extraTagsByCategory is map) && data.skills is map && data.skills.keys().hasAll(['frontend', 'backend', 'ux_ui', 'dados', 'hardware_android', 'vibe_coding']) && data.skills.frontend is number && data.skills.backend is number @@ -71,6 +73,37 @@ service cloud.firestore { && data.canvas.veto is list && data.canvas.veto.size() <= 50; } + // ───────────────────────────────────────────── + // SKILLS + // Catálogo global de skills. Qualquer usuário + // autenticado pode ler. Criação permitida apenas + // para skills com campos obrigatórios válidos. + // Edição e deleção bloqueadas para usuários comuns. + // ───────────────────────────────────────────── + function isValidSkill(data) { + return data.keys().hasAll(['id', 'name', 'normalizedName', 'status', 'usageCount', 'createdBy', 'createdAt']) + && data.id is string && data.id.size() > 0 && data.id.size() <= 128 + && data.name is string && data.name.size() > 0 && data.name.size() <= 100 + && data.normalizedName is string && data.normalizedName.size() > 0 && data.normalizedName.size() <= 100 + && data.status is string && data.status in ['pending', 'approved', 'rejected'] + && data.usageCount is number && data.usageCount >= 0 + && data.createdBy is string + && (!('category' in data) || data.category == null || (data.category is string && data.category.size() <= 50)); + } + + match /skills/{skillId} { + // Leitura pública para usuários autenticados (autocomplete) + allow read: if isSignedIn(); + + // Criação: usuário autenticado, skill válida, ID determinístico = normalizedName + allow create: if isSignedIn() + && skillId == incoming().normalizedName + && isValidSkill(incoming()); + + // Atualização e deleção: bloqueadas para usuários comuns (apenas admin via console) + allow update, delete: if false; + } + match /test/connection { allow read: if true; } @@ -110,7 +143,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', 'visibility']); + && incoming().keys().hasOnly(['userId', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'skills', 'canvas', 'status', 'squadId', 'eventId', 'roast', 'roastBrutal', 'roastMild', 'createdAt', 'updatedAt', 'visibility', 'extraTagsByCategory']); allow update: if isSignedIn() && isValidId(profileId) @@ -118,7 +151,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', 'visibility'])) + (incoming().diff(existing()).affectedKeys().hasOnly(['skills', 'canvas', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'status', 'squadId', 'eventId', 'updatedAt', 'roast', 'roastBrutal', 'roastMild', 'visibility', 'extraTagsByCategory'])) || false ); @@ -171,7 +204,6 @@ service cloud.firestore { && isValidMessage(incoming()) && incoming().createdAt == request.time; - // Allow listing for conversation thread queries (by conversationId, senderId or receiverId) allow list: if isSignedIn() && ( resource.data.receiverId == request.auth.uid diff --git a/src/application/factories/skillServiceFactory.ts b/src/application/factories/skillServiceFactory.ts new file mode 100644 index 0000000..e7de8c7 --- /dev/null +++ b/src/application/factories/skillServiceFactory.ts @@ -0,0 +1,11 @@ +import { SkillService } from "@/application/services/SkillService"; +import { FirebaseSkillRepository } from "@/infrastructure/firebase/skillRepository"; + +/** + * Factory simples para evitar acoplamento direto no frontend + * e manter consistência na criação do service. + */ +export function makeSkillService() { + const repository = new FirebaseSkillRepository(); + return new SkillService(repository); +} diff --git a/src/application/services/SkillService.ts b/src/application/services/SkillService.ts new file mode 100644 index 0000000..3665066 --- /dev/null +++ b/src/application/services/SkillService.ts @@ -0,0 +1,42 @@ +import { Timestamp } from "firebase/firestore"; + +import type { Skill } from "@/domain/entities/Skill"; +import type { ISkillRepository } from "@/domain/ports/ISkillRepository"; + +const normalizeSkillName = (name: string) => name.trim().toLowerCase().replace(/\s+/g, " "); + +export class SkillService { + constructor(private readonly repository: ISkillRepository) {} + + async createOrGetSkill(rawName: string, category: string | null = null): Promise { + if (!rawName || rawName.trim().length === 0) { + throw new Error("Skill inválida"); + } + + const normalizedName = normalizeSkillName(rawName); + + const existing = await this.repository.getSkillById(normalizedName); + if (existing) return existing; + + const newSkill: Skill = { + id: normalizedName, + name: rawName.trim(), + normalizedName, + category, + status: "pending", + usageCount: 1, + createdBy: "", + createdAt: Timestamp.now(), + }; + + try { + await this.repository.createSkill(newSkill); + } catch { + const fallback = await this.repository.getSkillById(normalizedName); + if (fallback) return fallback; + throw new Error(`Erro ao criar skill: ${rawName}`); + } + + return newSkill; + } +} diff --git a/src/domain/entities/Skill.ts b/src/domain/entities/Skill.ts new file mode 100644 index 0000000..71ee5c8 --- /dev/null +++ b/src/domain/entities/Skill.ts @@ -0,0 +1,14 @@ +import type { Timestamp } from "firebase/firestore"; + +export type SkillStatus = "pending" | "approved" | "rejected"; + +export interface Skill { + id: string; + name: string; + normalizedName: string; + category: string | null; + status: SkillStatus; + usageCount: number; + createdBy: string; + createdAt: Timestamp; +} diff --git a/src/domain/ports/ISkillRepository.ts b/src/domain/ports/ISkillRepository.ts new file mode 100644 index 0000000..d8a9ebd --- /dev/null +++ b/src/domain/ports/ISkillRepository.ts @@ -0,0 +1,11 @@ +import type { Skill } from "@/domain/entities/Skill"; + +export interface ISkillRepository { + getAllSkills(): Promise; + + getSkillById(id: string): Promise; + + createSkill(skill: Skill): Promise; + + updateSkill(id: string, data: Partial): Promise; +} diff --git a/src/features/onboarding/components/SkillAutocomplete.tsx b/src/features/onboarding/components/SkillAutocomplete.tsx new file mode 100644 index 0000000..6008e2a --- /dev/null +++ b/src/features/onboarding/components/SkillAutocomplete.tsx @@ -0,0 +1,230 @@ +import { Search, Plus, X } from "lucide-react"; +import { motion, AnimatePresence } from "motion/react"; +import { useRef, useState, useEffect, useCallback } from "react"; + +import type { Skill } from "@/domain/entities/Skill"; + +interface Props { + search: string; + filteredSkills: Skill[]; + selectedSkills: Skill[]; + onSearchChange: (value: string) => void; + onSelectSkill: (skill: Skill) => void; + onRemoveSkill: (skillId: string) => void; + onCreateSkill: (name: string) => Promise; +} + +export function SkillAutocomplete({ + search, + filteredSkills, + selectedSkills, + onSearchChange, + onSelectSkill, + onRemoveSkill, + onCreateSkill, +}: Props) { + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + + const containerRef = useRef(null); + const inputRef = useRef(null); + + const trimmed = search.trim(); + + // Fecha o dropdown ao clicar fora do container inteiro + // Isso é mais confiável do que onBlur + setTimeout + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Mostra resultados sempre que está aberto — lista completa quando vazio, filtrada quando tem texto + const showNoResults = open && trimmed.length > 0 && filteredSkills.length === 0; + const showResults = open && filteredSkills.length > 0; + + const handleSelect = useCallback( + (skill: Skill) => { + onSelectSkill(skill); + onSearchChange(""); + setOpen(false); + inputRef.current?.focus(); + }, + [onSelectSkill, onSearchChange], + ); + + const handleCreate = useCallback(async () => { + if (!trimmed || creating) return; + setCreating(true); + try { + await onCreateSkill(trimmed); + onSearchChange(""); + setOpen(false); + } finally { + setCreating(false); + } + }, [trimmed, creating, onCreateSkill, onSearchChange]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + onSearchChange(""); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + if (showNoResults) { + handleCreate(); + } else if (filteredSkills.length === 1) { + handleSelect(filteredSkills[0]); + } + } + }; + + return ( +
+ {/* ── Search input ── */} +
+
+ + + { + onSearchChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className="flex-1 px-4 py-3 font-black text-sm uppercase tracking-widest bg-transparent outline-none text-neo-black placeholder:text-neo-black/25" + /> + + {search && ( + + )} +
+ + {/* ── Dropdown ── */} + + {open && ( + + {showResults && ( + <> + {/* Label de contexto */} +
+ + {trimmed ? `Resultados para "${trimmed}"` : "Todas as skills disponíveis"} + +
+ + {filteredSkills.map((skill) => ( + + ))} + + )} + + {showNoResults && ( +
+

+ Nenhuma skill encontrada. +

+ +
+ )} + + {/* Campo vazio + dropdown aberto: nenhum texto digitado ainda */} + {open && !trimmed && filteredSkills.length === 0 && ( +
+

+ Todas as skills já foram adicionadas. +

+
+ )} +
+ )} +
+
+ + {/* ── Selected skills chips ── */} + {selectedSkills.length > 0 && ( +
+ + {selectedSkills.map((skill) => ( + + + {skill.name} + + + + ))} + +
+ )} +
+ ); +} diff --git a/src/features/onboarding/components/SkillSelectorCard.tsx b/src/features/onboarding/components/SkillSelectorCard.tsx new file mode 100644 index 0000000..21a7f4d --- /dev/null +++ b/src/features/onboarding/components/SkillSelectorCard.tsx @@ -0,0 +1,82 @@ +import { Plus } from "lucide-react"; +import { useMemo } from "react"; + +import type { Skill } from "@/domain/entities/Skill"; +import { Card } from "@/shared/components/ui/Card"; + +interface Props { + value: string; + skills: Skill[]; + onChange: (value: string) => void; + onSelect: (skill: Skill) => void; + onCreate: (name: string) => void; +} + +export function SkillSelectorCard({ value, skills, onChange, onSelect, onCreate }: Props) { + // Filtra localmente (UI burra, sem regra de negócio) + const filteredSkills = useMemo(() => { + if (!value.trim()) return skills; + + return skills.filter((skill) => skill.normalizedName.includes(value.toLowerCase().trim())); + }, [value, skills]); + + const hasResults = filteredSkills.length > 0; + const canCreate = value.trim().length > 0; + + return ( + + {/* HEADER */} +
+

+ ARSENAL_DE_SKILLS (SEARCH) +

+
+ + {/* INPUT */} +
+ onChange(e.target.value)} + placeholder="Buscar ou criar skill..." + className="w-full px-4 py-3 border-4 border-neo-black font-bold uppercase text-sm outline-none shadow-[4px_4px_0_0_#000]" + /> +
+ + {/* LISTA */} +
+ {filteredSkills.map((skill) => ( + + ))} + + {/* EMPTY STATE + CREATE */} + {!hasResults && ( +
+

Nenhuma skill encontrada

+ + {canCreate && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/features/onboarding/components/SkillSliders.tsx b/src/features/onboarding/components/SkillSliders.tsx index bfcf57c..fe45afe 100644 --- a/src/features/onboarding/components/SkillSliders.tsx +++ b/src/features/onboarding/components/SkillSliders.tsx @@ -2,12 +2,12 @@ import { Card } from "../../../shared/components/ui/Card"; import type { OnboardingSkills } from "../types"; const SKILL_CONFIG = [ - { label: "Frontend / Visual", key: "frontend" }, - { label: "Backend / Infra", key: "backend" }, - { label: "UX / Psicologia", key: "ux_ui" }, - { label: "Dados / Lógica", key: "dados" }, - { label: "Hardware / IoT", key: "hardware_android" }, - { label: "IA / Vibe Coding", key: "vibe_coding" }, + { label: "Frontend / Visual", key: "frontend" }, + { label: "Backend / Infra", key: "backend" }, + { label: "UX / Psicologia", key: "ux_ui" }, + { label: "Dados / Lógica", key: "dados" }, + { label: "Hardware / IoT", key: "hardware_android" }, + { label: "IA / Vibe Coding", key: "vibe_coding" }, ] as const; interface Props { @@ -17,7 +17,11 @@ interface Props { export function SkillSliders({ skills, onSkillChange }: Props) { return ( - +

03. NÍVEL_DE_SINC diff --git a/src/features/onboarding/components/TagCategoryCard.tsx b/src/features/onboarding/components/TagCategoryCard.tsx index 580696c..b8760a5 100644 --- a/src/features/onboarding/components/TagCategoryCard.tsx +++ b/src/features/onboarding/components/TagCategoryCard.tsx @@ -1,11 +1,13 @@ -import { Heart, Check, Ban } from "lucide-react"; -import { motion } from "motion/react"; +import { Heart, Check, Ban, Search, Plus, X } from "lucide-react"; +import { motion, AnimatePresence } from "motion/react"; +import { useRef, useState, useEffect, useMemo, useCallback } from "react"; import type { OnboardingForm, TagSentiment } from "../types"; import { Card } from "@/shared/components/ui/Card"; interface TagCategory { + key: string; name: string; color: string; textColor: string; @@ -14,18 +16,89 @@ interface TagCategory { interface Props { category: TagCategory; + extraTags?: string[]; // tags criadas pelo usuário nesta categoria form: OnboardingForm; onSetSentiment: (tag: string, sentiment: TagSentiment) => void; + onCreateSkill: (name: string, categoryKey: string) => Promise; } -export function TagCategoryCard({ category, form, onSetSentiment }: Props) { +export function TagCategoryCard({ + category, + extraTags = [], + form, + onSetSentiment, + onCreateSkill, +}: Props) { + const [search, setSearch] = useState(""); + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + + const containerRef = useRef(null); + const inputRef = useRef(null); + + // All tags: fixed + user-created, deduplicated and sorted alphabetically + const allTags = useMemo(() => { + const merged = [ + ...category.tags, + ...extraTags.filter((t) => !category.tags.some((ct) => ct.toLowerCase() === t.toLowerCase())), + ]; + return merged.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })); + }, [category.tags, extraTags]); + + const trimmed = search.trim(); + + // Tags visible in the list — filtered when there's a search term + const visibleTags = useMemo(() => { + if (!trimmed) return allTags; + return allTags.filter((tag) => tag.toLowerCase().includes(trimmed.toLowerCase())); + }, [allTags, trimmed]); + + const hasResults = visibleTags.length > 0; + const showNoResults = open && trimmed.length > 0 && !hasResults; + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + setSearch(""); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleCreate = useCallback(async () => { + if (!trimmed || creating) return; + setCreating(true); + try { + await onCreateSkill(trimmed, category.key); + setSearch(""); + setOpen(false); + } finally { + setCreating(false); + } + }, [trimmed, creating, onCreateSkill, category.key]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + setSearch(""); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + if (showNoResults) handleCreate(); + } + }; + return ( - {/* Category header */} + {/* ── Category header ── */}

- {/* Tag rows */} -
- {category.tags.map((tag) => { - const isLoves = form.loves.includes(tag); - const isComfort = form.comfort.includes(tag); - const isVeto = form.veto.includes(tag); - - return ( -
+
+ + { + setSearch(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className="flex-1 px-3 py-2.5 font-black text-[11px] uppercase tracking-widest bg-transparent outline-none text-neo-black placeholder:text-neo-black/20" + /> + {search && ( + - - {/* Comfort */} - - - {/* Veto */} - + )} +
+ + {/* ── Dropdown ── */} + + {open && trimmed.length > 0 && ( + + {hasResults + ? visibleTags.map((tag) => ( + + )) + : null} + + {showNoResults && ( +
+

+ Nenhuma skill encontrada. +

+ -
-
- ); - })} + + {creating ? "ADICIONANDO..." : `Adicionar "${trimmed}"`} + +
+ )} + + )} + +

+ + {/* ── Tag rows (alphabetical, filtered when searching) ── */} +
+ + {(trimmed ? visibleTags : allTags).map((tag) => { + const isLoves = form.loves.includes(tag); + const isComfort = form.comfort.includes(tag); + const isVeto = form.veto.includes(tag); + const isNew = extraTags.some((t) => t.toLowerCase() === tag.toLowerCase()); + + return ( + +
+ + {tag} + {/* Badge for user-created skills */} + {isNew && ( + + NOVA + + )} + + +
+ {/* Loves */} + + + {/* Comfort */} + + + {/* Veto */} + +
+
+
+ ); + })} +
); diff --git a/src/features/onboarding/constants/tagCategories.ts b/src/features/onboarding/constants/tagCategories.ts index 2d8f1b2..91ab371 100644 --- a/src/features/onboarding/constants/tagCategories.ts +++ b/src/features/onboarding/constants/tagCategories.ts @@ -1,38 +1,125 @@ export const TAG_CATEGORIES = [ { + key: "core_tech", name: "Core Tech", color: "bg-neo-cyan", textColor: "text-neo-cyan", - tags: ["React", "Next.js", "Vue", "Vite", "Tailwind", "Node.js", "Rust", "Go", "Java", "C++", "C#", "SQL", "NoSQL", "Firebase", "Supabase", "AWS", "Google Cloud", "Docker", "Kubernetes", "Redis", "MQTT"] + tags: [ + "React", + "Next.js", + "Vue", + "Vite", + "Tailwind", + "Node.js", + "Rust", + "Go", + "Java", + "C++", + "C#", + "SQL", + "NoSQL", + "Firebase", + "Supabase", + "AWS", + "Google Cloud", + "Docker", + "Kubernetes", + "Redis", + "MQTT", + ], }, { + key: "ia_future", name: "IA & Future", color: "bg-neo-pink", textColor: "text-neo-pink", - tags: ["Vibe Coding", "LLMs", "RAG", "Vector DBs", "OpenCV", "Fine-tuning", "Python", "Data Pipeline", "Web3", "Blockchain"] + tags: [ + "Vibe Coding", + "LLMs", + "RAG", + "Vector DBs", + "OpenCV", + "Fine-tuning", + "Python", + "Data Pipeline", + "Web3", + "Blockchain", + ], }, { + key: "hardware_iot", name: "Hardware & IoT", color: "bg-neo-yellow", textColor: "text-neo-yellow", - tags: ["Arduino", "ESP32", "Raspberry Pi", "Hardware", "IoT", "Edge Computing", "Android", "Swift", "Flutter"] + tags: [ + "Arduino", + "ESP32", + "Raspberry Pi", + "Hardware", + "IoT", + "Edge Computing", + "Android", + "Swift", + "Flutter", + ], }, { + key: "design_ux", name: "Design & UX", color: "bg-neo-lime", textColor: "text-neo-lime", - tags: ["UI/UX", "Mobile First", "Data Viz", "Motion Design", "Shaders (GLSL)", "Cybersecurity", "DevOps", "Cloud Arch"] + tags: [ + "UI/UX", + "Mobile First", + "Data Viz", + "Motion Design", + "Shaders (GLSL)", + "Cybersecurity", + "DevOps", + "Cloud Arch", + ], }, { + key: "lifestyle", name: "Lifestyle", color: "bg-white", textColor: "text-white", - tags: ["Café Preto", "Energético", "Noite Adentro", "Manhã Cedo", "Música Lofi", "Techno/Phonk", "Podcasts", "Action Figures", "Mechanical Keyboards", "Retro Gaming", "Cyberpunk", "D&D", "Pizzaria 24h", "Code Review"] + tags: [ + "Café Preto", + "Energético", + "Noite Adentro", + "Manhã Cedo", + "Música Lofi", + "Techno/Phonk", + "Podcasts", + "Action Figures", + "Mechanical Keyboards", + "Retro Gaming", + "Cyberpunk", + "D&D", + "Pizzaria 24h", + "Code Review", + ], }, { + key: "grunt_work", name: "The Grunt Work (Vetos)", color: "bg-neo-pink", textColor: "text-neo-pink", - tags: ["Documentação", "Servidores DNS", "CSS Puro", "Formulários", "Deploy Manual", "Reuniões", "PHP", "Legacy Code", "Excel", "Bitbucket", "SVN", "Vandalismo Visual", "Internet Lenta"] - } + tags: [ + "Documentação", + "Servidores DNS", + "CSS Puro", + "Formulários", + "Deploy Manual", + "Reuniões", + "PHP", + "Legacy Code", + "Excel", + "Bitbucket", + "SVN", + "Vandalismo Visual", + "Internet Lenta", + ], + }, ]; diff --git a/src/features/onboarding/hooks/useOnboardingForm.ts b/src/features/onboarding/hooks/useOnboardingForm.ts index f79d44e..4d9b1cd 100644 --- a/src/features/onboarding/hooks/useOnboardingForm.ts +++ b/src/features/onboarding/hooks/useOnboardingForm.ts @@ -1,20 +1,47 @@ import { doc, setDoc, getDoc, serverTimestamp } from "firebase/firestore"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { db } from "../../../shared/lib/firebase/firebase.client"; import { firestoreLog } from "../../../shared/lib/logger/logger"; import type { OnboardingForm, OnboardingSkills, TagSentiment } from "../types"; +import { SkillService } from "@/application/services/SkillService"; +import { FirebaseSkillRepository } from "@/infrastructure/firebase/skillRepository"; + +type ProfileData = { + userId: string; + name: string; + photoURL: string | null; + github: string; + linkedin: string; + bio: string; + primaryRole: string; + secondaryRoles: string[]; + skills: OnboardingSkills; + extraTagsByCategory: Record; + canvas: { loves: string[]; comfort: string[]; veto: string[] }; + status: string; + eventId: string; + updatedAt: ReturnType; + createdAt?: ReturnType; + visibility?: string; +}; + // eslint-disable-next-line export function useOnboardingForm(user: any) { - // TODO const navigate = useNavigate(); + // ───────────────────────────────────────────── + // STATE + // ───────────────────────────────────────────── const [loading, setLoading] = useState(false); const [initializing, setInitializing] = useState(true); const [submitError, setSubmitError] = useState(null); + // Extra tags added by users per category key, merged into the category tag list + const [extraTagsByCategory, setExtraTagsByCategory] = useState>({}); + const [skills, setSkills] = useState({ frontend: 7, backend: 4, @@ -38,7 +65,12 @@ export function useOnboardingForm(user: any) { createdAt: "", }); - // Firestore fetch on mount + const skillRepository = useMemo(() => new FirebaseSkillRepository(), []); + const skillService = useMemo(() => new SkillService(skillRepository), [skillRepository]); + + // ───────────────────────────────────────────── + // FIRESTORE LOAD + // ───────────────────────────────────────────── const fetchMemberData = async () => { if (!user) { setInitializing(false); @@ -65,6 +97,7 @@ export function useOnboardingForm(user: any) { if (docSnap.exists()) { const data = docSnap.data(); + setForm({ name: data.name || user.displayName || "", github: data.github || "", @@ -78,9 +111,17 @@ export function useOnboardingForm(user: any) { status: data.status || "looking", createdAt: fromMembersFallback ? null : data.createdAt || null, }); + if (data.skills) setSkills(data.skills); + + if (data.extraTagsByCategory) { + setExtraTagsByCategory(data.extraTagsByCategory); + } } else { - setForm((prev) => ({ ...prev, name: prev.name || user.displayName || "" })); + setForm((prev) => ({ + ...prev, + name: prev.name || user.displayName || "", + })); } } catch (error) { firestoreLog.error("Erro ao buscar dados do membro:", error); @@ -89,7 +130,36 @@ export function useOnboardingForm(user: any) { } }; - // Radar chart data derived from skills + // ───────────────────────────────────────────── + // SKILL CREATION PER CATEGORY + // useCallback so it can be safely listed as a + // useEffect dependency without infinite loops. + // ───────────────────────────────────────────── + const handleCreateSkillInCategory = useCallback( + async (rawName: string, categoryKey: string): Promise => { + const trimmed = rawName.trim(); + if (!trimmed) return; + + try { + await skillService.createOrGetSkill(trimmed, categoryKey); + + setExtraTagsByCategory((prev) => { + const existing = prev[categoryKey] ?? []; + if (existing.some((t) => t.toLowerCase() === trimmed.toLowerCase())) { + return prev; + } + return { ...prev, [categoryKey]: [...existing, trimmed] }; + }); + } catch (error) { + firestoreLog.error("Erro ao criar skill:", error); + } + }, + [skillService], + ); + + // ───────────────────────────────────────────── + // RADAR + // ───────────────────────────────────────────── const radarData = useMemo( () => [ { subject: "Frontend", A: skills.frontend, fullMark: 10 }, @@ -102,8 +172,9 @@ export function useOnboardingForm(user: any) { [skills], ); - // ── Handlers ────────────────────────────────────────────────────────────── - + // ───────────────────────────────────────────── + // HANDLERS FORM + // ───────────────────────────────────────────── const handleChange = ( e: React.ChangeEvent, ) => { @@ -121,11 +192,15 @@ export function useOnboardingForm(user: any) { const toggleRole = (role: string) => { setForm((prev) => { if (prev.primaryRole === role) return { ...prev, primaryRole: "" }; + if (prev.secondaryRoles.includes(role)) return { ...prev, secondaryRoles: prev.secondaryRoles.filter((r) => r !== role) }; + if (!prev.primaryRole) return { ...prev, primaryRole: role }; + if (prev.secondaryRoles.length < 2) return { ...prev, secondaryRoles: [...prev.secondaryRoles, role] }; + return prev; }); }; @@ -144,11 +219,15 @@ export function useOnboardingForm(user: any) { }); }; + // ───────────────────────────────────────────── + // SUBMIT + // ───────────────────────────────────────────── const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); if (!user) return; setLoading(true); + try { const cleanGithub = (form.github || "") .trim() @@ -162,9 +241,7 @@ export function useOnboardingForm(user: any) { .replace(/\/$/, "") .replace(/^@/, ""); - // eslint-disable-next-line - const profileData: any = { - // TODO + const profileData: ProfileData = { userId: user.uid, name: form.name.trim(), photoURL: user.photoURL || null, @@ -174,6 +251,7 @@ export function useOnboardingForm(user: any) { primaryRole: form.primaryRole, secondaryRoles: form.secondaryRoles, skills, + extraTagsByCategory, canvas: { loves: form.loves, comfort: form.comfort, @@ -190,6 +268,7 @@ export function useOnboardingForm(user: any) { } await setDoc(doc(db, "profiles", user.uid), profileData, { merge: true }); + navigate("/discover"); } catch (err) { firestoreLog.error("Erro ao registrar perfil:", err); @@ -200,6 +279,9 @@ export function useOnboardingForm(user: any) { } }; + // ───────────────────────────────────────────── + // RETURN + // ───────────────────────────────────────────── return { form, setForm, @@ -209,6 +291,8 @@ export function useOnboardingForm(user: any) { submitError, radarData, fetchMemberData, + extraTagsByCategory, + handlers: { change: handleChange, bioChange: handleBioChange, @@ -216,6 +300,7 @@ export function useOnboardingForm(user: any) { toggleRole, setTagSentiment, submit: handleSubmit, + createSkillInCategory: handleCreateSkillInCategory, }, }; } diff --git a/src/features/onboarding/pages/Onboarding.tsx b/src/features/onboarding/pages/Onboarding.tsx index a919f7d..8386d14 100644 --- a/src/features/onboarding/pages/Onboarding.tsx +++ b/src/features/onboarding/pages/Onboarding.tsx @@ -2,11 +2,6 @@ import { motion } from "motion/react"; import React from "react"; -// Constants - -// Hook - -// Components import { ArsenalCalibration } from "../components/ArsenalCalibration"; import { AuthGate } from "../components/AuthGate/AuthGate"; import { ClassSelector } from "../components/ClassSelector"; @@ -33,10 +28,18 @@ export default function Onboarding() { confirmMagicLinkEmail, } = useAuth(); - const { form, skills, loading, initializing, submitError, radarData, fetchMemberData, handlers } = - useOnboardingForm(user); + const { + form, + skills, + loading, + initializing, + submitError, + radarData, + fetchMemberData, + extraTagsByCategory, + handlers, + } = useOnboardingForm(user); - // Fetch profile data once user is authenticated React.useEffect(() => { fetchMemberData(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -135,10 +138,12 @@ export default function Onboarding() {
{TAG_CATEGORIES.map((category) => ( ))}
diff --git a/src/infrastructure/firebase/skillRepository.ts b/src/infrastructure/firebase/skillRepository.ts new file mode 100644 index 0000000..be6d27c --- /dev/null +++ b/src/infrastructure/firebase/skillRepository.ts @@ -0,0 +1,69 @@ +import { collection, getDocs, doc, getDoc, setDoc, serverTimestamp } from "firebase/firestore"; + +import type { Skill } from "@/domain/entities/Skill"; +import type { ISkillRepository } from "@/domain/ports/ISkillRepository"; +import { db } from "@/shared/lib/firebase/firebase.client"; + +export class FirebaseSkillRepository implements ISkillRepository { + /** + * Lista todas as skills. + * Usado para autocomplete inicial e administração. + */ + async getAllSkills(): Promise { + const snapshot = await getDocs(collection(db, "skills")); + + return snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as Skill[]; + } + + /** + * Busca skill diretamente pelo ID. + * + * DECISÃO: + * - id = normalizedName + * - leitura direta O(1) + */ + async getSkillById(id: string): Promise { + const ref = doc(db, "skills", id); + const snapshot = await getDoc(ref); + + if (!snapshot.exists()) { + return null; + } + + return { + id: snapshot.id, + ...snapshot.data(), + } as Skill; + } + + /** + * Cria skill usando ID determinístico. + */ + async createSkill(skill: Skill): Promise { + const ref = doc(db, "skills", skill.normalizedName); + + await setDoc( + ref, + { + ...skill, + id: skill.normalizedName, + createdAt: serverTimestamp(), + }, + { + merge: false, + }, + ); + } + + /** + * Atualização parcial. + * Prefixo _ nos parâmetros indica que são exigidos pela interface + * mas ainda não implementados nesta versão. + */ + async updateSkill(_id: string, _data: Partial): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/src/shared/lib/utils/normalizeSkillName.ts b/src/shared/lib/utils/normalizeSkillName.ts new file mode 100644 index 0000000..78df082 --- /dev/null +++ b/src/shared/lib/utils/normalizeSkillName.ts @@ -0,0 +1,6 @@ +/** + * Normaliza nome da skill para garantir consistência no banco. + */ +export function normalizeSkillName(name: string): string { + return name.trim().toLowerCase().replace(/\s+/g, " "); +}