Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda

This commit is contained in:
Leonardo
2026-02-22 17:56:01 -03:00
parent 6eff67bf22
commit 89b4ecaba1
77 changed files with 9433 additions and 1995 deletions

View File

@@ -0,0 +1,954 @@
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Header -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight">Meu Perfil</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Gerencie suas informações, preferências e segurança da conta.
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined @click="router.back()" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" :disabled="!dirty" @click="saveAll" />
</div>
</div>
</div>
</div>
<!-- Layout -->
<div class="grid grid-cols-12 gap-4 md:gap-6">
<!-- Sidebar -->
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
<div class="sticky top-4">
<Card class="overflow-hidden">
<template #content>
<div class="flex items-center gap-3">
<div class="relative">
<img
v-if="ui.avatarPreview"
:src="ui.avatarPreview"
class="h-12 w-12 rounded-2xl object-cover border border-[var(--surface-border)]"
alt="avatar"
/>
<div
v-else
class="h-12 w-12 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center font-semibold"
>
{{ initials }}
</div>
<span class="absolute -bottom-1 -right-1 h-4 w-4 rounded-full border border-[var(--surface-card)] bg-emerald-400/80" />
</div>
<div class="min-w-0">
<div class="font-semibold truncate">{{ form.full_name || userEmail || 'Conta' }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">
{{ userEmail || '—' }}
</div>
</div>
</div>
<div class="mt-4 space-y-1">
<button
v-for="s in sections"
:key="s.id"
type="button"
class="w-full text-left px-3 py-2 rounded-xl border border-transparent hover:border-[var(--surface-border)] hover:bg-[var(--surface-ground)] transition flex items-center gap-2"
:class="activeSection === s.id ? 'bg-[var(--surface-ground)] border-[var(--surface-border)]' : ''"
@click="scrollTo(s.id)"
>
<i :class="s.icon" class="text-sm opacity-80" />
<span class="text-sm font-medium">{{ s.label }}</span>
</button>
</div>
<Divider class="my-4" />
<div class="flex flex-col gap-2">
<Button label="Trocar senha" icon="pi pi-key" severity="secondary" outlined @click="openPasswordDialog" />
<Button label="Sair" icon="pi pi-sign-out" severity="danger" outlined @click="confirmSignOut" />
</div>
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">
Dica: alterações em <b>Nome</b> e <b>Avatar</b> atualizam sua identidade no app.
</div>
</template>
</Card>
</div>
</div>
<!-- Content -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9 space-y-4 md:space-y-6">
<!-- Conta -->
<Card id="conta" class="scroll-mt-24">
<template #title>Conta</template>
<template #subtitle>Informações básicas e identidade.</template>
<template #content>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Nome</label>
<InputText v-model="form.full_name" class="w-full" placeholder="Seu nome" @input="markDirty" />
<small class="text-[var(--text-color-secondary)]">Aparece no menu, cabeçalhos e registros.</small>
</div>
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">E-mail</label>
<InputText :modelValue="userEmail" class="w-full" disabled />
<small class="text-[var(--text-color-secondary)]">E-mail vem do login (Supabase Auth).</small>
</div>
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Bio</label>
<Textarea
v-model="form.bio"
class="w-full"
rows="5"
maxlength="2000"
placeholder="Uma breve descrição sobre você…"
@input="markDirty"
/>
<div class="mt-1 flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
<span>Máximo de 2000 caracteres.</span>
<span>{{ (form.bio || '').length }}/2000</span>
</div>
</div>
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">Telefone</label>
<InputMask
v-model="form.phone"
class="w-full"
mask="(99) 99999-9999"
:autoClear="false"
placeholder="(11) 99999-9999"
@update:modelValue="markDirty"
/>
<small class="text-[var(--text-color-secondary)]">Opcional.</small>
</div>
<div class="col-span-12">
<Divider />
<div class="grid grid-cols-12 gap-4 items-start">
<!-- Upload -->
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Enviar avatar (arquivo)</label>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3">
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<input
ref="fileInput"
type="file"
accept="image/*"
class="block w-full text-sm"
@change="onAvatarFileSelected"
/>
<div class="flex items-center gap-2">
<Button
label="Limpar"
icon="pi pi-times"
severity="secondary"
outlined
size="small"
:disabled="!ui.avatarFile"
@click="clearAvatarFile"
/>
<Button
label="Usar preview"
icon="pi pi-image"
severity="secondary"
outlined
size="small"
:disabled="!ui.avatarFile"
@click="applyFilePreviewOnly"
/>
</div>
</div>
<div class="mt-2 text-xs text-[var(--text-color-secondary)]">
Ao salvar, tentamos subir no Storage (<b>{{ AVATAR_BUCKET }}</b>) e atualizamos o Avatar URL automaticamente.
</div>
</div>
</div>
<!-- Avatar URL -->
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">Avatar (URL)</label>
<InputText v-model="form.avatar_url" class="w-full" placeholder="https://…" @input="onAvatarUrlChange" />
<small class="text-[var(--text-color-secondary)]">
Cole uma URL de imagem (PNG/JPG). Se vazio, usamos iniciais.
</small>
</div>
<!-- Preview -->
<div class="col-span-12">
<div class="mt-1 flex items-center gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3">
<img
v-if="ui.avatarPreview"
:src="ui.avatarPreview"
class="h-12 w-12 rounded-2xl object-cover border border-[var(--surface-border)]"
alt="preview"
/>
<div
v-else
class="h-12 w-12 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center font-semibold"
>
{{ initials }}
</div>
<div class="min-w-0">
<div class="font-medium truncate">{{ form.full_name || '—' }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">
Preview atual (será aplicado ao salvar)
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<Button
label="Remover avatar"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
:disabled="!form.avatar_url && !ui.avatarFile && !ui.avatarPreview"
@click="removeAvatar"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Aparência / Layout -->
<Card id="layout" class="scroll-mt-24">
<template #title>Aparência</template>
<template #subtitle>Tema, cores e modo do menu.</template>
<template #content>
<div class="grid grid-cols-12 gap-4">
<!-- Row 1 -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Primary</div>
<div class="text-xs text-[var(--text-color-secondary)]">Cor principal do tema.</div>
</div>
<i class="pi pi-palette opacity-70" />
</div>
<div class="pt-3 flex gap-2 flex-wrap">
<button
v-for="primaryColor of primaryColors"
:key="primaryColor.name"
type="button"
:title="primaryColor.name"
@click="updateColors('primary', primaryColor)"
:class="[
'border-none w-6 h-6 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.primary === primaryColor.name }
]"
:style="{ backgroundColor: `${primaryColor.name === 'noir' ? 'var(--text-color)' : primaryColor.palette['500']}` }"
></button>
</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Surface</div>
<div class="text-xs text-[var(--text-color-secondary)]">Base de fundo/superfícies.</div>
</div>
<i class="pi pi-circle-fill opacity-70" />
</div>
<div class="pt-3 flex gap-2 flex-wrap">
<button
v-for="surface of surfaces"
:key="surface.name"
type="button"
:title="surface.name"
@click="updateColors('surface', surface)"
:class="[
'border-none w-6 h-6 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{
'outline-primary': layoutConfig.surface
? layoutConfig.surface === surface.name
: (isDarkNow() ? surface.name === 'zinc' : surface.name === 'slate')
}
]"
:style="{ backgroundColor: `${surface.palette['500']}` }"
></button>
</div>
</div>
</div>
<!-- Row 2 -->
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Presets</div>
<div class="text-xs text-[var(--text-color-secondary)]">Aura / Lara / Nora.</div>
</div>
<i class="pi pi-sparkles opacity-70" />
</div>
<div class="pt-3">
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Menu Mode</div>
<div class="text-xs text-[var(--text-color-secondary)]">Static ou Overlay.</div>
</div>
<i class="pi pi-bars opacity-70" />
</div>
<div class="pt-3">
<SelectButton
v-model="menuModeModel"
:options="menuModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Tema</div>
<div class="text-xs text-[var(--text-color-secondary)]">Alternar claro/escuro.</div>
</div>
<i class="pi pi-moon opacity-70" />
</div>
<div class="pt-3">
<SelectButton
v-model="themeModeModel"
:options="themeModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
</div>
</div>
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
Essas opções aplicam o tema imediatamente (igual ao configurator).
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Dialog: Trocar senha -->
<Dialog
v-model:visible="openPassword"
modal
header="Trocar senha"
:draggable="false"
:style="{ width: '28rem', maxWidth: '92vw' }"
>
<div class="space-y-3">
<div class="text-sm text-[var(--text-color-secondary)]">
Vamos enviar um link de redefinição para seu e-mail.
</div>
<div class="space-y-2">
<label class="text-sm font-semibold">E-mail</label>
<InputText :modelValue="userEmail" class="w-full" disabled />
</div>
<div
v-if="passwordSent"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]"
>
<i class="pi pi-check mr-2 text-emerald-500"></i>
Se o e-mail existir, você receberá o link em instantes. Verifique também spam.
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="openPassword = false" />
<Button label="Enviar link" icon="pi pi-envelope" :loading="sendingPassword" @click="sendPasswordReset" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Toast from 'primevue/toast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Dialog from 'primevue/dialog'
import ConfirmDialog from 'primevue/confirmdialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import InputMask from 'primevue/inputmask'
import SelectButton from 'primevue/selectbutton'
import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout'
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'
import {
presetsMap,
presetOptions,
primaryColors,
surfaces,
getPresetExt,
getSurfacePalette
} from '@/theme/theme.options'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
/** trava para não marcar dirty durante o load */
const silentApplying = ref(true)
/** Storage bucket do avatar */
const AVATAR_BUCKET = 'avatars'
/* ----------------------------
Estado geral
----------------------------- */
const saving = ref(false)
const dirty = ref(false)
const openPassword = ref(false)
const sendingPassword = ref(false)
const passwordSent = ref(false)
const userEmail = ref('')
const userId = ref('')
const fileInput = ref(null)
const ui = reactive({
avatarPreview: '',
avatarFile: null,
avatarFilePreviewUrl: ''
})
// Perfil (MVP)
const form = reactive({
full_name: '',
avatar_url: '',
bio: '',
phone: '',
language: 'pt-BR',
timezone: 'America/Sao_Paulo',
notify_system_email: true,
notify_reminders: true,
notify_news: false
})
const sections = [
{ id: 'conta', label: 'Conta', icon: 'pi pi-user' },
{ id: 'layout', label: 'Aparência', icon: 'pi pi-palette' },
{ id: 'preferencias', label: 'Preferências', icon: 'pi pi-sliders-h' },
{ id: 'seguranca', label: 'Segurança', icon: 'pi pi-shield' }
]
const activeSection = ref('conta')
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()
})
function markDirty () {
dirty.value = true
}
/* ----------------------------
Navegação (sidebar)
----------------------------- */
function scrollTo (id) {
activeSection.value = id
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function observeSections () {
const ids = sections.map(s => s.id)
const els = ids.map(id => document.getElementById(id)).filter(Boolean)
const io = new IntersectionObserver(
entries => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => (b.intersectionRatio || 0) - (a.intersectionRatio || 0))[0]
if (visible?.target?.id) activeSection.value = visible.target.id
},
{ root: null, threshold: [0.2, 0.35, 0.5], rootMargin: '-15% 0px -70% 0px' }
)
els.forEach(el => io.observe(el))
return () => io.disconnect()
}
let disconnectObserver = null
/* ----------------------------
Avatar: URL
----------------------------- */
function onAvatarUrlChange () {
ui.avatarPreview = String(form.avatar_url || '').trim()
markDirty()
}
function removeAvatar () {
form.avatar_url = ''
ui.avatarPreview = ''
clearAvatarFile()
markDirty()
}
/* ----------------------------
Avatar: upload arquivo
----------------------------- */
function clearAvatarFile () {
ui.avatarFile = null
if (ui.avatarFilePreviewUrl) {
try { URL.revokeObjectURL(ui.avatarFilePreviewUrl) } catch {}
}
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 {}
}
ui.avatarFilePreviewUrl = URL.createObjectURL(file)
ui.avatarPreview = ui.avatarFilePreviewUrl
markDirty()
}
function applyFilePreviewOnly () {
if (!ui.avatarFilePreviewUrl) return
ui.avatarPreview = ui.avatarFilePreviewUrl
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
}
/* ----------------------------
Aparência (SEM duplicar engine)
----------------------------- */
const { layoutConfig, toggleDarkMode, changeMenuMode } = useLayout()
function isDarkNow () {
return document.documentElement.classList.contains('app-dark')
}
function setDarkMode (shouldBeDark) {
if (shouldBeDark !== isDarkNow()) toggleDarkMode()
}
/** ✅ motor único (igual topbar) */
function applyThemeEngine () {
const presetValue = presetsMap?.[layoutConfig.preset] || presetsMap?.Aura
const surfacePalette = getSurfacePalette(layoutConfig.surface) || surfaces.find(s => s.name === layoutConfig.surface)?.palette
$t()
.preset(presetValue)
.preset(getPresetExt(layoutConfig))
.surfacePalette(surfacePalette)
.use({ useDefaultOptions: true })
updatePreset(getPresetExt(layoutConfig))
if (surfacePalette) updateSurfacePalette(surfacePalette)
}
/** v-models diretos no layoutConfig */
const presetModel = computed({
get: () => layoutConfig.preset,
set: (val) => {
if (!val || val === layoutConfig.preset) return
layoutConfig.preset = val
applyThemeEngine()
if (!silentApplying.value) markDirty()
}
})
const menuModeOptions = [
{ label: 'Static', value: 'static' },
{ label: 'Overlay', value: 'overlay' }
]
const menuModeModel = computed({
get: () => layoutConfig.menuMode,
set: (val) => {
if (!val || val === layoutConfig.menuMode) return
layoutConfig.menuMode = val
try { changeMenuMode?.() } catch {}
if (!silentApplying.value) markDirty()
}
})
const themeModeOptions = [
{ label: 'Claro', value: 'light' },
{ label: 'Escuro', value: 'dark' }
]
const themeModeModel = computed({
get: () => (isDarkNow() ? 'dark' : 'light'),
set: async (val) => {
if (!val) return
setDarkMode(val === 'dark')
await nextTick()
if (!silentApplying.value) markDirty()
}
})
function updateColors (type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name
applyThemeEngine()
if (!silentApplying.value) markDirty()
return
}
if (type === 'surface') {
layoutConfig.surface = item.name
applyThemeEngine()
if (!silentApplying.value) markDirty()
}
}
/* ----------------------------
DB: carregar/aplicar settings
----------------------------- */
function safeEq (a, b) {
return String(a || '').trim() === String(b || '').trim()
}
async function loadUserSettings (uid) {
const { data: settings, error } = await supabase
.from('user_settings')
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
.eq('user_id', uid)
.maybeSingle()
if (error) {
const msg = String(error.message || '')
const tolerant =
/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 error
return null
}
if (!settings) return null
// 1) dark/light
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
// 2) layoutConfig
if (settings.preset && !safeEq(settings.preset, layoutConfig.preset)) layoutConfig.preset = settings.preset
if (settings.primary_color && !safeEq(settings.primary_color, layoutConfig.primary)) layoutConfig.primary = settings.primary_color
if (settings.surface_color && !safeEq(settings.surface_color, layoutConfig.surface)) layoutConfig.surface = settings.surface_color
if (settings.menu_mode && !safeEq(settings.menu_mode, layoutConfig.menuMode)) {
layoutConfig.menuMode = settings.menu_mode
try { changeMenuMode?.() } catch {}
}
// 3) aplica UMA vez
applyThemeEngine()
return settings
}
/* ----------------------------
Load / Save (perfil)
----------------------------- */
async function loadProfile () {
silentApplying.value = true
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 || ''
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('full_name, avatar_url, phone, bio, language, timezone, notify_system_email, notify_reminders, notify_news')
.eq('id', user.id)
.maybeSingle()
if (!pErr && prof) {
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.language = prof.language ?? form.language
form.timezone = prof.timezone ?? form.timezone
if (typeof prof.notify_system_email === 'boolean') form.notify_system_email = prof.notify_system_email
if (typeof prof.notify_reminders === 'boolean') form.notify_reminders = prof.notify_reminders
if (typeof prof.notify_news === 'boolean') form.notify_news = prof.notify_news
ui.avatarPreview = form.avatar_url
}
await loadUserSettings(user.id)
silentApplying.value = false
dirty.value = false
}
async function saveAll () {
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}"). Você pode usar Avatar URL. (${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
const profilePayload = {
id: userId.value,
full_name: metaPayload.full_name,
avatar_url: metaPayload.avatar_url,
phone: String(form.phone || '').trim() || null,
bio: String(form.bio || '').trim() || null,
language: form.language || 'pt-BR',
timezone: form.timezone || 'America/Sao_Paulo',
notify_system_email: !!form.notify_system_email,
notify_reminders: !!form.notify_reminders,
notify_news: !!form.notify_news,
updated_at: new Date().toISOString()
}
const { error: pErr2 } = await supabase
.from('profiles')
.upsert(profilePayload, { onConflict: 'id' })
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
}
const settingsPayload = {
user_id: userId.value,
theme_mode: isDarkNow() ? 'dark' : 'light',
preset: layoutConfig.preset || 'Aura',
primary_color: layoutConfig.primary || 'noir',
surface_color: layoutConfig.surface || 'slate',
menu_mode: layoutConfig.menuMode || 'static',
updated_at: new Date().toISOString()
}
const { error: sErr } = await supabase
.from('user_settings')
.upsert(settingsPayload, { onConflict: 'user_id' })
if (sErr) {
const msg = String(sErr.message || '')
const tolerant =
/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 sErr
}
clearAvatarFile()
dirty.value = false
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Seu perfil foi 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
}
}
/* ----------------------------
Segurança: reset + signout
----------------------------- */
function openPasswordDialog () {
passwordSent.value = false
openPassword.value = true
}
async function sendPasswordReset () {
if (!userEmail.value) return
sendingPassword.value = true
passwordSent.value = false
try {
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(userEmail.value, { redirectTo })
if (error) throw error
passwordSent.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 5000 })
} finally {
sendingPassword.value = false
}
}
function confirmSignOut () {
confirm.require({
header: 'Sair',
message: 'Deseja sair da sua conta neste dispositivo?',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sair',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await supabase.auth.signOut()
} finally {
router.push('/auth/login')
}
}
})
}
/* ----------------------------
Lifecycle
----------------------------- */
onMounted(async () => {
try {
await loadProfile()
disconnectObserver = observeSections()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não consegui carregar o perfil.', life: 6000 })
}
})
onBeforeUnmount(() => {
try { disconnectObserver?.() } catch {}
clearAvatarFile()
})
</script>