diff --git a/src/layout/melissa/MelissaConfiguracoes.vue b/src/layout/melissa/MelissaConfiguracoes.vue index b2a4f4d..70b2a7d 100644 --- a/src/layout/melissa/MelissaConfiguracoes.vue +++ b/src/layout/melissa/MelissaConfiguracoes.vue @@ -39,7 +39,6 @@ const emit = defineEmits(['close']); // existir, senão a key como-é. const ROUTE_ALIASES = { aparencia: 'aparencia', - perfil: 'cfg-perfil', plano: 'cfg-plano', negocio: 'cfg-negocio', seguranca: 'cfg-seguranca', @@ -99,7 +98,7 @@ const COMPONENT_MAP = { 'cfg-recursos-extras': defineAsyncComponent(() => import('@/layout/configuracoes/ConfiguracoesRecursosExtrasPage.vue')), 'cfg-auditoria': defineAsyncComponent(() => import('@/layout/configuracoes/AuditoriaPage.vue')), // Conta (páginas pessoais que vivem em /account/*) - 'cfg-perfil': defineAsyncComponent(() => import('@/views/pages/account/ProfilePage.vue')), + // 'cfg-perfil' removido — virou pagina nativa MelissaPerfil em /melissa/perfil 'cfg-plano': defineAsyncComponent(() => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')), 'cfg-negocio': defineAsyncComponent(() => import('@/views/pages/account/Negociopage.vue')), 'cfg-seguranca': defineAsyncComponent(() => import('@/views/pages/auth/SecurityPage.vue')) @@ -151,13 +150,13 @@ const grupos = [ { key: 'conta', label: 'Conta', - desc: 'Perfil pessoal, plano contratado, dados do negócio e segurança.', + desc: 'Plano contratado, dados do negócio e segurança.', icon: 'pi pi-user', items: [ - { key: 'cfg-perfil', label: 'Meu Perfil', desc: 'Nome, avatar, redes sociais e preferências de aparência.', icon: 'pi pi-user' }, - { key: 'cfg-plano', label: 'Meu Plano', desc: 'Plano contratado, limites de uso e fatura.', icon: 'pi pi-credit-card' }, - { key: 'cfg-negocio', label: 'Meu Negócio', desc: 'Dados do negócio, faturamento e branding.', icon: 'pi pi-briefcase' }, - { key: 'cfg-seguranca', label: 'Segurança', desc: 'Senha, dispositivos confiáveis e sessões ativas.', icon: 'pi pi-shield' } + // "Meu Perfil" virou pagina nativa em /melissa/perfil (MelissaPerfil) + { key: 'cfg-plano', label: 'Meu Plano', desc: 'Plano contratado, limites de uso e fatura.', icon: 'pi pi-credit-card' }, + { key: 'cfg-negocio', label: 'Meu Negócio', desc: 'Dados do negócio, faturamento e branding.', icon: 'pi pi-briefcase' }, + { key: 'cfg-seguranca', label: 'Segurança', desc: 'Senha, dispositivos confiáveis e sessões ativas.', icon: 'pi pi-shield' } ] }, { diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index 6f8374c..da8388a 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -33,6 +33,7 @@ import MelissaConversas from './MelissaConversas.vue'; import MelissaTags from './MelissaTags.vue'; import MelissaGrupos from './MelissaGrupos.vue'; import MelissaConfiguracoes from './MelissaConfiguracoes.vue'; +import MelissaPerfil from './MelissaPerfil.vue'; import MelissaEmbed from './MelissaEmbed.vue'; import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue'; import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue'; @@ -163,8 +164,9 @@ const SECOES = { medicos: { label: 'Médicos e referências', icon: 'pi pi-user-edit', descricao: 'Profissionais que encaminham ou recebem pacientes.' }, // Pagina unificada do Layout Melissa (Aparencia + Fundo + Relogio + Cronometro) aparencia: { label: 'Layout Melissa', icon: 'pi pi-palette', descricao: 'Aparência, plano de fundo, relógio e cronômetro do resumo.' }, - // Atalhos de Conta — todas montam o MelissaConfiguracoes com a seção embed pré-selecionada - perfil: { label: 'Meu Perfil', icon: 'pi pi-user', descricao: 'Dados pessoais, avatar e preferências.' }, + // Pagina nativa do perfil (MelissaPerfil) — saiu do MelissaConfiguracoes + perfil: { label: 'Meu Perfil', icon: 'pi pi-user', descricao: 'Identidade, contato, bio, redes — gamificacao no aside.' }, + // Atalhos de Conta restantes — todos montam o MelissaConfiguracoes com a seção embed pré-selecionada plano: { label: 'Meu Plano', icon: 'pi pi-credit-card', descricao: 'Plano contratado, limites e fatura.' }, negocio: { label: 'Meu Negócio', icon: 'pi pi-briefcase', descricao: 'Dados do negócio, faturamento e branding.' }, seguranca: { label: 'Segurança', icon: 'pi pi-shield', descricao: 'Senha, dispositivos confiáveis e sessões.' }, @@ -193,12 +195,13 @@ const MELISSA_NON_CONFIG_SLUGS = new Set([ 'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos', 'link-externo', 'notificacoes', 'financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'relatorios', + 'perfil', ...MELISSA_EMBED_KEYS ]); // Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes. // Mantidos sincronizados com o ROUTE_ALIASES/INLINE_KEYS de lá. const MELISSA_CONFIG_ALIASES = new Set([ - 'aparencia', 'perfil', 'plano', 'negocio', 'seguranca', 'bloqueios', + 'aparencia', 'plano', 'negocio', 'seguranca', 'bloqueios', 'fundo', 'relogio', 'cronometro' ]); function isMelissaConfigRoute(slug) { @@ -2207,6 +2210,11 @@ function onKeydown(e) { @close="fecharSecao" /> + + +/* + * MelissaPerfil — Pagina nativa Melissa pra "Meu Perfil". + * + * Substitui o embed cfg-perfil que vivia dentro do MelissaConfiguracoes. + * Layout 2-col: + * - COL 1 (sidebar) — Gamificação (nivel + XP + badges + dicas) + Avatar + * (upload/remover) + footer Sair da conta + * - COL 2 (main) — Identidade + Contato + Bio + Sites e Redes + * + * Aparência (tema/cores) e Layout Variant ficaram fora — Aparencia ja + * vive no MelissaConfiguracoes "Layout Melissa" e Layout Variant nao + * faz sentido dentro do shell Melissa. Trocar senha vai pro cfg-seguranca. + * + * Logica de load/save espelhada do ProfilePage.vue (mesmas tabelas + * profiles + user_settings + auth.user_metadata, mesmo bucket avatars). + */ +import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'; +import { useToast } from 'primevue/usetoast'; +import { useConfirm } from 'primevue/useconfirm'; +import { useRouter } from 'vue-router'; +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +// InputText/Select/Textarea/InputMask/Skeleton/Tag/Button: auto via PrimeVueResolver + +const emit = defineEmits(['close']); + +const toast = useToast(); +const confirm = useConfirm(); +const router = useRouter(); +const tenantStore = useTenantStore(); + +const AVATAR_BUCKET = 'avatars'; + +// ── Breakpoints + drawer ─────────────────────────────────── +const drawerOpen = ref(false); +const isMobile = ref(false); +let _mqMobile = null; +function _onMqMobileChange(e) { + isMobile.value = e.matches; + if (!e.matches) drawerOpen.value = false; +} +function toggleDrawer() { drawerOpen.value = !drawerOpen.value; } +function fecharDrawer() { drawerOpen.value = false; } + +// ── Estado ───────────────────────────────────────────────── +const loading = ref(true); +const saving = ref(false); +const dirty = ref(false); +const silentApplying = ref(true); + +const userId = ref(''); +const userEmail = ref(''); +const userRole = ref(null); + +const fieldErrors = reactive({ + full_name: '', + nickname: '', + phone: '' +}); + +function clearErr(field) { fieldErrors[field] = ''; } +function markDirty() { + if (silentApplying.value) return; + dirty.value = true; +} + +const form = reactive({ + full_name: '', + nickname: '', + work_description: '', + work_description_other: '', + avatar_url: '', + bio: '', + phone: '', + site_url: '', + social_instagram: '', + social_youtube: '', + social_facebook: '', + social_x: '' +}); + +const customSocials = ref([]); + +function addCustomSocial() { + customSocials.value.push({ name: '', url: '' }); + markDirty(); +} +function removeCustomSocial(idx) { + customSocials.value.splice(idx, 1); + markDirty(); +} + +const ui = reactive({ + avatarPreview: '', + avatarFile: null, + avatarFilePreviewUrl: '' +}); + +const fileInput = ref(null); + +const workDescriptionOptions = [ + { label: 'Psicólogo(a) Clínico(a)', value: 'psicologo_clinico' }, + { label: 'Psicanalista', value: 'psicanalista' }, + { label: 'Psiquiatra', value: 'psiquiatra' }, + { label: 'Psicoterapeuta', value: 'psicoterapeuta' }, + { label: 'Neuropsicólogo(a)', value: 'neuropsicologo' }, + { label: 'Psicólogo(a) Organizacional', value: 'psicologo_organizacional' }, + { label: 'Psicólogo(a) Escolar / Educacional', value: 'psicologo_escolar' }, + { label: 'Psicólogo(a) Hospitalar', value: 'psicologo_hospitalar' }, + { label: 'Psicólogo(a) Jurídico(a)', value: 'psicologo_juridico' }, + { label: 'Coach / Mentor(a)', value: 'coach_mentor' }, + { label: 'Terapeuta Holístico(a)', value: 'terapeuta_holistico' }, + { label: 'Outro', value: 'outro' } +]; + +// ── Iniciais (avatar fallback) ────────────────────────────── +const initials = computed(() => { + const name = form.full_name || userEmail.value || ''; + const parts = String(name).trim().split(/\s+/).filter(Boolean); + const a = parts[0]?.[0] || 'U'; + const b = parts.length > 1 ? parts[parts.length - 1][0] : ''; + return (a + b).toUpperCase(); +}); + +// ── Gamificação / Progresso ──────────────────────────────── +const profileFields = computed(() => [ + { key: 'full_name', filled: !!form.full_name?.trim(), icon: 'pi pi-user', text: 'Preencha seu nome completo' }, + { key: 'nickname', filled: !!form.nickname?.trim(), icon: 'pi pi-comment', text: 'Escolha um nome de exibição' }, + { key: 'work_description', filled: !!form.work_description?.trim(), icon: 'pi pi-briefcase', text: 'Descreva seu trabalho' }, + { key: 'avatar', filled: !!(form.avatar_url?.trim() || ui.avatarFile), icon: 'pi pi-image', text: 'Adicione uma foto' }, + { key: 'bio', filled: !!form.bio?.trim(), icon: 'pi pi-pencil', text: 'Complete sua bio' }, + { key: 'phone', filled: !!form.phone?.trim()?.replace(/[^0-9]/g, ''), icon: 'pi pi-whatsapp', text: 'Informe seu WhatsApp' }, + { key: 'social', filled: !!(form.social_instagram || form.social_x || form.site_url || form.social_youtube || form.social_facebook || customSocials.value.length), icon: 'pi pi-share-alt', text: 'Adicione uma rede social' } +]); + +const profileProgress = computed(() => { + const filled = profileFields.value.filter((f) => f.filled).length; + return Math.round((filled / profileFields.value.length) * 100); +}); + +const progressSuggestions = computed(() => profileFields.value.filter((f) => !f.filled)); + +const progressColor = computed(() => { + if (profileProgress.value >= 80) return '#10b981'; + if (profileProgress.value >= 50) return '#f59e0b'; + return '#ef4444'; +}); + +const gameLevels = [ + { min: 0, max: 14, label: 'Iniciante', icon: '🌱', color: '#94a3b8' }, + { min: 15, max: 28, label: 'Aprendiz', icon: '🌿', color: '#60a5fa' }, + { min: 29, max: 42, label: 'Praticante', icon: '⚡', color: '#f59e0b' }, + { min: 43, max: 57, label: 'Avançado', icon: '🔥', color: '#f97316' }, + { min: 58, max: 71, label: 'Expert', icon: '💎', color: '#a78bfa' }, + { min: 72, max: 85, label: 'Mestre', icon: '🏆', color: '#10b981' }, + { min: 86, max: 100, label: 'Lendário', icon: '🌟', color: '#eab308' } +]; + +const currentLevel = computed(() => + gameLevels.find((l) => profileProgress.value >= l.min && profileProgress.value <= l.max) || gameLevels[0] +); +const nextLevel = computed(() => { + const i = gameLevels.indexOf(currentLevel.value); + return i < gameLevels.length - 1 ? gameLevels[i + 1] : null; +}); +const xpToNext = computed(() => (nextLevel.value ? nextLevel.value.min - profileProgress.value : 0)); + +const badges = computed(() => [ + { key: 'name', earned: !!form.full_name?.trim(), icon: '👤', label: 'Identificado' }, + { key: 'nick', earned: !!form.nickname?.trim(), icon: '✏️', label: 'Apelido' }, + { key: 'work', earned: !!form.work_description?.trim(), icon: '💼', label: 'Profissional' }, + { key: 'photo', earned: !!(form.avatar_url?.trim() || ui.avatarFile), icon: '📷', label: 'Fotogênico' }, + { key: 'bio', earned: !!form.bio?.trim(), icon: '📝', label: 'Eloquente' }, + { key: 'phone', earned: !!form.phone?.trim()?.replace(/[^0-9]/g, ''), icon: '📱', label: 'Conectado' }, + { key: 'social', earned: !!(form.social_instagram || form.social_x || form.site_url || form.social_youtube || form.social_facebook || customSocials.value.length), icon: '🌐', label: 'Social' } +]); + +// ── Validação ────────────────────────────────────────────── +function validateRequired() { + const nameParts = form.full_name?.trim().split(/\s+/).filter(Boolean) || []; + fieldErrors.full_name = + nameParts.length === 0 ? 'Nome completo é obrigatório.' + : nameParts.length < 2 ? 'Informe seu nome e sobrenome.' + : ''; + fieldErrors.nickname = form.nickname?.trim() ? '' : 'Nome de exibição é obrigatório.'; + const digits = form.phone?.replace(/[^0-9]/g, '') || ''; + fieldErrors.phone = digits.length >= 10 ? '' : 'WhatsApp é obrigatório.'; + return !fieldErrors.full_name && !fieldErrors.nickname && !fieldErrors.phone; +} + +// ── Avatar ───────────────────────────────────────────────── +function pickAvatar() { fileInput.value?.click(); } + +function clearAvatarFile() { + ui.avatarFile = null; + if (ui.avatarFilePreviewUrl) { + try { URL.revokeObjectURL(ui.avatarFilePreviewUrl); } catch { /* ignore */ } + } + ui.avatarFilePreviewUrl = ''; + if (fileInput.value) fileInput.value.value = ''; +} + +function onAvatarFileSelected(ev) { + const file = ev?.target?.files?.[0]; + if (!file) return; + if (!file.type?.startsWith('image/')) { + toast.add({ severity: 'warn', summary: 'Arquivo inválido', detail: 'Escolha uma imagem (PNG/JPG/WebP).', life: 3500 }); + clearAvatarFile(); + return; + } + if (file.size > 5 * 1024 * 1024) { + toast.add({ severity: 'warn', summary: 'Arquivo grande', detail: 'Máximo recomendado: 5MB.', life: 3500 }); + clearAvatarFile(); + return; + } + ui.avatarFile = file; + if (ui.avatarFilePreviewUrl) { + try { URL.revokeObjectURL(ui.avatarFilePreviewUrl); } catch { /* ignore */ } + } + ui.avatarFilePreviewUrl = URL.createObjectURL(file); + ui.avatarPreview = ui.avatarFilePreviewUrl; + markDirty(); +} + +function removeAvatar() { + form.avatar_url = ''; + ui.avatarPreview = ''; + clearAvatarFile(); + markDirty(); +} + +function extFromMime(mime) { + if (!mime) return 'png'; + if (mime.includes('jpeg')) return 'jpg'; + if (mime.includes('png')) return 'png'; + if (mime.includes('webp')) return 'webp'; + return 'png'; +} + +async function uploadAvatarIfNeeded() { + if (!ui.avatarFile) return null; + if (!userId.value) throw new Error('Sessão inválida para upload.'); + const file = ui.avatarFile; + const ext = extFromMime(file.type); + const path = `${userId.value}/avatar-${Date.now()}.${ext}`; + const { error: upErr } = await supabase.storage + .from(AVATAR_BUCKET) + .upload(path, file, { upsert: true, contentType: file.type }); + if (upErr) throw upErr; + const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path); + const url = data?.publicUrl; + if (!url) throw new Error('Upload ok, mas não consegui obter a URL pública.'); + return url; +} + +// ── Carregar / Salvar ────────────────────────────────────── +async function ensureProfileExists(uid) { + const { data: prof, error: selErr } = await supabase + .from('profiles').select('id, role').eq('id', uid).maybeSingle(); + if (selErr) throw selErr; + if (prof?.id) return prof; + const { data: created, error: insErr } = await supabase + .from('profiles').insert({ id: uid, role: 'portal_user' }).select('id, role').single(); + if (insErr) throw insErr; + return created; +} + +async function loadProfile() { + silentApplying.value = true; + loading.value = true; + try { + const { data: u, error: uErr } = await supabase.auth.getUser(); + if (uErr) throw uErr; + const user = u?.user; + if (!user) throw new Error('Você precisa estar logado.'); + + userId.value = user.id; + userEmail.value = user.email || ''; + + await ensureProfileExists(user.id); + + const meta = user.user_metadata || {}; + form.full_name = meta.full_name || ''; + form.avatar_url = meta.avatar_url || ''; + ui.avatarPreview = form.avatar_url; + + const { data: prof, error: pErr } = await supabase + .from('profiles') + .select( + 'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom' + ) + .eq('id', user.id) + .maybeSingle(); + + if (!pErr && prof) { + userRole.value = prof.role || null; + form.full_name = prof.full_name ?? form.full_name; + form.avatar_url = prof.avatar_url ?? form.avatar_url; + form.phone = prof.phone ?? ''; + form.bio = prof.bio ?? ''; + form.nickname = prof.nickname ?? ''; + form.work_description = prof.work_description ?? ''; + form.work_description_other = prof.work_description_other ?? ''; + form.site_url = prof.site_url ?? ''; + form.social_instagram = prof.social_instagram ?? ''; + form.social_youtube = prof.social_youtube ?? ''; + form.social_facebook = prof.social_facebook ?? ''; + form.social_x = prof.social_x ?? ''; + customSocials.value = Array.isArray(prof.social_custom) ? prof.social_custom : []; + ui.avatarPreview = form.avatar_url; + } + } catch (e) { + toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não consegui carregar o perfil.', life: 6000 }); + } finally { + loading.value = false; + silentApplying.value = false; + dirty.value = false; + } +} + +async function saveAll() { + if (!validateRequired()) { + toast.add({ + severity: 'warn', + summary: 'Campos obrigatórios', + detail: 'Preencha nome completo, nome de exibição e WhatsApp antes de salvar.', + life: 4000 + }); + return; + } + saving.value = true; + try { + if (ui.avatarFile) { + try { + const uploadedUrl = await uploadAvatarIfNeeded(); + if (uploadedUrl) { + form.avatar_url = uploadedUrl; + ui.avatarPreview = uploadedUrl; + } + } catch (e) { + toast.add({ + severity: 'warn', + summary: 'Avatar não subiu', + detail: `Não consegui enviar o arquivo (bucket "${AVATAR_BUCKET}"). (${e?.message || 'erro'})`, + life: 6500 + }); + } + } + + const metaPayload = { + full_name: String(form.full_name || '').trim(), + avatar_url: String(form.avatar_url || '').trim() || null + }; + const { error: upErr } = await supabase.auth.updateUser({ data: metaPayload }); + if (upErr) throw upErr; + + await ensureProfileExists(userId.value); + + const profilePayload = { + full_name: metaPayload.full_name, + avatar_url: metaPayload.avatar_url, + phone: String(form.phone || '').trim() || null, + bio: String(form.bio || '').trim() || null, + nickname: String(form.nickname || '').trim() || null, + work_description: String(form.work_description || '').trim() || null, + work_description_other: + form.work_description === 'outro' + ? String(form.work_description_other || '').trim() || null + : null, + site_url: String(form.site_url || '').trim() || null, + social_instagram: String(form.social_instagram || '').trim() || null, + social_youtube: String(form.social_youtube || '').trim() || null, + social_facebook: String(form.social_facebook || '').trim() || null, + social_x: String(form.social_x || '').trim() || null, + social_custom: customSocials.value.filter((s) => s.name || s.url) + }; + + const { data: updatedProfile, error: pErr2 } = await supabase + .from('profiles') + .update(profilePayload) + .eq('id', userId.value) + .select('id') + .single(); + + if (pErr2) { + const msg = String(pErr2.message || ''); + const tolerant = /does not exist/i.test(msg) || /column .* does not exist/i.test(msg) + || /relation .* does not exist/i.test(msg) || /permission denied/i.test(msg) + || /violates row-level security/i.test(msg); + if (!tolerant) throw pErr2; + } + if (!updatedProfile) throw new Error('Perfil não encontrado para atualização.'); + + clearAvatarFile(); + dirty.value = false; + toast.add({ severity: 'success', summary: 'Salvo', detail: 'Perfil atualizado.', life: 2500 }); + } catch (e) { + toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || 'Não consegui salvar.', life: 6000 }); + } finally { + saving.value = false; + } +} + +// ── Sair da conta ────────────────────────────────────────── +function confirmSignOut() { + confirm.require({ + header: 'Sair da conta', + message: 'Tem certeza que deseja sair? Você precisará fazer login novamente.', + icon: 'pi pi-sign-out', + acceptLabel: 'Sair', + rejectLabel: 'Cancelar', + acceptClass: 'p-button-danger', + accept: async () => { + try { await supabase.auth.signOut(); } + finally { router.push('/auth/login'); } + } + }); +} + +// ── Lifecycle ────────────────────────────────────────────── +onMounted(async () => { + if (typeof window !== 'undefined' && window.matchMedia) { + _mqMobile = window.matchMedia('(max-width: 1023px)'); + isMobile.value = _mqMobile.matches; + try { _mqMobile.addEventListener('change', _onMqMobileChange); } + catch { _mqMobile.addListener(_onMqMobileChange); } + } + await tenantStore.ensureLoaded(); + await loadProfile(); +}); + +onBeforeUnmount(() => { + if (_mqMobile) { + try { _mqMobile.removeEventListener('change', _onMqMobileChange); } + catch { _mqMobile.removeListener(_onMqMobileChange); } + } + clearAvatarFile(); +}); + + +