Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda
This commit is contained in:
954
src/views/pages/account/ProfilePage.vue
Normal file
954
src/views/pages/account/ProfilePage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user