Files
agenciapsilmno/src/views/pages/account/ProfilePage.vue
T

1539 lines
71 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/account/ProfilePage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<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 { applyThemeEngine } from '@/theme/theme.options';
import { useLayout as _useLayout } from '@/layout/composables/layout';
const { setVariant } = _useLayout();
import Textarea from 'primevue/textarea';
import InputMask from 'primevue/inputmask';
import Checkbox from 'primevue/checkbox';
import Select from 'primevue/select';
import { supabase } from '@/lib/supabase/client';
import { useLayout } from '@/layout/composables/layout';
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const mounted = ref(false);
/** 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
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: '',
language: 'pt-BR',
timezone: 'America/Sao_Paulo',
notify_system_email: true,
notify_reminders: true,
notify_news: false
});
const customSocials = ref([]);
function addCustomSocial() {
customSocials.value.push({ name: '', url: '' });
markDirty();
}
function removeCustomSocial(idx) {
customSocials.value.splice(idx, 1);
markDirty();
}
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' }
];
const sections = [
{ id: 'conta', label: 'Conta', icon: 'pi pi-user' },
{ id: 'redes-sociais', label: 'Sites e Redes', icon: 'pi pi-share-alt' },
{ id: 'avatar', label: 'Avatar', icon: 'pi pi-image' },
{ id: 'layout', label: 'Aparência', icon: 'pi pi-palette' },
{ id: 'preferencias', label: 'Preferências', icon: 'pi pi-sliders-h' },
{ id: 'layout-variant', label: 'Layout', icon: 'pi pi-th-large' },
{ 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;
}
/* ----------------------------
Cores e preset
----------------------------- */
const primaryColors = [
{ name: 'noir', palette: { 500: 'currentColor' } },
{ name: 'emerald', palette: { 500: '#10b981' } },
{ name: 'green', palette: { 500: '#22c55e' } },
{ name: 'lime', palette: { 500: '#84cc16' } },
{ name: 'orange', palette: { 500: '#f97316' } },
{ name: 'amber', palette: { 500: '#f59e0b' } },
{ name: 'yellow', palette: { 500: '#eab308' } },
{ name: 'teal', palette: { 500: '#14b8a6' } },
{ name: 'cyan', palette: { 500: '#06b6d4' } },
{ name: 'sky', palette: { 500: '#0ea5e9' } },
{ name: 'blue', palette: { 500: '#3b82f6' } },
{ name: 'indigo', palette: { 500: '#6366f1' } },
{ name: 'violet', palette: { 500: '#8b5cf6' } },
{ name: 'purple', palette: { 500: '#a855f7' } },
{ name: 'fuchsia', palette: { 500: '#d946ef' } },
{ name: 'pink', palette: { 500: '#ec4899' } },
{ name: 'rose', palette: { 500: '#f43f5e' } }
];
const surfaces = [
{ name: 'slate', palette: { 500: '#64748b' } },
{ name: 'gray', palette: { 500: '#6b7280' } },
{ name: 'zinc', palette: { 500: '#71717a' } },
{ name: 'neutral', palette: { 500: '#737373' } },
{ name: 'stone', palette: { 500: '#78716c' } }
];
const presetOptions = ['Aura', 'Lara', 'Nora'];
/* ----------------------------
Navegação (sidebar)
----------------------------- */
// ── Hero menu (mobile < 1200px) ──────────────────────────
const heroMenuRef = ref(null);
const heroEl = ref(null);
const heroSentinelRef = ref(null);
const heroStuck = ref(false);
const heroMenuItems = computed(() => [
{ label: 'Voltar', icon: 'pi pi-arrow-left', command: () => router.back() },
{ label: 'Salvar alterações', icon: 'pi pi-check', command: () => saveAll(), disabled: !dirty.value },
{ separator: true },
{
label: 'Menu desta sessão',
items: sections.map((s) => ({ label: s.label, icon: s.icon, command: () => scrollTo(s.id) }))
}
]);
let disconnectStickyObserver = null;
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, layoutState, toggleDarkMode, changeMenuMode } = useLayout();
function isDarkNow() {
return document.documentElement.classList.contains('app-dark');
}
function setDarkMode(shouldBeDark) {
if (shouldBeDark !== isDarkNow()) toggleDarkMode();
}
const presetModel = computed({
get: () => layoutConfig.preset,
set: (val) => {
if (!val || val === layoutConfig.preset) return;
layoutConfig.preset = val;
applyThemeEngine(layoutConfig);
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;
// Não chama changeMenuMode() no Rail — ela reseta estados do sidebar
if (layoutConfig.variant !== 'rail') {
try {
changeMenuMode?.(val);
} catch {
try {
changeMenuMode?.({ value: val });
} 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(layoutConfig);
if (!silentApplying.value) markDirty();
return;
}
if (type === 'surface') {
layoutConfig.surface = item.name;
applyThemeEngine(layoutConfig);
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, layout_variant').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;
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark');
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;
// Não chama changeMenuMode() — ela reseta staticMenuInactive e outros estados,
// fazendo a sidebar desaparecer ao entrar na página.
}
// Variant NÃO é re-aplicada aqui: bootstrapUserSettings cuida disso no arranque.
// Re-aplicar no loadUserSettings causava regressão (dado stale do banco sobrescrevia
// o variant ativo). A UI lê layoutConfig.variant diretamente para exibir a seleção.
applyThemeEngine(layoutConfig);
return settings;
}
/* ----------------------------
Load / Save (perfil)
----------------------------- */
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;
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(
'full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, 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.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 ?? '';
if (Array.isArray(prof.social_custom)) {
customSocials.value = prof.social_custom;
}
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;
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),
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
};
const { data: updatedProfile, error: pErr2 } = await supabase
.from('profiles')
.update(profilePayload)
.eq('id', userId.value)
.select(
'id, 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, language, timezone, notify_system_email, notify_reminders, notify_news, updated_at'
)
.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 (profiles).');
}
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',
layout_variant: layoutConfig.variant || 'rail',
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;
layoutState._variantDirty = 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
----------------------------- */
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();
mounted.value = true;
disconnectObserver = observeSections();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não consegui carregar o perfil.', life: 6000 });
}
// Sticky detection: sentinel sai da área visível (top 56px = topbar) → hero grudou
const sentinel = heroSentinelRef.value;
if (sentinel) {
const io = new IntersectionObserver(
([entry]) => {
heroStuck.value = !entry.isIntersecting;
},
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
);
io.observe(sentinel);
disconnectStickyObserver = () => io.disconnect();
}
});
onBeforeUnmount(() => {
try {
disconnectObserver?.();
} catch {}
try {
disconnectStickyObserver?.();
} catch {}
clearAvatarFile();
});
</script>
<template>
<!-- HERO -->
<div ref="heroSentinelRef" class="h-px" />
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
:class="{ 'rounded-tl-none rounded-tr-none': heroStuck }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-80 h-80 -top-20 -right-16 bg-indigo-500/[0.14]" />
<div class="absolute rounded-full blur-[70px] w-[22rem] h-[22rem] top-4 -left-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-64 h-64 -bottom-12 right-20 bg-fuchsia-500/[0.09]" />
</div>
<div class="relative z-10 flex items-center gap-5 flex-wrap">
<div class="flex items-center gap-4 flex-1 min-w-0">
<div class="relative shrink-0">
<img v-if="ui.avatarPreview" :src="ui.avatarPreview" class="w-16 h-16 rounded-[1.125rem] border-2 border-[var(--surface-border)] block object-cover" alt="avatar" />
<div v-else class="w-16 h-16 rounded-[1.125rem] border-2 border-[var(--surface-border)] grid place-items-center bg-[var(--surface-ground)] text-[1.3rem] font-extrabold text-[var(--text-color)]">{{ initials }}</div>
<span class="absolute bottom-[-3px] right-[-3px] w-[0.9rem] h-[0.9rem] rounded-full bg-emerald-400 border-[2.5px] border-[var(--surface-card)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] leading-snug truncate">{{ form.full_name || 'Meu Perfil' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5 truncate">{{ userEmail || 'Gerencie suas informações pessoais e segurança' }}</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" @click="router.back()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" :disabled="!dirty" @click="saveAll" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center gap-2 shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- GRID -->
<div class="px-3 md:px-4 pb-8 grid grid-cols-12 gap-4 md:gap-5">
<!-- Sidebar -->
<div class="col-span-12 lg:col-span-3">
<div class="sticky top-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 flex flex-col gap-3">
<!-- Mini user -->
<div class="flex items-center gap-3 pb-1">
<div class="relative shrink-0">
<img v-if="ui.avatarPreview" :src="ui.avatarPreview" class="w-10 h-10 rounded-xl border border-[var(--surface-border)] block object-cover" alt="avatar" />
<div v-else class="w-10 h-10 rounded-xl border border-[var(--surface-border)] grid place-items-center bg-[var(--surface-ground)] text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</div>
<span class="absolute bottom-[-2px] right-[-2px] w-2.5 h-2.5 rounded-full bg-emerald-400 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] truncate">{{ form.full_name || 'Conta' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate">{{ userEmail }}</div>
</div>
</div>
<!-- Nav -->
<nav class="flex flex-col gap-0.5">
<button
v-for="s in sections"
:key="s.id"
class="nav-link flex items-center gap-2.5 px-3 py-2 rounded-xl text-[1rem] font-medium cursor-pointer text-left w-full border border-transparent bg-transparent transition-all"
:class="{ 'nav-link--active': activeSection === s.id }"
@click="scrollTo(s.id)"
>
<i :class="[s.icon, 'text-[1rem] opacity-65 shrink-0']" />
<span>{{ s.label }}</span>
</button>
</nav>
<div class="h-px bg-[var(--surface-border)]" />
<!-- Quick actions -->
<div class="flex flex-col gap-0.5">
<button class="action-btn flex items-center gap-2.5 px-3 py-2 rounded-xl text-[1rem] font-medium border-0 bg-transparent cursor-pointer text-left w-full transition-all" @click="openPasswordDialog">
<i class="pi pi-key" /> <span>Trocar senha</span>
</button>
<button class="action-btn action-btn--danger flex items-center gap-2.5 px-3 py-2 rounded-xl text-[1rem] font-medium border-0 bg-transparent cursor-pointer text-left w-full transition-all" @click="confirmSignOut">
<i class="pi pi-sign-out" /> <span>Sair da conta</span>
</button>
</div>
</div>
</div>
<!-- Content -->
<div class="col-span-12 lg:col-span-9 flex flex-col gap-4 md:gap-5">
<!-- 01 CONTA -->
<div
id="conta"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #60a5fa; --c-dim: rgba(96, 165, 250, 0.08); --c-border: rgba(96, 165, 250, 0.2)"
>
<div class="pcard__num absolute top-3.5 right-5 text-[4.5rem] font-black tracking-[-0.06em] leading-none text-[var(--text-color)] opacity-[0.022] pointer-events-none select-none transition-opacity duration-[220ms]">01</div>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-user" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Conta</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Informações pessoais</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Seu nome, contato e descrição exibidos no app.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-12 gap-4">
<!-- Nome completo -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText id="prof_name" v-model="form.full_name" class="w-full" autocomplete="name" @input="markDirty" />
<label for="prof_name">Nome completo</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Aparece no menu, cabeçalhos e registros.</div>
</div>
<!-- Como a Agência PSI deveria te chamar? -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText id="prof_nickname" v-model="form.nickname" class="w-full" autocomplete="nickname" @input="markDirty" />
<label for="prof_nickname">Como a Agência PSI deveria te chamar?</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Apelido ou nome preferido para comunicação.</div>
</div>
<!-- O que melhor descreve seu trabalho? -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<Select id="prof_work_desc" v-model="form.work_description" :options="workDescriptionOptions" optionLabel="label" optionValue="value" class="w-full" @change="markDirty" />
<label for="prof_work_desc">O que melhor descreve seu trabalho?</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Exibido no seu perfil público.</div>
</div>
<!-- E-mail -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText id="prof_email" :modelValue="userEmail" class="w-full" disabled />
<label for="prof_email">E-mail</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Gerenciado pelo Supabase Auth.</div>
</div>
<!-- Informe seu trabalho (somente quando 'outro') -->
<Transition name="prof-slide">
<div v-if="form.work_description === 'outro'" class="col-span-12">
<FloatLabel variant="on">
<InputText id="prof_work_other" v-model="form.work_description_other" class="w-full" autocomplete="off" @input="markDirty" />
<label for="prof_work_other">Informe qual é o seu trabalho</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Descreva brevemente sua atuação profissional.</div>
</div>
</Transition>
<!-- Bio -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<Textarea id="prof_bio" v-model="form.bio" class="w-full" rows="5" maxlength="300" @input="markDirty" />
<label for="prof_bio">Bio</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)] flex justify-between">
<span>Breve descrição sobre você.</span>
<span>{{ (form.bio || '').length }}/300</span>
</div>
</div>
<!-- Whatsapp -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputMask id="prof_phone" v-model="form.phone" class="w-full" mask="(99) 99999-9999" :autoClear="false" @update:modelValue="markDirty" />
<label for="prof_phone">Whatsapp</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Opcional.</div>
</div>
</div>
</div>
<!-- 02 SITES E REDES SOCIAIS -->
<div
id="redes-sociais"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #e879f9; --c-dim: rgba(232, 121, 249, 0.08); --c-border: rgba(232, 121, 249, 0.2)"
>
<div class="pcard__num absolute top-3.5 right-5 text-[4.5rem] font-black tracking-[-0.06em] leading-none text-[var(--text-color)] opacity-[0.022] pointer-events-none select-none transition-opacity duration-[220ms]">02</div>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-share-alt" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Sites e Redes Sociais</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Seus Sites e Redes Sociais</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Links exibidos no seu perfil público.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-12 gap-4">
<!-- Site -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-globe" />
<InputText id="prof_site" v-model="form.site_url" class="w-full" type="url" @input="markDirty" />
</IconField>
<label for="prof_site">Endereço do site</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: https://seuperfil.com.br</div>
</div>
<!-- Instagram -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-instagram" />
<InputText id="prof_instagram" v-model="form.social_instagram" class="w-full" @input="markDirty" />
</IconField>
<label for="prof_instagram">Instagram</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: @seuperfil</div>
</div>
<!-- Youtube -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-youtube" />
<InputText id="prof_youtube" v-model="form.social_youtube" class="w-full" @input="markDirty" />
</IconField>
<label for="prof_youtube">YouTube</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: @seucanal</div>
</div>
<!-- Facebook -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-facebook" />
<InputText id="prof_facebook" v-model="form.social_facebook" class="w-full" @input="markDirty" />
</IconField>
<label for="prof_facebook">Facebook</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: /suapagina</div>
</div>
<!-- X -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-twitter" />
<InputText id="prof_x" v-model="form.social_x" class="w-full" @input="markDirty" />
</IconField>
<label for="prof_x">X (Twitter)</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: @seuuser</div>
</div>
</div>
<!-- Outras redes -->
<div class="mt-5">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Outras redes ou links</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Adicione qualquer outra rede social, podcast, link ou perfil.</div>
</div>
<Button icon="pi pi-plus" label="Adicionar" severity="secondary" size="small" outlined class="rounded-full" @click="addCustomSocial" />
</div>
<div v-if="customSocials.length" class="flex flex-col gap-3">
<div v-for="(item, idx) in customSocials" :key="idx" class="flex items-center gap-2">
<FloatLabel variant="on" class="flex-1">
<InputText :id="`prof_cs_name_${idx}`" v-model="item.name" class="w-full" @input="markDirty" />
<label :for="`prof_cs_name_${idx}`">Nome da rede</label>
</FloatLabel>
<FloatLabel variant="on" class="flex-[2]">
<IconField>
<InputIcon class="pi pi-link" />
<InputText :id="`prof_cs_url_${idx}`" v-model="item.url" class="w-full" @input="markDirty" />
</IconField>
<label :for="`prof_cs_url_${idx}`">URL / usuário</label>
</FloatLabel>
<Button icon="pi pi-trash" severity="danger" text rounded size="small" v-tooltip.top="'Remover'" @click="removeCustomSocial(idx)" />
</div>
</div>
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] italic mt-1">Nenhuma rede adicional cadastrada ainda.</div>
</div>
</div>
<!-- 03 AVATAR -->
<div
id="avatar"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #4ade80; --c-dim: rgba(74, 222, 128, 0.08); --c-border: rgba(74, 222, 128, 0.2)"
>
<div class="pcard__num absolute top-3.5 right-5 text-[4.5rem] font-black tracking-[-0.06em] leading-none text-[var(--text-color)] opacity-[0.022] pointer-events-none select-none transition-opacity duration-[220ms]">03</div>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-image" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Avatar</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Foto de perfil</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Envie um arquivo ou cole uma URL pública.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-12 gap-4 items-start">
<!-- Preview -->
<div class="col-span-12 md:col-span-3 flex flex-col items-center gap-3">
<div class="w-22 h-22 rounded-[1.375rem] border-2 border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center overflow-hidden" style="width: 5.5rem; height: 5.5rem">
<img v-if="ui.avatarPreview" :src="ui.avatarPreview" class="w-full h-full object-cover" alt="preview" />
<div v-else class="text-[1.75rem] font-extrabold text-[var(--text-color)]">{{ initials }}</div>
</div>
<div class="text-center">
<div class="text-[1rem] font-medium truncate max-w-[120px]">{{ form.full_name || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Preview</div>
</div>
<Button label="Remover" icon="pi pi-trash" severity="danger" outlined size="small" :disabled="!form.avatar_url && !ui.avatarFile && !ui.avatarPreview" @click="removeAvatar" />
</div>
<!-- Upload + URL -->
<div class="col-span-12 md:col-span-9 flex flex-col gap-4">
<!-- Upload arquivo -->
<div class="rounded-md border border-dashed border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 flex flex-col gap-2.5">
<div class="flex items-center gap-2 text-[1rem] font-semibold text-[var(--text-color-secondary)]">
<i class="pi pi-upload" />
<span>Arquivo (PNG · JPG · WebP · máx 5 MB)</span>
</div>
<div class="flex items-center gap-2 flex-wrap">
<input ref="fileInput" type="file" accept="image/*" class="text-[1rem] flex-1 text-[var(--text-color-secondary)]" @change="onAvatarFileSelected" />
<Button icon="pi pi-times" severity="secondary" outlined size="small" :disabled="!ui.avatarFile" @click="clearAvatarFile" v-tooltip.top="'Limpar'" />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Ao salvar, o arquivo é enviado ao bucket <b>{{ AVATAR_BUCKET }}</b> e a URL é atualizada automaticamente.
</div>
</div>
<!-- URL direta -->
<FloatLabel variant="on">
<InputText id="prof_avatar_url" v-model="form.avatar_url" class="w-full" @input="onAvatarUrlChange" />
<label for="prof_avatar_url">Avatar URL</label>
</FloatLabel>
<div class="-mt-2 text-[1rem] text-[var(--text-color-secondary)]">Cole uma URL pública de imagem. Se vazio, usamos suas iniciais.</div>
</div>
</div>
</div>
<!-- 04 APARÊNCIA -->
<div
id="layout"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #a78bfa; --c-dim: rgba(167, 139, 250, 0.08); --c-border: rgba(167, 139, 250, 0.2)"
>
<div class="pcard__num absolute top-3.5 right-5 text-[4.5rem] font-black tracking-[-0.06em] leading-none text-[var(--text-color)] opacity-[0.022] pointer-events-none select-none transition-opacity duration-[220ms]">04</div>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-palette" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Aparência</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Tema e visual</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Personalize cores, preset de componentes e modo do menu.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-12 gap-4">
<!-- Primary -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-start justify-between mb-3.5">
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Cor principal</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Tom dominante da interface.</div>
</div>
<i class="pi pi-palette opacity-40" />
</div>
<div class="flex gap-1.5 flex-wrap">
<button
v-for="pc of primaryColors"
:key="pc.name"
type="button"
:title="pc.name"
class="swatch w-[1.375rem] h-[1.375rem] rounded-full border-2 border-transparent cursor-pointer outline-none outline-offset-2 transition-transform duration-150"
:class="{ 'swatch--active': layoutConfig.primary === pc.name }"
:style="{ backgroundColor: pc.name === 'noir' ? 'var(--text-color)' : pc.palette['500'] }"
@click="updateColors('primary', pc)"
/>
</div>
</div>
</div>
<!-- Surface -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-start justify-between mb-3.5">
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Surface</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Base de fundo e superfícies.</div>
</div>
<i class="pi pi-circle-fill opacity-40" />
</div>
<div class="flex gap-1.5 flex-wrap">
<button
v-for="sf of surfaces"
:key="sf.name"
type="button"
:title="sf.name"
class="swatch w-[1.375rem] h-[1.375rem] rounded-full border-2 border-transparent cursor-pointer outline-none outline-offset-2 transition-transform duration-150"
:class="{
'swatch--active': layoutConfig.surface ? layoutConfig.surface === sf.name : isDarkNow() ? sf.name === 'zinc' : sf.name === 'slate'
}"
:style="{ backgroundColor: sf.palette['500'] }"
@click="updateColors('surface', sf)"
/>
</div>
</div>
</div>
<!-- Preset -->
<div class="col-span-12 md:col-span-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-start justify-between mb-3.5">
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Aura · Lara · Nora</div>
</div>
<i class="pi pi-sparkles opacity-40" />
</div>
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
</div>
</div>
<!-- Menu Mode: relevante no Layout Clássico -->
<div v-if="layoutConfig.variant === 'classic'" class="col-span-12 md:col-span-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-start justify-between mb-3.5">
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Menu</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Static ou Overlay</div>
</div>
<i class="pi pi-bars opacity-40" />
</div>
<SelectButton v-model="menuModeModel" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
</div>
</div>
<!-- Theme Mode -->
<div class="col-span-12 md:col-span-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-start justify-between mb-3.5">
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Tema</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Claro ou escuro</div>
</div>
<i class="pi pi-moon opacity-40" />
</div>
<SelectButton v-model="themeModeModel" :options="themeModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
</div>
</div>
</div>
</div>
<!-- 05 PREFERÊNCIAS -->
<div
id="preferencias"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #fb923c; --c-dim: rgba(251, 146, 60, 0.08); --c-border: rgba(251, 146, 60, 0.2)"
>
<div class="pcard__num absolute top-3.5 right-5 text-[4.5rem] font-black tracking-[-0.06em] leading-none text-[var(--text-color)] opacity-[0.022] pointer-events-none select-none transition-opacity duration-[220ms]">05</div>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-sliders-h" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Preferências</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Notificações</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Escolha quais comunicações deseja receber.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="flex flex-col gap-3">
<label class="toggle-row flex items-start gap-3.5 px-4 py-3.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] cursor-pointer transition-[border-color,background] duration-150">
<Checkbox v-model="form.notify_system_email" binary @change="markDirty" />
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">E-mails do sistema</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Confirmações, alertas importantes e faturas.</div>
</div>
</label>
<label class="toggle-row flex items-start gap-3.5 px-4 py-3.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] cursor-pointer transition-[border-color,background] duration-150">
<Checkbox v-model="form.notify_reminders" binary @change="markDirty" />
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Lembretes de sessão</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Notificações antes das consultas agendadas.</div>
</div>
</label>
<label class="toggle-row flex items-start gap-3.5 px-4 py-3.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] cursor-pointer transition-[border-color,background] duration-150">
<Checkbox v-model="form.notify_news" binary @change="markDirty" />
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Novidades da plataforma</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Updates, novas funcionalidades e comunicados.</div>
</div>
</label>
</div>
</div>
<!-- 06 LAYOUT -->
<div
id="layout-variant"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #22d3ee; --c-dim: rgba(34, 211, 238, 0.08); --c-border: rgba(34, 211, 238, 0.2)"
>
<div class="pcard__num absolute top-3.5 right-5 text-[4.5rem] font-black tracking-[-0.06em] leading-none text-[var(--text-color)] opacity-[0.022] pointer-events-none select-none transition-opacity duration-[220ms]">06</div>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-th-large" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Layout</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Estilo de navegação</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Escolha como a interface principal é organizada.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Layout 1: Clássico -->
<button
class="lv-card"
:class="{ 'lv-card--active': layoutConfig.variant === 'classic' }"
@click="
setVariant('classic');
markDirty();
"
>
<div class="lv-card__preview lv-card__preview--classic">
<div class="lv-p__sidebar" />
<div class="lv-p__main">
<div class="lv-p__bar" />
<div class="lv-p__line" />
<div class="lv-p__line lv-p__line--sm" />
</div>
</div>
<div class="lv-card__foot">
<div class="lv-card__radio">
<div v-if="layoutConfig.variant === 'classic'" class="lv-card__dot" />
</div>
<div>
<div class="lv-card__name">Clássico</div>
<div class="lv-card__sub">Sidebar lateral com menu completo</div>
</div>
</div>
</button>
<!-- Layout 2: Rail -->
<button
class="lv-card"
:class="{ 'lv-card--active': layoutConfig.variant === 'rail' }"
@click="
setVariant('rail');
markDirty();
"
>
<div class="lv-card__preview lv-card__preview--rail">
<div class="lv-p__rail" />
<div class="lv-p__panel" />
<div class="lv-p__main">
<div class="lv-p__bar" />
<div class="lv-p__line" />
<div class="lv-p__line lv-p__line--sm" />
</div>
</div>
<div class="lv-card__foot">
<div class="lv-card__radio">
<div v-if="layoutConfig.variant === 'rail'" class="lv-card__dot" />
</div>
<div>
<div class="lv-card__name">Rail</div>
<div class="lv-card__sub">Mini rail + painel expansível, full-width</div>
</div>
</div>
</button>
</div>
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
O layout Rail exibe ícones no canto esquerdo. Ao clicar em um ícone, o painel lateral expande com os itens de navegação. Disponível apenas no desktop.
</div>
</div>
</div>
<!-- 07 SEGURANÇA -->
<div
id="seguranca"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #f87171; --c-dim: rgba(248, 113, 113, 0.08); --c-border: rgba(248, 113, 113, 0.2)"
>
<div class="pcard__num absolute top-3.5 right-5 text-[4.5rem] font-black tracking-[-0.06em] leading-none text-[var(--text-color)] opacity-[0.022] pointer-events-none select-none transition-opacity duration-[220ms]">07</div>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-shield" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Segurança</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Acesso e senha</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Mantenha sua conta protegida com uma senha forte.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="flex flex-col sm:flex-row gap-3">
<Button label="Trocar senha de acesso" icon="pi pi-shield" severity="secondary" outlined @click="router.push('/account/security')" />
</div>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-3">
<LoadedPhraseBlock v-if="mounted" />
</div>
<!-- Dialog: Trocar senha -->
<Dialog v-model:visible="openPassword" modal header="Redefinir senha" :draggable="false" :style="{ width: '28rem', maxWidth: '92vw' }">
<div class="space-y-4">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Enviaremos um link de redefinição de senha para o seu e-mail.</div>
<FloatLabel variant="on">
<InputText id="dlg_email" :modelValue="userEmail" class="w-full" disabled />
<label for="dlg_email">E-mail</label>
</FloatLabel>
<div v-if="passwordSent" class="rounded-md border border-emerald-500/20 bg-emerald-500/5 px-4 py-3 flex items-center gap-2 text-[1rem] text-emerald-600 dark:text-emerald-400">
<i class="pi pi-check" />
Link enviado! Verifique sua caixa de entrada (e pasta de 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 />
</template>
<style scoped>
/* ─── Sidebar nav ───────────────────────────────────────── */
.nav-link {
color: var(--text-color-secondary);
}
.nav-link:hover,
.nav-link--active {
color: var(--text-color);
background: var(--surface-ground);
border-color: var(--surface-border);
}
/* ─── Sidebar actions ───────────────────────────────────── */
.action-btn {
color: var(--text-color-secondary);
}
.action-btn:hover {
color: var(--text-color);
background: var(--surface-ground);
}
.action-btn--danger:hover {
color: #f87171;
background: rgba(248, 113, 113, 0.06);
}
/* ─── Content cards ─────────────────────────────────────── */
.pcard {
animation: prof-fadeUp 0.4s ease both;
}
.pcard:hover {
border-color: var(--c-border);
}
.pcard:hover .pcard__shine {
opacity: 1;
}
.pcard:hover .pcard__num {
opacity: 0.055;
}
.pcard__shine {
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
background: radial-gradient(ellipse at 20% 0%, color-mix(in srgb, var(--c) 9%, transparent), transparent 52%);
}
/* ─── Swatches ──────────────────────────────────────────── */
.swatch:hover {
transform: scale(1.2);
}
.swatch--active {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
transform: scale(1.1);
}
/* ─── Toggle rows ───────────────────────────────────────── */
.toggle-row:hover {
border-color: color-mix(in srgb, var(--primary-color) 40%, transparent);
background: color-mix(in srgb, var(--primary-color) 3%, var(--surface-ground));
}
/* ─── Layout variant cards ──────────────────────────────── */
.lv-card {
position: relative;
display: flex;
flex-direction: column;
border-radius: 0.75rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
cursor: pointer;
text-align: left;
transition:
border-color 0.18s,
box-shadow 0.18s;
overflow: hidden;
}
.lv-card:hover {
border-color: color-mix(in srgb, var(--primary-color) 50%, transparent);
}
.lv-card--active {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.lv-card--active .lv-card__radio {
border-color: var(--primary-color);
}
.lv-card__preview {
height: 90px;
display: flex;
background: var(--surface-card);
border-bottom: 1px solid var(--surface-border);
overflow: hidden;
padding: 10px;
}
.lv-card__preview--classic {
gap: 6px;
}
.lv-card__preview--rail {
gap: 4px;
}
.lv-p__sidebar {
width: 38px;
border-radius: 5px;
background: var(--surface-border);
flex-shrink: 0;
}
.lv-p__rail {
width: 14px;
border-radius: 4px;
background: var(--surface-border);
flex-shrink: 0;
}
.lv-p__panel {
width: 28px;
border-radius: 4px;
background: color-mix(in srgb, var(--surface-border) 60%, transparent);
flex-shrink: 0;
}
.lv-p__main {
flex: 1;
display: flex;
flex-direction: column;
gap: 5px;
padding-top: 2px;
}
.lv-p__bar {
height: 10px;
border-radius: 4px;
background: color-mix(in srgb, var(--primary-color) 25%, transparent);
}
.lv-p__line {
height: 7px;
border-radius: 3px;
background: var(--surface-border);
}
.lv-p__line--sm {
width: 65%;
}
.lv-card__foot {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
}
.lv-card__radio {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--surface-border);
display: grid;
place-items: center;
flex-shrink: 0;
transition: border-color 0.15s;
}
.lv-card__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--primary-color);
}
.lv-card__name {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
}
.lv-card__sub {
font-size: 1rem;
color: var(--text-color-secondary);
margin-top: 1px;
}
/* ─── Animation ─────────────────────────────────────────── */
@keyframes prof-fadeUp {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ─── Transition "outro" ────────────────────────────────── */
.prof-slide-enter-active,
.prof-slide-leave-active {
transition:
opacity 0.2s ease,
max-height 0.25s ease,
margin 0.2s ease;
max-height: 6rem;
overflow: hidden;
}
.prof-slide-enter-from,
.prof-slide-leave-to {
opacity: 0;
max-height: 0;
margin: 0;
}
</style>