1748 lines
82 KiB
Vue
1748 lines
82 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 { useConfirm } from 'primevue/useconfirm';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
|
|
import { useLayout as _useLayout } from '@/layout/composables/layout';
|
|
import { applyThemeEngine } from '@/theme/theme.options';
|
|
const { setVariant } = _useLayout();
|
|
|
|
import Checkbox from 'primevue/checkbox';
|
|
import InputMask from 'primevue/inputmask';
|
|
import Select from 'primevue/select';
|
|
import Textarea from 'primevue/textarea';
|
|
|
|
import { useLayout } from '@/layout/composables/layout';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
|
|
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 fieldErrors = reactive({
|
|
full_name: '',
|
|
nickname: '',
|
|
phone: ''
|
|
});
|
|
|
|
function clearErr(field) {
|
|
fieldErrors[field] = '';
|
|
}
|
|
|
|
function validateRequired() {
|
|
const nameParts = form.full_name?.trim().split(/\s+/).filter(Boolean) || [];
|
|
fieldErrors.full_name = nameParts.length === 0 ? 'Nome completo é obrigatório.' : nameParts.length < 2 ? 'Informe seu nome e sobrenome.' : '';
|
|
fieldErrors.nickname = form.nickname?.trim() ? '' : 'Nome de exibição é obrigatório.';
|
|
const digits = form.phone?.replace(/[^0-9]/g, '') || '';
|
|
fieldErrors.phone = digits.length >= 10 ? '' : 'WhatsApp é obrigatório.';
|
|
return !fieldErrors.full_name && !fieldErrors.nickname && !fieldErrors.phone;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/* ----------------------------
|
|
Gamificação / Progresso
|
|
----------------------------- */
|
|
const profileFields = computed(() => [
|
|
{ key: 'full_name', filled: !!form.full_name?.trim(), icon: 'pi pi-user', text: 'Preencha seu nome completo' },
|
|
{ key: 'nickname', filled: !!form.nickname?.trim(), icon: 'pi pi-comment', text: 'Escolha um nome de exibição' },
|
|
{ key: 'work_description', filled: !!form.work_description?.trim(), icon: 'pi pi-briefcase', text: 'Descreva seu trabalho' },
|
|
{ key: 'avatar', filled: !!(form.avatar_url?.trim() || ui.avatarFile), icon: 'pi pi-image', text: 'Adicione uma foto' },
|
|
{ key: 'bio', filled: !!form.bio?.trim(), icon: 'pi pi-pencil', text: 'Complete sua bio' },
|
|
{ key: 'phone', filled: !!form.phone?.trim()?.replace(/[^0-9]/g, ''), icon: 'pi pi-whatsapp', text: 'Informe seu WhatsApp' },
|
|
{ key: 'social', filled: !!(form.social_instagram || form.social_x || form.site_url || form.social_youtube || form.social_facebook || customSocials.value.length), icon: 'pi pi-share-alt', text: 'Adicione uma rede social' }
|
|
]);
|
|
|
|
const profileProgress = computed(() => {
|
|
const filled = profileFields.value.filter((f) => f.filled).length;
|
|
return Math.round((filled / profileFields.value.length) * 100);
|
|
});
|
|
|
|
const progressSuggestions = computed(() => profileFields.value.filter((f) => !f.filled));
|
|
|
|
const progressColor = computed(() => {
|
|
if (profileProgress.value >= 80) return '#10b981';
|
|
if (profileProgress.value >= 50) return '#f59e0b';
|
|
return '#ef4444';
|
|
});
|
|
|
|
const gameLevels = [
|
|
{ min: 0, max: 14, label: 'Iniciante', icon: '🌱', color: '#94a3b8' },
|
|
{ min: 15, max: 28, label: 'Aprendiz', icon: '🌿', color: '#60a5fa' },
|
|
{ min: 29, max: 42, label: 'Praticante', icon: '⚡', color: '#f59e0b' },
|
|
{ min: 43, max: 57, label: 'Avançado', icon: '🔥', color: '#f97316' },
|
|
{ min: 58, max: 71, label: 'Expert', icon: '💎', color: '#a78bfa' },
|
|
{ min: 72, max: 85, label: 'Mestre', icon: '🏆', color: '#10b981' },
|
|
{ min: 86, max: 100, label: 'Lendário', icon: '🌟', color: '#eab308' }
|
|
];
|
|
|
|
const currentLevel = computed(() => gameLevels.find((l) => profileProgress.value >= l.min && profileProgress.value <= l.max) || gameLevels[0]);
|
|
const nextLevel = computed(() => {
|
|
const i = gameLevels.indexOf(currentLevel.value);
|
|
return i < gameLevels.length - 1 ? gameLevels[i + 1] : null;
|
|
});
|
|
const xpToNext = computed(() => (nextLevel.value ? nextLevel.value.min - profileProgress.value : 0));
|
|
|
|
const badges = computed(() => [
|
|
{ key: 'name', earned: !!form.full_name?.trim(), icon: '👤', label: 'Identificado' },
|
|
{ key: 'nick', earned: !!form.nickname?.trim(), icon: '✏️', label: 'Apelido' },
|
|
{ key: 'work', earned: !!form.work_description?.trim(), icon: '💼', label: 'Profissional' },
|
|
{ key: 'photo', earned: !!(form.avatar_url?.trim() || ui.avatarFile), icon: '📷', label: 'Fotogênico' },
|
|
{ key: 'bio', earned: !!form.bio?.trim(), icon: '📝', label: 'Eloquente' },
|
|
{ key: 'phone', earned: !!form.phone?.trim()?.replace(/[^0-9]/g, ''), icon: '📱', label: 'Conectado' },
|
|
{ key: 'social', earned: !!(form.social_instagram || form.social_x || form.site_url || form.social_youtube || form.social_facebook || customSocials.value.length), icon: '🌐', label: 'Social' }
|
|
]);
|
|
|
|
/* ----------------------------
|
|
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() {
|
|
if (!validateRequired()) {
|
|
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Preencha nome completo, nome de exibição e WhatsApp antes de salvar.', life: 4000 });
|
|
// Rola até a seção Conta para o usuário ver os erros
|
|
document.getElementById('conta')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
try {
|
|
if (ui.avatarFile) {
|
|
try {
|
|
const uploadedUrl = await uploadAvatarIfNeeded();
|
|
if (uploadedUrl) {
|
|
form.avatar_url = uploadedUrl;
|
|
ui.avatarPreview = uploadedUrl;
|
|
}
|
|
} catch (e) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Avatar não subiu',
|
|
detail: `Não consegui enviar o arquivo (bucket "${AVATAR_BUCKET}"). 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="p-2" />
|
|
<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, 55px)' }"
|
|
: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>
|
|
|
|
<!-- ── PROGRESSO / GAMIFICAÇÃO ──────────────────────────── -->
|
|
<div class="px-3 md:px-4 mb-4">
|
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 space-y-4">
|
|
<!-- Nível + barra -->
|
|
<div class="flex items-center gap-4">
|
|
<div class="shrink-0 w-12 h-12 rounded-xl flex items-center justify-center text-2xl border" :style="{ backgroundColor: currentLevel.color + '18', borderColor: currentLevel.color + '40' }" v-tooltip.top="currentLevel.label">
|
|
{{ currentLevel.icon }}
|
|
</div>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center justify-between mb-1.5">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-[1rem] font-bold text-[var(--text-color)]">{{ currentLevel.label }}</span>
|
|
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" :style="{ backgroundColor: currentLevel.color + '20', color: currentLevel.color }">Nível {{ gameLevels.indexOf(currentLevel) + 1 }}</span>
|
|
</div>
|
|
<span class="text-xs font-bold" :style="{ color: progressColor }">{{ profileProgress }}%</span>
|
|
</div>
|
|
|
|
<div class="w-full h-2.5 rounded-full bg-[var(--surface-ground)] overflow-hidden">
|
|
<div class="h-full rounded-full transition-all duration-700 ease-out relative overflow-hidden" :style="{ width: profileProgress + '%', backgroundColor: currentLevel.color }">
|
|
<div class="absolute inset-0 prof-shimmer" />
|
|
</div>
|
|
</div>
|
|
|
|
<p class="text-xs text-[var(--text-color-secondary)] mt-1.5 m-0">
|
|
<span v-if="nextLevel">
|
|
<span class="font-medium" :style="{ color: nextLevel.color }">{{ xpToNext }}% até {{ nextLevel.label }} {{ nextLevel.icon }}</span>
|
|
— preencha mais dados para evoluir
|
|
</span>
|
|
<span v-else class="font-semibold" style="color: #10b981">🎉 Perfil 100% completo!</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Badges -->
|
|
<div>
|
|
<p class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider mb-2">Conquistas</p>
|
|
<div class="flex gap-2 flex-wrap">
|
|
<div v-for="badge in badges" :key="badge.key" class="prof-badge" :class="badge.earned ? 'prof-badge--earned' : 'prof-badge--locked'" v-tooltip.top="badge.earned ? badge.label : 'Bloqueado — ' + badge.label">
|
|
<span class="text-base leading-none">{{ badge.icon }}</span>
|
|
<span class="text-xs font-medium leading-none">{{ badge.label }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dicas do que falta -->
|
|
<div v-if="progressSuggestions.length" class="flex flex-wrap gap-2 pt-1 border-t border-[var(--surface-border)]">
|
|
<span v-for="(tip, i) in progressSuggestions" :key="i" class="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full bg-[var(--surface-ground)] text-[var(--text-color-secondary)] border border-[var(--surface-border)]">
|
|
<i :class="tip.icon" class="text-[0.65rem]" />
|
|
{{ tip.text }}
|
|
</span>
|
|
</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 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 flex flex-col gap-3" :style="{ top: 'var(--layout-sticky-top, 140px)' }">
|
|
<!-- 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"
|
|
:invalid="!!fieldErrors.full_name"
|
|
@input="
|
|
markDirty();
|
|
clearErr('full_name');
|
|
"
|
|
/>
|
|
<label for="prof_name">Nome completo <span class="text-red-400">*</span></label>
|
|
</FloatLabel>
|
|
<Message v-if="fieldErrors.full_name" severity="error" size="small" variant="simple" class="mt-1">{{ fieldErrors.full_name }}</Message>
|
|
<div v-else 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"
|
|
:invalid="!!fieldErrors.nickname"
|
|
@input="
|
|
markDirty();
|
|
clearErr('nickname');
|
|
"
|
|
/>
|
|
<label for="prof_nickname">Como a Agência PSI deveria te chamar? <span class="text-red-400">*</span></label>
|
|
</FloatLabel>
|
|
<Message v-if="fieldErrors.nickname" severity="error" size="small" variant="simple" class="mt-1">{{ fieldErrors.nickname }}</Message>
|
|
<div v-else 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"
|
|
:invalid="!!fieldErrors.phone"
|
|
@update:modelValue="
|
|
markDirty();
|
|
clearErr('phone');
|
|
"
|
|
/>
|
|
<label for="prof_phone">WhatsApp <span class="text-red-400">*</span></label>
|
|
</FloatLabel>
|
|
<Message v-if="fieldErrors.phone" severity="error" size="small" variant="simple" class="mt-1">{{ fieldErrors.phone }}</Message>
|
|
<div v-else class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)] flex items-center gap-1.5">
|
|
<i class="pi pi-lock text-[0.7rem] opacity-60" />
|
|
<span>Usado apenas para notificações importantes.</span>
|
|
</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: só 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 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>
|
|
/* ─── Shimmer na barra de progresso ─────────────────────── */
|
|
@keyframes prof-shimmer-anim {
|
|
0% {
|
|
transform: translateX(-100%);
|
|
}
|
|
100% {
|
|
transform: translateX(250%);
|
|
}
|
|
}
|
|
.prof-shimmer {
|
|
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.28) 50%, transparent 100%);
|
|
width: 55%;
|
|
animation: prof-shimmer-anim 2.2s ease-in-out infinite;
|
|
}
|
|
|
|
/* ─── Badges de conquista ────────────────────────────────── */
|
|
.prof-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.3rem 0.65rem;
|
|
border-radius: 9999px;
|
|
border: 1px solid;
|
|
transition: all 0.18s ease;
|
|
cursor: default;
|
|
}
|
|
.prof-badge--earned {
|
|
background-color: rgba(16, 185, 129, 0.08);
|
|
border-color: rgba(16, 185, 129, 0.3);
|
|
color: #10b981;
|
|
}
|
|
.prof-badge--locked {
|
|
background-color: var(--surface-ground);
|
|
border-color: var(--surface-border);
|
|
color: var(--text-color-secondary);
|
|
filter: grayscale(1);
|
|
opacity: 0.45;
|
|
}
|
|
|
|
/* ─── 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>
|