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

1959 lines
66 KiB
Vue

<template>
<div class="prof-root">
<Toast />
<!-- HERO -->
<div ref="heroSentinelRef" class="prof-hero-sentinel" />
<div ref="heroEl" class="prof-hero mb-4" :class="{ 'prof-hero--stuck': heroStuck }">
<div class="prof-hero__blobs" aria-hidden="true">
<div class="prof-hero__blob prof-hero__blob--1" />
<div class="prof-hero__blob prof-hero__blob--2" />
<div class="prof-hero__blob prof-hero__blob--3" />
</div>
<div class="prof-hero__inner">
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-center gap-4 flex-1 min-w-0">
<div class="prof-hero__av-wrap">
<img v-if="ui.avatarPreview" :src="ui.avatarPreview" class="prof-hero__av-img" alt="avatar" />
<div v-else class="prof-hero__av-init">{{ initials }}</div>
<span class="prof-hero__online" />
</div>
<div class="min-w-0">
<div class="prof-hero__name">{{ form.full_name || 'Meu Perfil' }}</div>
<div class="prof-hero__email">{{ 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="grid grid-cols-12 gap-4 md:gap-5">
<!-- Sidebar -->
<div class="col-span-12 lg:col-span-3">
<div class="sticky top-4 prof-sidebar">
<!-- Mini user -->
<div class="prof-sb__user">
<div class="prof-sb__av-wrap">
<img v-if="ui.avatarPreview" :src="ui.avatarPreview" class="prof-sb__av-img" alt="avatar" />
<div v-else class="prof-sb__av-init">{{ initials }}</div>
<span class="prof-sb__dot" />
</div>
<div class="min-w-0">
<div class="prof-sb__name">{{ form.full_name || 'Conta' }}</div>
<div class="prof-sb__email">{{ userEmail }}</div>
</div>
</div>
<!-- Nav -->
<nav class="prof-sb__nav">
<button
v-for="s in sections"
:key="s.id"
class="prof-sb__link"
:class="{ 'prof-sb__link--active': activeSection === s.id }"
@click="scrollTo(s.id)"
>
<i :class="[s.icon, 'prof-sb__link-icon']" />
<span>{{ s.label }}</span>
</button>
</nav>
<div class="prof-sb__divider" />
<!-- Quick actions -->
<div class="prof-sb__actions">
<button class="prof-sb__action" @click="openPasswordDialog">
<i class="pi pi-key" /> <span>Trocar senha</span>
</button>
<button class="prof-sb__action prof-sb__action--danger" @click="confirmSignOut">
<i class="pi pi-sign-out" /> <span>Sair da conta</span>
</button>
</div>
<p class="prof-sb__tip">
Alterações em <b>Nome</b> e <b>Avatar</b> aparecem no menu e cabeçalhos do app.
</p>
</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="prof-card scroll-mt-20"
style="--c:#60A5FA;--c-dim:rgba(96,165,250,0.08);--c-border:rgba(96,165,250,0.2)"
>
<div class="prof-card__num">01</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
<div class="prof-card__icon"><i class="pi pi-user" /></div>
<span class="prof-card__tag">Conta</span>
</div>
<div class="prof-card__title">Informações pessoais</div>
<div class="prof-card__subtitle">Seu nome, contato e descrição exibidos no app.</div>
<div class="prof-card__sep" />
<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>
<small class="prof-hint">Aparece no menu, cabeçalhos e registros.</small>
</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>
<small class="prof-hint">Apelido ou nome preferido para comunicação.</small>
</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>
<small class="prof-hint">Exibido no seu perfil público.</small>
</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>
<small class="prof-hint">Gerenciado pelo Supabase Auth.</small>
</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>
<small class="prof-hint">Descreva brevemente sua atuação profissional.</small>
</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="prof-hint 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>
<small class="prof-hint">Opcional.</small>
</div>
</div>
</div>
<!-- 02 SITES E REDES SOCIAIS -->
<div
id="redes-sociais"
class="prof-card scroll-mt-20"
style="--c:#E879F9;--c-dim:rgba(232,121,249,0.08);--c-border:rgba(232,121,249,0.2)"
>
<div class="prof-card__num">02</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
<div class="prof-card__icon"><i class="pi pi-share-alt" /></div>
<span class="prof-card__tag">Sites e Redes Sociais</span>
</div>
<div class="prof-card__title">Seus Sites e Redes Sociais</div>
<div class="prof-card__subtitle">Links exibidos no seu perfil público.</div>
<div class="prof-card__sep" />
<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>
<small class="prof-hint">Ex: https://seuperfil.com.br</small>
</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>
<small class="prof-hint">Ex: @seuperfil</small>
</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>
<small class="prof-hint">Ex: @seucanal</small>
</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>
<small class="prof-hint">Ex: /suapagina</small>
</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>
<small class="prof-hint">Ex: @seuuser</small>
</div>
</div>
<!-- Outras redes -->
<div class="mt-5">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Outras redes ou links</div>
<div class="text-xs 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>
<p v-else class="text-xs text-[var(--text-color-secondary)] italic mt-1">
Nenhuma rede adicional cadastrada ainda.
</p>
</div>
</div>
<!-- 03 AVATAR -->
<div
id="avatar"
class="prof-card scroll-mt-20"
style="--c:#4ADE80;--c-dim:rgba(74,222,128,0.08);--c-border:rgba(74,222,128,0.2)"
>
<div class="prof-card__num">03</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
<div class="prof-card__icon"><i class="pi pi-image" /></div>
<span class="prof-card__tag">Avatar</span>
</div>
<div class="prof-card__title">Foto de perfil</div>
<div class="prof-card__subtitle">Envie um arquivo ou cole uma URL pública.</div>
<div class="prof-card__sep" />
<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="prof-av-preview">
<img v-if="ui.avatarPreview" :src="ui.avatarPreview" class="prof-av-preview__img" alt="preview" />
<div v-else class="prof-av-preview__init">{{ initials }}</div>
</div>
<div class="text-center">
<div class="text-sm font-medium truncate max-w-[120px]">{{ form.full_name || '—' }}</div>
<div class="text-xs 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="prof-upload-zone">
<div class="prof-upload-zone__label">
<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="prof-file-input"
@change="onAvatarFileSelected"
/>
<Button
icon="pi pi-times"
severity="secondary"
outlined
size="small"
:disabled="!ui.avatarFile"
@click="clearAvatarFile"
v-tooltip.top="'Limpar'"
/>
</div>
<small class="prof-hint">
Ao salvar, o arquivo é enviado ao bucket <b>{{ AVATAR_BUCKET }}</b> e a URL é atualizada automaticamente.
</small>
</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>
<small class="prof-hint -mt-2">Cole uma URL pública de imagem. Se vazio, usamos suas iniciais.</small>
</div>
</div>
</div>
<!-- 03 APARÊNCIA -->
<div
id="layout"
class="prof-card scroll-mt-20"
style="--c:#A78BFA;--c-dim:rgba(167,139,250,0.08);--c-border:rgba(167,139,250,0.2)"
>
<div class="prof-card__num">04</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
<div class="prof-card__icon"><i class="pi pi-palette" /></div>
<span class="prof-card__tag">Aparência</span>
</div>
<div class="prof-card__title">Tema e visual</div>
<div class="prof-card__subtitle">Personalize cores, preset de componentes e modo do menu.</div>
<div class="prof-card__sep" />
<div class="grid grid-cols-12 gap-4">
<!-- Primary -->
<div class="col-span-12 md:col-span-6">
<div class="prof-palette-box">
<div class="prof-palette-box__head">
<div>
<div class="prof-palette-box__label">Cor principal</div>
<div class="prof-palette-box__sub">Tom dominante da interface.</div>
</div>
<i class="pi pi-palette opacity-40 text-sm" />
</div>
<div class="prof-palette-box__swatches">
<button
v-for="pc of primaryColors"
:key="pc.name"
type="button"
:title="pc.name"
class="prof-swatch"
:class="{ 'prof-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="prof-palette-box">
<div class="prof-palette-box__head">
<div>
<div class="prof-palette-box__label">Surface</div>
<div class="prof-palette-box__sub">Base de fundo e superfícies.</div>
</div>
<i class="pi pi-circle-fill opacity-40 text-sm" />
</div>
<div class="prof-palette-box__swatches">
<button
v-for="sf of surfaces"
:key="sf.name"
type="button"
:title="sf.name"
class="prof-swatch"
:class="{
'prof-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="prof-ctrl-box">
<div class="prof-ctrl-box__head">
<div>
<div class="prof-palette-box__label">Preset</div>
<div class="prof-palette-box__sub">Aura · Lara · Nora</div>
</div>
<i class="pi pi-sparkles opacity-40 text-sm" />
</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="prof-ctrl-box">
<div class="prof-ctrl-box__head">
<div>
<div class="prof-palette-box__label">Menu</div>
<div class="prof-palette-box__sub">Static ou Overlay</div>
</div>
<i class="pi pi-bars opacity-40 text-sm" />
</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="prof-ctrl-box">
<div class="prof-ctrl-box__head">
<div>
<div class="prof-palette-box__label">Tema</div>
<div class="prof-palette-box__sub">Claro ou escuro</div>
</div>
<i class="pi pi-moon opacity-40 text-sm" />
</div>
<SelectButton
v-model="themeModeModel"
:options="themeModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
</div>
</div>
</div>
<!-- 04 PREFERÊNCIAS -->
<div
id="preferencias"
class="prof-card scroll-mt-20"
style="--c:#FB923C;--c-dim:rgba(251,146,60,0.08);--c-border:rgba(251,146,60,0.2)"
>
<div class="prof-card__num">05</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
<div class="prof-card__icon"><i class="pi pi-sliders-h" /></div>
<span class="prof-card__tag">Preferências</span>
</div>
<div class="prof-card__title">Notificações</div>
<div class="prof-card__subtitle">Escolha quais comunicações deseja receber.</div>
<div class="prof-card__sep" />
<div class="flex flex-col gap-3">
<label class="prof-toggle">
<Checkbox v-model="form.notify_system_email" binary @change="markDirty" />
<div>
<div class="prof-toggle__label">E-mails do sistema</div>
<div class="prof-toggle__sub">Confirmações, alertas importantes e faturas.</div>
</div>
</label>
<label class="prof-toggle">
<Checkbox v-model="form.notify_reminders" binary @change="markDirty" />
<div>
<div class="prof-toggle__label">Lembretes de sessão</div>
<div class="prof-toggle__sub">Notificações antes das consultas agendadas.</div>
</div>
</label>
<label class="prof-toggle">
<Checkbox v-model="form.notify_news" binary @change="markDirty" />
<div>
<div class="prof-toggle__label">Novidades da plataforma</div>
<div class="prof-toggle__sub">Updates, novas funcionalidades e comunicados.</div>
</div>
</label>
</div>
</div>
<!-- 06 LAYOUT -->
<div
id="layout-variant"
class="prof-card scroll-mt-20"
style="--c:#22D3EE;--c-dim:rgba(34,211,238,0.08);--c-border:rgba(34,211,238,0.2)"
>
<div class="prof-card__num">06</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
<div class="prof-card__icon"><i class="pi pi-th-large" /></div>
<span class="prof-card__tag">Layout</span>
</div>
<div class="prof-card__title">Estilo de navegação</div>
<div class="prof-card__subtitle">Escolha como a interface principal é organizada.</div>
<div class="prof-card__sep" />
<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-xl 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)] text-sm mt-0.5 flex-shrink-0" />
<p class="text-xs 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.
</p>
</div>
</div>
<!-- 07 SEGURANÇA -->
<div
id="seguranca"
class="prof-card scroll-mt-20"
style="--c:#F87171;--c-dim:rgba(248,113,113,0.08);--c-border:rgba(248,113,113,0.2)"
>
<div class="prof-card__num">07</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
<div class="prof-card__icon"><i class="pi pi-shield" /></div>
<span class="prof-card__tag">Segurança</span>
</div>
<div class="prof-card__title">Acesso e senha</div>
<div class="prof-card__subtitle">Mantenha sua conta protegida com uma senha forte.</div>
<div class="prof-card__sep" />
<div class="flex flex-col sm:flex-row gap-3">
<Button
label="Trocar senha agora"
icon="pi pi-key"
severity="secondary"
outlined
@click="openPasswordDialog"
/>
<Button
label="Abrir página de segurança"
icon="pi pi-shield"
severity="secondary"
outlined
@click="router.push('/account/security')"
/>
</div>
<div class="mt-4 rounded-xl 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)] text-sm mt-0.5 flex-shrink-0" />
<p class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
Na página de segurança você pode trocar a senha diretamente (com a atual) ou solicitar um link por e-mail para redefinição.
</p>
</div>
</div>
</div>
</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">
<p class="text-sm text-[var(--text-color-secondary)]">
Enviaremos um link de redefinição de senha para o seu e-mail.
</p>
<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-xl border border-emerald-500/20 bg-emerald-500/5 px-4 py-3 flex items-center gap-2 text-xs 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 />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { 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()
/** 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
----------------------------- */
function openPasswordDialog () {
passwordSent.value = false
openPassword.value = true
}
async function sendPasswordReset () {
if (!userEmail.value) return
sendingPassword.value = true
passwordSent.value = false
try {
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(userEmail.value, { redirectTo })
if (error) throw error
passwordSent.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 5000 })
} finally {
sendingPassword.value = false
}
}
function confirmSignOut () {
confirm.require({
header: 'Sair',
message: 'Deseja sair da sua conta neste dispositivo?',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sair',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await supabase.auth.signOut()
} finally {
router.push('/auth/login')
}
}
})
}
/* ----------------------------
Lifecycle
----------------------------- */
onMounted(async () => {
try {
await loadProfile()
disconnectObserver = observeSections()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não consegui carregar o perfil.', life: 6000 })
}
// 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>
<style scoped>
/* ─── Tokens ───────────────────────────────────────────── */
.prof-root {
padding: 1rem;
}
/* ─── Hero ──────────────────────────────────────────────── */
.prof-hero-sentinel { height: 1px; }
.prof-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.prof-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.prof-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.prof-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.prof-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(99,102,241,0.14); }
.prof-hero__blob--2 { width: 22rem; height: 22rem; top: 1rem; left: -5rem; background: rgba(52,211,153,0.1); }
.prof-hero__blob--3 { width: 16rem; height: 16rem; bottom: -3rem; right: 5rem; background: rgba(217,70,239,0.09); }
.prof-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
/* Hero avatar */
.prof-hero__av-wrap { position: relative; flex-shrink: 0; }
.prof-hero__av-img,
.prof-hero__av-init {
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
display: block;
}
.prof-hero__av-img { object-fit: cover; }
.prof-hero__av-init {
display: grid; place-items: center;
background: var(--surface-ground);
font-size: 1.3rem; font-weight: 800;
color: var(--text-color);
}
.prof-hero__online {
position: absolute; bottom: -3px; right: -3px;
width: 0.9rem; height: 0.9rem; border-radius: 50%;
background: #34d399; border: 2.5px solid var(--surface-card);
}
.prof-hero__info { flex: 1; min-width: 0; }
.prof-hero__name {
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.prof-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.prof-hero__email {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 3px;
}
/* ─── Sidebar ───────────────────────────────────────────── */
.prof-sidebar {
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem;
display: flex; flex-direction: column; gap: 0.75rem;
}
.prof-sb__user {
display: flex; align-items: center; gap: 0.75rem; padding-bottom: 0.25rem;
}
.prof-sb__av-wrap { position: relative; flex-shrink: 0; }
.prof-sb__av-img,
.prof-sb__av-init {
width: 2.5rem; height: 2.5rem; border-radius: 0.75rem;
border: 1px solid var(--surface-border);
display: block;
}
.prof-sb__av-img { object-fit: cover; }
.prof-sb__av-init {
display: grid; place-items: center;
background: var(--surface-ground);
font-size: 0.875rem; font-weight: 700; color: var(--text-color);
}
.prof-sb__dot {
position: absolute; bottom: -2px; right: -2px;
width: 0.625rem; height: 0.625rem; border-radius: 50%;
background: #34d399; border: 2px solid var(--surface-card);
}
.prof-sb__name {
font-size: 0.85rem; font-weight: 600; color: var(--text-color);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.prof-sb__email {
font-size: 0.68rem; color: var(--text-color-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Sidebar nav */
.prof-sb__nav { display: flex; flex-direction: column; gap: 2px; }
.prof-sb__link {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.5rem 0.75rem; border-radius: 0.75rem;
font-size: 0.82rem; font-weight: 500;
color: var(--text-color-secondary);
border: 1px solid transparent;
background: transparent; cursor: pointer; text-align: left;
transition: all 0.15s; width: 100%;
}
.prof-sb__link:hover {
color: var(--text-color);
background: var(--surface-ground);
border-color: var(--surface-border);
}
.prof-sb__link--active {
color: var(--text-color);
background: var(--surface-ground);
border-color: var(--surface-border);
}
.prof-sb__link-icon { font-size: 0.75rem; opacity: 0.65; flex-shrink: 0; }
.prof-sb__divider {
height: 1px; background: var(--surface-border); margin: 0.125rem 0;
}
/* Sidebar actions */
.prof-sb__actions { display: flex; flex-direction: column; gap: 2px; }
.prof-sb__action {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.5rem 0.75rem; border-radius: 0.75rem;
font-size: 0.82rem; font-weight: 500;
color: var(--text-color-secondary);
border: none; background: transparent; cursor: pointer; text-align: left;
transition: all 0.15s; width: 100%;
}
.prof-sb__action:hover {
color: var(--text-color); background: var(--surface-ground);
}
.prof-sb__action--danger:hover {
color: #f87171; background: rgba(248,113,113,0.06);
}
.prof-sb__tip {
font-size: 0.67rem; color: var(--text-color-secondary);
line-height: 1.5; padding-top: 0.25rem;
}
/* ─── Content Cards ─────────────────────────────────────── */
.prof-card {
position: relative; overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
animation: prof-fadeUp 0.4s ease both;
transition: border-color 0.22s ease;
}
.prof-card:hover { border-color: var(--c-border); }
.prof-card:hover .prof-card__shine { opacity: 1; }
.prof-card:hover .prof-card__num { opacity: 0.055; }
/* Decorative number */
.prof-card__num {
position: absolute; top: 0.875rem; right: 1.25rem;
font-size: 4.5rem; font-weight: 900; letter-spacing: -0.06em; line-height: 1;
color: var(--text-color); opacity: 0.022;
pointer-events: none; user-select: none;
transition: opacity 0.22s ease;
}
/* Shine */
.prof-card__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%
);
}
/* Card header */
.prof-card__head {
display: flex; align-items: center; gap: 0.625rem; margin-bottom: 0.875rem;
}
.prof-card__icon {
width: 2.25rem; height: 2.25rem; border-radius: 0.625rem; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem;
background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c);
}
.prof-card__tag {
font-size: 0.58rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--c); opacity: 0.75;
}
.prof-card__title {
font-size: 1.05rem; font-weight: 700; letter-spacing: -0.02em;
color: var(--text-color); margin-bottom: 0.25rem;
}
.prof-card__subtitle {
font-size: 0.77rem; color: var(--text-color-secondary); line-height: 1.5;
}
.prof-card__sep {
height: 1px; background: var(--surface-border); margin: 1.25rem 0;
}
/* Hint */
.prof-hint {
display: block; font-size: 0.7rem;
color: var(--text-color-secondary); margin-top: 0.375rem; line-height: 1.4;
}
/* ─── Avatar preview ────────────────────────────────────── */
.prof-av-preview {
width: 5.5rem; height: 5.5rem; border-radius: 1.375rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center; overflow: hidden;
}
.prof-av-preview__img { width: 100%; height: 100%; object-fit: cover; }
.prof-av-preview__init {
font-size: 1.75rem; font-weight: 800; color: var(--text-color);
}
/* ─── Upload zone ───────────────────────────────────────── */
.prof-upload-zone {
border-radius: 1rem;
border: 1px dashed var(--surface-border);
background: var(--surface-ground);
padding: 1rem;
display: flex; flex-direction: column; gap: 0.625rem;
}
.prof-upload-zone__label {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.78rem; font-weight: 600; color: var(--text-color-secondary);
}
.prof-file-input {
font-size: 0.75rem; flex: 1; color: var(--text-color-secondary);
}
/* ─── Palette & Control boxes ───────────────────────────── */
.prof-palette-box,
.prof-ctrl-box {
border-radius: 1rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
padding: 1rem;
height: 100%;
}
.prof-palette-box__head,
.prof-ctrl-box__head {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 0.875rem;
}
.prof-palette-box__label { font-size: 0.83rem; font-weight: 600; color: var(--text-color); }
.prof-palette-box__sub { font-size: 0.69rem; color: var(--text-color-secondary); margin-top: 2px; }
.prof-palette-box__swatches { display: flex; gap: 0.375rem; flex-wrap: wrap; }
.prof-swatch {
width: 1.375rem; height: 1.375rem; border-radius: 50%;
border: 2px solid transparent; cursor: pointer;
outline: none; outline-offset: 2px;
transition: transform 0.15s;
}
.prof-swatch:hover { transform: scale(1.2); }
.prof-swatch--active {
outline: 2px solid var(--primary-color);
transform: scale(1.1);
}
/* ─── Preference toggles ────────────────────────────────── */
.prof-toggle {
display: flex; align-items: flex-start; gap: 0.875rem;
padding: 0.875rem 1rem; border-radius: 1rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.prof-toggle:hover {
border-color: color-mix(in srgb, var(--primary-color) 40%, transparent);
background: color-mix(in srgb, var(--primary-color) 3%, var(--surface-ground));
}
.prof-toggle__label { font-size: 0.85rem; font-weight: 600; color: var(--text-color); }
.prof-toggle__sub { font-size: 0.72rem; color: var(--text-color-secondary); margin-top: 2px; }
/* ─── Layout variant picker ─────────────────────────────── */
.lv-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0;
border-radius: 1rem;
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);
}
/* Preview area */
.lv-card__preview {
height: 90px;
display: flex;
background: var(--surface-card);
border-bottom: 1px solid var(--surface-border);
overflow: hidden;
border-radius: 0;
gap: 0;
}
.lv-card__preview--classic { padding: 10px; gap: 6px; }
.lv-card__preview--rail { padding: 10px; gap: 4px; }
/* Sidebar mockup */
.lv-p__sidebar {
width: 38px;
border-radius: 5px;
background: var(--surface-border);
flex-shrink: 0;
}
/* Rail mockup */
.lv-p__rail {
width: 14px;
border-radius: 4px;
background: var(--surface-border);
flex-shrink: 0;
}
/* Panel mockup */
.lv-p__panel {
width: 28px;
border-radius: 4px;
background: color-mix(in srgb, var(--surface-border) 60%, transparent);
flex-shrink: 0;
}
/* Main content mockup */
.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%; }
/* Footer */
.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--active .lv-card__radio {
border-color: var(--primary-color);
}
.lv-card__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--primary-color);
}
.lv-card__name {
font-size: 0.83rem;
font-weight: 600;
color: var(--text-color);
}
.lv-card__sub {
font-size: 0.68rem;
color: var(--text-color-secondary);
margin-top: 1px;
}
/* ─── Animation ─────────────────────────────────────────── */
@keyframes prof-fadeUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Social addons ─────────────────────────────────────── */
.social-addon {
display: flex; align-items: center; justify-content: center;
width: 2.75rem; flex-shrink: 0;
font-size: 0.95rem;
}
.social-addon--site { color: var(--text-color-secondary); }
.social-addon--instagram { color: #E1306C; }
.social-addon--youtube { color: #FF0000; }
.social-addon--facebook { color: #1877F2; }
.social-addon--x { color: var(--text-color); }
/* FloatLabel com inputgroup: label offset pelo addon */
.social-float-label {
left: 2.85rem !important;
}
/* Fix FloatLabel wrapping inputgroup */
.p-floatlabel:has(.p-inputgroup) { display: block; }
.p-floatlabel .p-inputgroup { width: 100%; }
.p-floatlabel .p-inputgroup .p-inputtext {
border-radius: 0 var(--p-inputtext-border-radius, 8px) var(--p-inputtext-border-radius, 8px) 0 !important;
}
/* ─── 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>