Agenda google, avisos globais, feriados + avisos globais, templates de email, configuracoes empresa, preview empresa.

This commit is contained in:
Leonardo
2026-03-18 15:47:37 -03:00
parent d6d2fe29d1
commit 29ed349cf2
21 changed files with 5366 additions and 41 deletions
@@ -0,0 +1,850 @@
<!-- src/layout/configuracoes/ConfiguracoesEmailTemplatesPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Editor from 'primevue/editor'
import { supabase } from '@/lib/supabase/client'
import { renderEmail, renderTemplate, generateLayoutSection } from '@/lib/email/emailTemplateService'
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'
const toast = useToast()
const confirm = useConfirm()
const router = useRouter()
// ── Contexto ──────────────────────────────────────────────────
const tenantId = ref(null)
const profileLogoUrl = ref(null)
async function loadUser() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
tenantId.value = user.id
const { data } = await supabase
.from('company_profiles')
.select('logo_url')
.eq('tenant_id', user.id)
.maybeSingle()
profileLogoUrl.value = data?.logo_url || null
}
// ── Layout config global (header/footer do tenant) ────────────
const layoutConfigId = ref(null)
const layoutConfig = ref({ header: defaultSection(), footer: defaultSection() })
function defaultSection() {
return { enabled: false, content: '', layout: null }
}
async function loadLayoutConfig() {
if (!tenantId.value) return
const { data } = await supabase
.from('email_layout_config')
.select('*')
.eq('tenant_id', tenantId.value)
.maybeSingle()
if (data) {
layoutConfigId.value = data.id
layoutConfig.value.header = { ...defaultSection(), ...(data.header_config || {}) }
layoutConfig.value.footer = { ...defaultSection(), ...(data.footer_config || {}) }
}
}
// ── Templates globais + overrides ─────────────────────────────
const globals = ref([])
const overrides = ref([])
const loading = ref(false)
const filterDomain = ref(null)
async function load() {
if (!tenantId.value) return
loading.value = true
try {
const [{ data: gData, error: gErr }, { data: oData, error: oErr }] = await Promise.all([
supabase.from('email_templates_global').select('*').eq('is_active', true).order('domain').order('key'),
supabase.from('email_templates_tenant').select('*').eq('tenant_id', tenantId.value).is('owner_id', null),
])
if (gErr) throw gErr
if (oErr) throw oErr
globals.value = gData || []
overrides.value = oData || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 })
} finally {
loading.value = false
}
}
const overrideMap = computed(() => {
const map = {}
for (const o of overrides.value) map[o.template_key] = o
return map
})
const filtered = computed(() => {
const list = filterDomain.value
? globals.value.filter(t => t.domain === filterDomain.value)
: globals.value
return list.map(g => {
const ov = overrideMap.value[g.key] || null
const mock = _mockForDomain(g.domain)
return {
...g,
override: ov,
has_override: !!ov,
needs_sync: ov ? (ov.synced_version !== null && ov.synced_version < g.version) : false,
rendered_subject: renderTemplate(ov?.subject ?? g.subject, mock),
rendered_body_snippet: _bodySnippet(ov?.body_html ?? g.body_html, mock),
}
})
})
function _bodySnippet(html, mock) {
const rendered = renderTemplate(html || '', mock)
const text = rendered
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
return text.length > 100 ? text.slice(0, 100) + '…' : text
}
function _mockForDomain(domain) {
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session }
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake }
return { ...MOCK_DATA.system }
}
const DOMAIN_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem',value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema',value: TEMPLATE_DOMAINS.SYSTEM },
]
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' }
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' }
// ── Dialog layout (header/footer global) ──────────────────────
const layoutDlg = ref({ open: false, saving: false })
const layoutForm = ref({ header: defaultSection(), footer: defaultSection() })
const headerEditorRef = ref(null)
const footerEditorRef = ref(null)
const LAYOUT_OPTIONS = [
{ value: 'logo-left', label: 'Logo à esquerda' },
{ value: 'logo-right', label: 'Logo à direita' },
{ value: 'logo-center', label: 'Logo centralizada' },
]
const TEXT_OPTIONS = [
{ value: 'text-left', label: 'Texto à esquerda' },
{ value: 'text-center', label: 'Texto centralizado' },
{ value: 'text-right', label: 'Texto à direita' },
]
function openLayoutDlg() {
layoutForm.value = {
header: { ...defaultSection(), ...layoutConfig.value.header },
footer: { ...defaultSection(), ...layoutConfig.value.footer },
}
layoutDlg.value = { open: true, saving: false }
}
function selectLayout(which, type) {
layoutForm.value[which].layout = type
}
const headerLayoutPreview = computed(() =>
generateLayoutSection(layoutForm.value.header, profileLogoUrl.value, true)
)
const footerLayoutPreview = computed(() =>
generateLayoutSection(layoutForm.value.footer, profileLogoUrl.value, false)
)
async function saveLayout() {
if (!tenantId.value) return
layoutDlg.value.saving = true
try {
const payload = {
tenant_id: tenantId.value,
header_config: layoutForm.value.header,
footer_config: layoutForm.value.footer,
}
if (layoutConfigId.value) {
const { error } = await supabase.from('email_layout_config').update(payload).eq('id', layoutConfigId.value)
if (error) throw error
} else {
const { data, error } = await supabase.from('email_layout_config').insert(payload).select('id').single()
if (error) throw error
layoutConfigId.value = data.id
}
layoutConfig.value.header = { ...layoutForm.value.header }
layoutConfig.value.footer = { ...layoutForm.value.footer }
toast.add({ severity: 'success', summary: 'Layout salvo', life: 3000 })
layoutDlg.value.open = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
layoutDlg.value.saving = false
}
}
// ── Dialog edição de template ──────────────────────────────────
const dlg = ref({ open: false, saving: false, mode: 'create', globalTemplate: null })
const form = ref({})
const editorRef = ref(null)
function openEdit(row) {
const ov = row.override
form.value = {
template_key: row.key,
use_custom_subject: !!ov?.subject,
use_custom_body: !!ov?.body_html,
subject: ov?.subject ?? row.subject,
body_html: ov?.body_html ?? row.body_html,
body_text: ov?.body_text ?? '',
enabled: ov?.enabled ?? true,
synced_version: row.version,
variables: row.variables || {},
}
dlg.value = { open: true, saving: false, mode: ov ? 'edit' : 'create', globalTemplate: row }
}
function closeDlg() { dlg.value.open = false }
const formVariables = computed(() => {
const keys = Object.keys(form.value.variables || {})
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url')
return keys
})
function insertVar(varName) {
const snippet = `{{${varName}}}`
const quill = editorRef.value?.quill
if (!quill) { form.value.body_html = (form.value.body_html || '') + snippet; return }
const range = quill.getSelection(true)
const index = range ? range.index : quill.getLength() - 1
quill.insertText(index, snippet, 'user')
quill.setSelection(index + snippet.length, 0)
}
async function save() {
if (!tenantId.value) return
if (form.value.use_custom_subject && !form.value.subject?.trim()) {
toast.add({ severity: 'warn', summary: 'Subject não pode ser vazio', life: 3000 })
return
}
dlg.value.saving = true
try {
const payload = {
tenant_id: tenantId.value,
owner_id: null,
template_key: form.value.template_key,
subject: form.value.use_custom_subject ? form.value.subject : null,
body_html: form.value.use_custom_body ? form.value.body_html : null,
body_text: form.value.use_custom_body && form.value.body_text ? form.value.body_text : null,
enabled: form.value.enabled,
synced_version: form.value.synced_version,
}
if (dlg.value.mode === 'create') {
const { error } = await supabase.from('email_templates_tenant').insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Personalização salva', life: 3000 })
} else {
const { error } = await supabase.from('email_templates_tenant').update(payload).eq('id', overrideMap.value[form.value.template_key].id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Personalização salva', life: 3000 })
}
closeDlg()
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
dlg.value.saving = false
}
}
function confirmRevert(row) {
confirm.require({
message: `Reverter "${row.key}" para o template global?`,
header: 'Remover personalização',
icon: 'pi pi-undo',
acceptClass: 'p-button-danger',
accept: async () => {
try {
const { error } = await supabase.from('email_templates_tenant').delete().eq('id', row.override.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Revertido para o padrão', life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
}
})
}
// ── Dialog preview ─────────────────────────────────────────────
const preview = ref({ open: false, subject: '', body_html: '', key: '', source: '' })
function openPreview(row) {
const resolved = {
...row,
subject: row.override?.subject ?? row.subject,
body_html: row.override?.body_html ?? row.body_html,
body_text: row.override?.body_text ?? row.body_text,
}
const mock = {
..._mockForDomain(row.domain),
clinic_logo_url: profileLogoUrl.value || null,
}
const rendered = renderEmail(resolved, mock, {
headerConfig: layoutConfig.value.header,
footerConfig: layoutConfig.value.footer,
logoUrl: profileLogoUrl.value,
})
preview.value = { open: true, ...rendered, key: row.key, source: row.has_override ? 'personalizado' : 'global' }
}
const layoutActive = computed(() =>
layoutConfig.value.header.enabled || layoutConfig.value.footer.enabled
)
onMounted(async () => {
await loadUser()
await Promise.all([load(), loadLayoutConfig()])
})
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-envelope" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Templates de E-mail</div>
<div class="cfg-subheader__sub">Personalize os e-mails enviados aos seus pacientes</div>
</div>
<div class="cfg-subheader__actions">
<Button
label="Personalizar +"
icon="pi pi-palette"
size="small"
class="rounded-full"
@click="openLayoutDlg"
/>
</div>
</div>
<!-- Filtro -->
<div class="flex gap-2 flex-wrap">
<Button
v-for="opt in DOMAIN_OPTIONS"
:key="String(opt.value)"
:label="opt.label"
size="small"
:severity="filterDomain === opt.value ? 'primary' : 'secondary'"
:outlined="filterDomain !== opt.value"
@click="filterDomain = opt.value"
/>
</div>
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<div
v-for="row in filtered"
:key="row.id"
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] px-4 py-3 flex gap-3 items-center"
>
<Tag
:value="DOMAIN_LABEL[row.domain] ?? row.domain"
:severity="DOMAIN_SEVERITY[row.domain] ?? 'secondary'"
class="text-[0.7rem] shrink-0 self-start mt-0.5"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-0.5">
<span class="font-mono text-xs text-[var(--text-color-secondary)]">{{ row.key }}</span>
<Tag v-if="row.has_override" value="Personalizado" severity="success" class="text-[0.65rem]" />
<Tag v-if="row.needs_sync" value="Desatualizado" severity="warning" class="text-[0.65rem]" />
</div>
<div class="text-sm font-medium truncate">{{ row.rendered_subject }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate mt-0.5">{{ row.rendered_body_snippet }}</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(row)" />
<Button
:icon="row.has_override ? 'pi pi-pencil' : 'pi pi-sliders-h'"
text rounded size="small"
:title="row.has_override ? 'Editar personalização' : 'Personalizar'"
@click="openEdit(row)"
/>
<Button
v-if="row.has_override"
icon="pi pi-undo" text rounded size="small" severity="danger"
title="Reverter para padrão"
@click="confirmRevert(row)"
/>
</div>
</div>
</div>
<!-- Dialog Layout Global (Header/Footer) -->
<Dialog
v-model:visible="layoutDlg.open"
header="Personalizar Layout do E-mail"
modal
:style="{ width: '820px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Banner: trocar logo -->
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-image text-[var(--text-color-secondary)] text-sm opacity-60" />
<span class="text-sm flex-1 text-[var(--text-color-secondary)]">
Para trocar sua logo, acesse <strong>Minha Empresa</strong>.
</span>
<Button
label="Minha Empresa"
icon="pi pi-building"
size="small"
severity="secondary"
outlined
class="shrink-0"
@click="router.push('/configuracoes/empresa')"
/>
</div>
<!-- HEADER -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
<div class="flex items-center gap-2">
<i class="pi pi-arrow-up text-xs opacity-50" />
<span class="text-sm font-semibold">Header</span>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="layoutForm.header.enabled" inputId="sw-header" />
<label for="sw-header" class="text-xs cursor-pointer select-none">Ativo</label>
</div>
</div>
<div v-if="layoutForm.header.enabled" class="px-4 py-4 flex flex-col gap-4">
<!-- Cards de layout -->
<div class="flex flex-col gap-2">
<p class="text-xs font-semibold">Com logo</p>
<div class="flex gap-2">
<button
v-for="opt in LAYOUT_OPTIONS"
:key="opt.value"
class="layout-card"
:class="{ 'layout-card--active': layoutForm.header.layout === opt.value }"
@click="selectLayout('header', opt.value)"
>
<div class="layout-card__thumb">
<template v-if="opt.value === 'logo-left'">
<div class="lc-logo" /><div class="lc-spacer" /><div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div>
</template>
<template v-else-if="opt.value === 'logo-right'">
<div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div><div class="lc-spacer" /><div class="lc-logo" />
</template>
<template v-else>
<div class="lc-center"><div class="lc-logo" /><div class="lc-line" style="width:70%;margin-top:5px;" /></div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
<p class="text-xs font-semibold mt-1"> texto</p>
<div class="flex gap-2">
<button
v-for="opt in TEXT_OPTIONS"
:key="opt.value"
class="layout-card"
:class="{ 'layout-card--active': layoutForm.header.layout === opt.value }"
@click="selectLayout('header', opt.value)"
>
<div class="layout-card__thumb">
<template v-if="opt.value === 'text-left'">
<div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div>
</template>
<template v-else-if="opt.value === 'text-center'">
<div class="lc-lines lc-lines--center"><div class="lc-line" style="width:85%;" /><div class="lc-line short" style="width:55%;align-self:center;" /></div>
</template>
<template v-else>
<div class="lc-lines lc-lines--right"><div class="lc-line" /><div class="lc-line short" style="align-self:flex-end;" /></div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Editor de texto -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Texto</label>
<Editor ref="headerEditorRef" v-model="layoutForm.header.content" editor-style="min-height:100px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-color" />
</span>
</template>
</Editor>
</div>
<!-- Preview -->
<div v-if="headerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800">
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="headerLayoutPreview" />
</div>
</div>
</div>
<!-- FOOTER -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
<div class="flex items-center gap-2">
<i class="pi pi-arrow-down text-xs opacity-50" />
<span class="text-sm font-semibold">Rodapé</span>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="layoutForm.footer.enabled" inputId="sw-footer" />
<label for="sw-footer" class="text-xs cursor-pointer select-none">Ativo</label>
</div>
</div>
<div v-if="layoutForm.footer.enabled" class="px-4 py-4 flex flex-col gap-4">
<!-- Cards de layout -->
<div class="flex flex-col gap-2">
<p class="text-xs font-semibold">Com logo</p>
<div class="flex gap-2">
<button
v-for="opt in LAYOUT_OPTIONS"
:key="opt.value"
class="layout-card"
:class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }"
@click="selectLayout('footer', opt.value)"
>
<div class="layout-card__thumb">
<template v-if="opt.value === 'logo-left'">
<div class="lc-logo" /><div class="lc-spacer" /><div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div>
</template>
<template v-else-if="opt.value === 'logo-right'">
<div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div><div class="lc-spacer" /><div class="lc-logo" />
</template>
<template v-else>
<div class="lc-center"><div class="lc-logo" /><div class="lc-line" style="width:70%;margin-top:5px;" /></div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
<p class="text-xs font-semibold mt-1"> texto</p>
<div class="flex gap-2">
<button
v-for="opt in TEXT_OPTIONS"
:key="opt.value"
class="layout-card"
:class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }"
@click="selectLayout('footer', opt.value)"
>
<div class="layout-card__thumb">
<template v-if="opt.value === 'text-left'">
<div class="lc-lines"><div class="lc-line" /><div class="lc-line short" /></div>
</template>
<template v-else-if="opt.value === 'text-center'">
<div class="lc-lines lc-lines--center"><div class="lc-line" style="width:85%;" /><div class="lc-line short" style="width:55%;align-self:center;" /></div>
</template>
<template v-else>
<div class="lc-lines lc-lines--right"><div class="lc-line" /><div class="lc-line short" style="align-self:flex-end;" /></div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Editor de texto -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Texto</label>
<Editor ref="footerEditorRef" v-model="layoutForm.footer.content" editor-style="min-height:100px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-color" />
</span>
</template>
</Editor>
</div>
<!-- Preview -->
<div v-if="footerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800">
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="footerLayoutPreview" />
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="layoutDlg.open = false" :disabled="layoutDlg.saving" />
<Button label="Salvar layout" icon="pi pi-check" :loading="layoutDlg.saving" @click="saveLayout" />
</template>
</Dialog>
<!-- Dialog Edição de Template -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? `Personalizar — ${form.template_key}` : `Editar — ${form.template_key}`"
modal
:style="{ width: '840px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Subject -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
<span class="text-sm font-semibold">Subject</span>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.use_custom_subject" inputId="sw-custom-subject" />
<label for="sw-custom-subject" class="text-xs cursor-pointer select-none">Personalizar</label>
</div>
</div>
<div class="px-4 py-3">
<InputText v-if="form.use_custom_subject" v-model="form.subject" class="w-full" />
<p v-else class="text-xs text-[var(--text-color-secondary)] m-0 italic">
Usando padrão: "{{ dlg.globalTemplate?.subject }}"
</p>
</div>
</div>
<!-- Corpo -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
<span class="text-sm font-semibold">Corpo do e-mail</span>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.use_custom_body" inputId="sw-custom-body" />
<label for="sw-custom-body" class="text-xs cursor-pointer select-none">Personalizar</label>
</div>
</div>
<div class="px-4 py-3 flex flex-col gap-3">
<template v-if="form.use_custom_body">
<Editor ref="editorRef" v-model="form.body_html" editor-style="min-height:200px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<select class="ql-header">
<option value="1">Título</option>
<option value="2">Subtítulo</option>
<option selected>Normal</option>
</select>
</span>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-align" />
<select class="ql-color" />
<select class="ql-background" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" type="button" />
<button class="ql-list" value="bullet" type="button" />
</span>
<span class="ql-formats">
<button class="ql-link" type="button" />
<button class="ql-clean" type="button" />
</span>
</template>
</Editor>
<div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="v in formVariables" :key="v"
:label="`{{${v}}}`"
size="small" severity="secondary" outlined
class="font-mono !text-[0.68rem] !py-1 !px-2"
@click="insertVar(v)"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold">Versão texto <span class="font-normal opacity-60">(gerado do HTML se vazio)</span></label>
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
</div>
</template>
<p v-else class="text-xs text-[var(--text-color-secondary)] m-0 italic">
Usando o corpo padrão do sistema.
</p>
</div>
</div>
<!-- Override ativo -->
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.enabled" inputId="sw-enabled" />
<label for="sw-enabled" class="text-sm cursor-pointer select-none">Override ativo</label>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button :label="dlg.mode === 'create' ? 'Salvar personalização' : 'Salvar'" icon="pi pi-check" :loading="dlg.saving" @click="save" />
</template>
</Dialog>
<!-- Dialog Preview -->
<Dialog
v-model:visible="preview.open"
:header="`Preview — ${preview.key}`"
modal
:style="{ width: '680px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<div class="flex justify-center">
<Tag
:value="preview.source === 'personalizado' ? 'Usando sua personalização' : 'Usando template global'"
:severity="preview.source === 'personalizado' ? 'success' : 'secondary'"
/>
</div>
<div class="border border-[var(--surface-border)] rounded-lg p-3 bg-[var(--surface-ground)]">
<span class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-widest">Subject</span>
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
</div>
<div class="border border-[var(--surface-border)] rounded-lg p-5 bg-white text-gray-800 text-sm leading-relaxed">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="preview.body_html" />
</div>
<p v-if="!profileLogoUrl" class="text-xs text-[var(--text-color-secondary)] m-0 text-center">
Adicione um avatar no seu perfil para exibir o logo no header/rodapé.
</p>
</div>
<template #footer>
<Button label="Fechar" @click="preview.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<style scoped>
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Layout cards ───────────────────────────────────────── */
.layout-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 8px;
border: 1.5px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.layout-card:hover {
border-color: color-mix(in srgb, var(--primary-color,#6366f1) 50%, transparent);
background: color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card));
}
.layout-card--active {
border-color: var(--primary-color,#6366f1);
background: color-mix(in srgb, var(--primary-color,#6366f1) 8%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
}
.layout-card__thumb {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
height: 38px;
border: 1px solid #e5e7eb;
border-radius: 5px;
padding: 6px;
background: #f9fafb;
}
.layout-card__label {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-color-secondary);
text-align: center;
line-height: 1.3;
}
.layout-card--active .layout-card__label {
color: var(--primary-color,#6366f1);
}
/* Elementos internos dos cards */
.lc-logo {
width: 22px;
height: 22px;
border-radius: 3px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 35%, #e5e7eb);
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
}
.lc-spacer { flex: 1; min-width: 4px; }
.lc-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.lc-lines--center { align-items: center; }
.lc-lines--right { align-items: flex-end; }
.lc-line { height: 3px; background: #d1d5db; border-radius: 2px; }
.lc-line.short { width: 60%; }
.lc-center {
display: flex; flex-direction: column; align-items: center;
width: 100%; gap: 2px;
}
.lc-center .lc-logo { width: 18px; height: 18px; }
.lc-center .lc-line { width: 100%; }
/* Esconde botão de imagem do Quill em todos os editores desta página */
:deep(.ql-image) { display: none !important; }
</style>
@@ -0,0 +1,706 @@
<!-- src/layout/configuracoes/ConfiguracoesMinhaEmpresaPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
// ── Constantes ────────────────────────────────────────────────
const AVATAR_BUCKET = 'avatars'
const TIPO_OPTIONS = [
'MEI', 'ME', 'EPP', 'EIRELI', 'SLU', 'LTDA', 'S/A',
'Associação', 'Fundação', 'Cooperativa', 'Outro'
]
const REDES_OPTIONS = [
{ label: 'Instagram', value: 'instagram' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'LinkedIn', value: 'linkedin' },
{ label: 'X / Twitter', value: 'twitter' },
{ label: 'YouTube', value: 'youtube' },
{ label: 'TikTok', value: 'tiktok' },
{ label: 'WhatsApp', value: 'whatsapp' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Pinterest', value: 'pinterest' },
{ label: 'Outro', value: 'outro' },
]
const REDE_ICONS = {
instagram: 'pi-instagram',
facebook: 'pi-facebook',
linkedin: 'pi-linkedin',
twitter: 'pi-twitter',
youtube: 'pi-youtube',
whatsapp: 'pi-whatsapp',
tiktok: 'pi-tiktok',
telegram: 'pi-send',
pinterest: 'pi-heart',
outro: 'pi-link',
}
const ESTADOS = [
'AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS',
'MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC',
'SP','SE','TO'
]
// ── Estado ────────────────────────────────────────────────────
const tenantId = ref(null)
const recordId = ref(null)
const saving = ref(false)
const loadingCep = ref(false)
const form = ref({
nome_fantasia: '',
razao_social: '',
tipo_empresa: null,
cnpj: '',
ie: '',
im: '',
cep: '',
logradouro: '',
numero: '',
complemento: '',
bairro: '',
cidade: '',
estado: null,
email: '',
telefone: '',
site: '',
logo_url: '',
redes_sociais: [],
})
// ── Computed preview ──────────────────────────────────────────
const logoDisplay = computed(() => logoPreview.value || form.value.logo_url || null)
const enderecoLinhas = computed(() => {
const f = form.value
const lines = []
if (f.logradouro) {
let l = f.logradouro
if (f.numero) l += `, ${f.numero}`
if (f.complemento) l += `${f.complemento}`
lines.push(l)
}
if (f.bairro) lines.push(f.bairro)
const ce = [f.cidade, f.estado].filter(Boolean).join(' / ')
if (ce) lines.push(ce)
if (f.cep) lines.push(`CEP ${f.cep}`)
return lines
})
const redesValidas = computed(() =>
form.value.redes_sociais.filter(r => r.rede && r.url)
)
const temDados = computed(() =>
!!(form.value.nome_fantasia || form.value.razao_social || logoDisplay.value)
)
// ── Logo upload ───────────────────────────────────────────────
const logoInputRef = ref(null)
const logoPreview = ref(null)
const logoFile = ref(null)
const uploadingLogo = ref(false)
function onLogoClick() { logoInputRef.value?.click() }
function onLogoChange(e) {
const file = e.target.files?.[0]
if (!file) return
if (!['image/jpeg','image/png','image/webp','image/svg+xml'].includes(file.type)) {
toast.add({ severity: 'warn', summary: 'Formato inválido', detail: 'Use JPG, PNG, WEBP ou SVG.', life: 3000 })
return
}
logoFile.value = file
logoPreview.value = URL.createObjectURL(file)
}
function removeLogo() {
logoFile.value = null
logoPreview.value = null
form.value.logo_url = ''
if (logoInputRef.value) logoInputRef.value.value = ''
}
async function uploadLogo() {
if (!logoFile.value || !tenantId.value) return form.value.logo_url
const ext = logoFile.value.name.split('.').pop().toLowerCase()
const path = `${tenantId.value}/company-logo-${Date.now()}.${ext}`
uploadingLogo.value = true
try {
const { error } = await supabase.storage
.from(AVATAR_BUCKET)
.upload(path, logoFile.value, { upsert: true, contentType: logoFile.value.type })
if (error) throw error
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
return data?.publicUrl || ''
} finally {
uploadingLogo.value = false
}
}
// ── CEP ───────────────────────────────────────────────────────
async function buscarCep() {
const cep = form.value.cep.replace(/\D/g, '')
if (cep.length !== 8) return
loadingCep.value = true
try {
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
const data = await res.json()
if (data.erro) { toast.add({ severity: 'warn', summary: 'CEP não encontrado', life: 3000 }); return }
form.value.logradouro = data.logradouro || ''
form.value.bairro = data.bairro || ''
form.value.cidade = data.localidade || ''
form.value.estado = data.uf || null
} catch {
toast.add({ severity: 'error', summary: 'Erro ao buscar CEP', life: 3000 })
} finally {
loadingCep.value = false
}
}
// ── Redes sociais ─────────────────────────────────────────────
function addRede() { form.value.redes_sociais.push({ rede: null, url: '' }) }
function removeRede(i) { form.value.redes_sociais.splice(i, 1) }
// ── Load / Save ───────────────────────────────────────────────
async function load() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
tenantId.value = user.id
const { data } = await supabase
.from('company_profiles')
.select('*')
.eq('tenant_id', user.id)
.maybeSingle()
if (data) {
recordId.value = data.id
Object.keys(form.value).forEach(k => {
if (data[k] !== undefined && data[k] !== null) form.value[k] = data[k]
})
if (data.logo_url) logoPreview.value = data.logo_url
}
}
async function save() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
toast.add({ severity: 'error', summary: 'Sessão expirada', detail: 'Faça login novamente.', life: 4000 })
return
}
tenantId.value = user.id
saving.value = true
try {
if (logoFile.value) form.value.logo_url = await uploadLogo()
const payload = {
tenant_id: tenantId.value,
nome_fantasia: form.value.nome_fantasia || null,
razao_social: form.value.razao_social || null,
tipo_empresa: form.value.tipo_empresa || null,
cnpj: form.value.cnpj || null,
ie: form.value.ie || null,
im: form.value.im || null,
cep: form.value.cep || null,
logradouro: form.value.logradouro || null,
numero: form.value.numero || null,
complemento: form.value.complemento || null,
bairro: form.value.bairro || null,
cidade: form.value.cidade || null,
estado: form.value.estado || null,
email: form.value.email || null,
telefone: form.value.telefone || null,
site: form.value.site || null,
logo_url: form.value.logo_url || null,
redes_sociais: form.value.redes_sociais.filter(r => r.rede && r.url),
}
if (recordId.value) {
const { error } = await supabase.from('company_profiles').update(payload).eq('id', recordId.value)
if (error) throw error
} else {
const { data, error } = await supabase.from('company_profiles').insert(payload).select('id').single()
if (error) throw error
recordId.value = data.id
}
logoFile.value = null
toast.add({ severity: 'success', summary: 'Dados salvos com sucesso', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
saving.value = false
}
}
// ── Dados de exemplo ──────────────────────────────────────────
function preencherExemplo() {
form.value.nome_fantasia = 'Clínica Mente Sã'
form.value.razao_social = 'Mente Sã Serviços de Psicologia Ltda'
form.value.tipo_empresa = 'LTDA'
form.value.cnpj = '12.345.678/0001-90'
form.value.ie = '123.456.789.110'
form.value.im = '98765-4'
form.value.cep = '13561-260'
form.value.logradouro = 'Rua Episcopal'
form.value.numero = '738'
form.value.complemento = 'Sala 12 — 2º andar'
form.value.bairro = 'Centro'
form.value.cidade = 'São Carlos'
form.value.estado = 'SP'
form.value.email = 'contato@mentesa.com.br'
form.value.telefone = '(16) 99123-4567'
form.value.site = 'https://www.mentesa.com.br'
form.value.redes_sociais = [
{ rede: 'instagram', url: 'https://instagram.com/mentesa' },
{ rede: 'facebook', url: 'https://facebook.com/mentesa' },
{ rede: 'linkedin', url: 'https://linkedin.com/company/mentesa' },
{ rede: 'whatsapp', url: 'https://wa.me/5516991234567' },
]
}
onMounted(load)
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-building" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Minha Empresa</div>
<div class="cfg-subheader__sub">Dados da empresa, logomarca e presença digital</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Exemplo" icon="pi pi-magic-wand" size="small" severity="secondary" outlined class="rounded-full" @click="preencherExemplo" />
<Button label="Salvar" icon="pi pi-check" size="small" class="rounded-full" :loading="saving" @click="save" />
</div>
</div>
<!-- Corpo: formulário + preview -->
<div class="flex gap-4 items-start">
<!-- Formulário (60%) -->
<div class="form-col">
<!-- Logomarca -->
<div class="cfg-card">
<div class="cfg-card__head">
<i class="pi pi-image text-xs opacity-50" />
<span>Logomarca</span>
</div>
<div class="flex items-center gap-5 px-4 py-4">
<div class="logo-upload-preview" :class="{ empty: !logoDisplay }" @click="onLogoClick">
<img v-if="logoDisplay" :src="logoDisplay" alt="Logo" class="w-full h-full object-contain p-1" />
<div v-else class="flex flex-col items-center gap-1 opacity-40">
<i class="pi pi-image text-2xl" />
<span class="text-[0.65rem]">Clique para adicionar</span>
</div>
</div>
<input ref="logoInputRef" type="file" accept="image/*" class="hidden" @change="onLogoChange" />
<div class="flex flex-col gap-2">
<Button label="Carregar imagem" icon="pi pi-upload" size="small" outlined @click="onLogoClick" :loading="uploadingLogo" />
<Button v-if="logoDisplay" label="Remover" icon="pi pi-trash" size="small" severity="danger" text @click="removeLogo" />
<p class="text-[0.7rem] text-[var(--text-color-secondary)] m-0">JPG, PNG, WEBP ou SVG.<br>Recomendado: fundo transparente.</p>
</div>
</div>
</div>
<!-- Identificação -->
<div class="cfg-card mt-4">
<div class="cfg-card__head">
<i class="pi pi-id-card text-xs opacity-50" />
<span>Identificação</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 px-4 py-4">
<div class="flex flex-col gap-1">
<label class="cfg-label">Nome fantasia</label>
<InputText v-model="form.nome_fantasia" placeholder="Nome comercial da empresa" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Razão social</label>
<InputText v-model="form.razao_social" placeholder="Razão social completa" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Tipo de empresa</label>
<Select v-model="form.tipo_empresa" :options="TIPO_OPTIONS" placeholder="Selecione" class="w-full" show-clear />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">CNPJ</label>
<InputText v-model="form.cnpj" placeholder="00.000.000/0001-00" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Inscrição Estadual (IE)</label>
<InputText v-model="form.ie" placeholder="Inscrição Estadual" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Inscrição Municipal (IM)</label>
<InputText v-model="form.im" placeholder="Inscrição Municipal" class="w-full" />
</div>
</div>
</div>
<!-- Endereço -->
<div class="cfg-card mt-4">
<div class="cfg-card__head">
<i class="pi pi-map-marker text-xs opacity-50" />
<span>Endereço</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-6 gap-3 px-4 py-4">
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">CEP</label>
<div class="flex gap-2">
<InputText v-model="form.cep" placeholder="00000-000" class="w-full" @blur="buscarCep" @keydown.enter.prevent="buscarCep" />
<Button icon="pi pi-search" severity="secondary" outlined :loading="loadingCep" @click="buscarCep" v-tooltip="'Buscar CEP'" />
</div>
</div>
<div class="flex flex-col gap-1 md:col-span-3">
<label class="cfg-label">Logradouro</label>
<InputText v-model="form.logradouro" placeholder="Rua, Avenida..." class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-1">
<label class="cfg-label">Número</label>
<InputText v-model="form.numero" placeholder="Nº" class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">Complemento</label>
<InputText v-model="form.complemento" placeholder="Sala, apto..." class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">Bairro</label>
<InputText v-model="form.bairro" placeholder="Bairro" class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">Cidade</label>
<InputText v-model="form.cidade" placeholder="Cidade" class="w-full" />
</div>
<div class="flex flex-col gap-1 md:col-span-2">
<label class="cfg-label">Estado</label>
<Select v-model="form.estado" :options="ESTADOS" placeholder="UF" class="w-full" show-clear />
</div>
</div>
</div>
<!-- Contato -->
<div class="cfg-card mt-4">
<div class="cfg-card__head">
<i class="pi pi-globe text-xs opacity-50" />
<span>Contato e Presença Digital</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 px-4 py-4">
<div class="flex flex-col gap-1">
<label class="cfg-label">E-mail da empresa</label>
<InputText v-model="form.email" placeholder="contato@empresa.com.br" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Telefone / WhatsApp</label>
<InputText v-model="form.telefone" placeholder="(00) 00000-0000" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="cfg-label">Site</label>
<InputText v-model="form.site" placeholder="https://www.empresa.com.br" class="w-full" />
</div>
</div>
</div>
<!-- Redes Sociais -->
<div class="cfg-card mt-4">
<div class="cfg-card__head">
<i class="pi pi-share-alt text-xs opacity-50" />
<span>Redes Sociais</span>
<Button label="Adicionar" icon="pi pi-plus" size="small" severity="secondary" outlined class="ml-auto" @click="addRede" />
</div>
<div class="px-4 py-3 flex flex-col gap-2">
<p v-if="form.redes_sociais.length === 0" class="text-sm text-[var(--text-color-secondary)] italic m-0 py-2">
Nenhuma rede social adicionada.
</p>
<div v-for="(item, i) in form.redes_sociais" :key="i" class="flex gap-2 items-center">
<Select v-model="item.rede" :options="REDES_OPTIONS" option-label="label" option-value="value" placeholder="Rede" class="w-40 shrink-0" />
<InputText v-model="item.url" :placeholder="item.rede ? `URL do ${item.rede}` : 'URL do perfil'" class="flex-1" />
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="removeRede(i)" />
</div>
</div>
</div>
<!-- Salvar -->
<div class="flex justify-end mt-4 pb-2">
<Button label="Salvar dados da empresa" icon="pi pi-check" :loading="saving" @click="save" />
</div>
</div>
<!-- Preview (40%) -->
<div class="preview-col">
<div class="preview-card">
<!-- Placeholder vazio -->
<div v-if="!temDados" class="preview-empty">
<i class="pi pi-building text-3xl opacity-20" />
<p class="text-xs opacity-40 mt-2 text-center">Preencha os dados<br>para ver o preview</p>
</div>
<template v-else>
<!-- Logo -->
<div class="preview-logo-wrap">
<img v-if="logoDisplay" :src="logoDisplay" alt="Logo" class="preview-logo" />
<div v-else class="preview-logo-placeholder">
<i class="pi pi-building text-xl opacity-30" />
</div>
</div>
<!-- Nome + tipo -->
<div class="preview-name-block">
<h2 v-if="form.nome_fantasia" class="preview-name">{{ form.nome_fantasia }}</h2>
<p v-if="form.razao_social" class="preview-razao">{{ form.razao_social }}</p>
<span v-if="form.tipo_empresa" class="preview-tipo">{{ form.tipo_empresa }}</span>
</div>
<!-- Documentos -->
<div v-if="form.cnpj || form.ie || form.im" class="preview-section">
<div class="preview-divider" />
<div class="preview-docs">
<div v-if="form.cnpj" class="preview-doc-row">
<span class="preview-doc-label">CNPJ</span>
<span class="preview-doc-value">{{ form.cnpj }}</span>
</div>
<div v-if="form.ie" class="preview-doc-row">
<span class="preview-doc-label">IE</span>
<span class="preview-doc-value">{{ form.ie }}</span>
</div>
<div v-if="form.im" class="preview-doc-row">
<span class="preview-doc-label">IM</span>
<span class="preview-doc-value">{{ form.im }}</span>
</div>
</div>
</div>
<!-- Endereço -->
<div v-if="enderecoLinhas.length" class="preview-section">
<div class="preview-divider" />
<div class="preview-info-row">
<i class="pi pi-map-marker preview-info-icon" />
<div class="flex flex-col gap-0.5">
<span v-for="(linha, i) in enderecoLinhas" :key="i" class="preview-info-text">{{ linha }}</span>
</div>
</div>
</div>
<!-- Contato -->
<div v-if="form.email || form.telefone || form.site" class="preview-section">
<div class="preview-divider" />
<div class="flex flex-col gap-1.5">
<div v-if="form.email" class="preview-info-row">
<i class="pi pi-envelope preview-info-icon" />
<span class="preview-info-text">{{ form.email }}</span>
</div>
<div v-if="form.telefone" class="preview-info-row">
<i class="pi pi-phone preview-info-icon" />
<span class="preview-info-text">{{ form.telefone }}</span>
</div>
<div v-if="form.site" class="preview-info-row">
<i class="pi pi-globe preview-info-icon" />
<span class="preview-info-text truncate">{{ form.site }}</span>
</div>
</div>
</div>
<!-- Redes sociais -->
<div v-if="redesValidas.length" class="preview-section">
<div class="preview-divider" />
<div class="flex flex-wrap gap-2">
<a
v-for="(r, i) in redesValidas"
:key="i"
:href="r.url"
target="_blank"
class="preview-rede"
:title="r.rede"
>
<i :class="`pi ${REDE_ICONS[r.rede] || 'pi-link'}`" />
<span>{{ REDES_OPTIONS.find(o => o.value === r.rede)?.label ?? r.rede }}</span>
</a>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Subheader ─────────────────────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Layout colunas ────────────────────────────────────────── */
.preview-col {
width: 40%;
flex-shrink: 0;
position: sticky;
top: calc(var(--layout-sticky-top, 56px) + 58px);
align-self: flex-start;
}
.form-col {
flex: 1;
min-width: 0;
}
/* ── Preview card ──────────────────────────────────────────── */
.preview-card {
border: 1px solid var(--surface-border);
border-radius: 12px;
background: var(--surface-card);
overflow: hidden;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0;
}
.preview-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 200px;
color: var(--text-color-secondary);
}
.preview-logo-wrap {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.preview-logo {
max-width: 120px;
max-height: 72px;
object-fit: contain;
}
.preview-logo-placeholder {
width: 72px; height: 72px;
border-radius: 8px;
background: var(--surface-ground);
border: 2px dashed var(--surface-border);
display: flex; align-items: center; justify-content: center;
color: var(--text-color-secondary);
}
.preview-name-block {
text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 4px;
}
.preview-name {
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text-color);
margin: 0;
}
.preview-razao {
font-size: 0.75rem;
color: var(--text-color-secondary);
margin: 0;
}
.preview-tipo {
display: inline-block;
margin-top: 4px;
padding: 2px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 25%, transparent);
color: var(--primary-color,#6366f1);
font-size: 0.7rem;
font-weight: 700;
}
.preview-section { margin-top: 0.75rem; }
.preview-divider { height: 1px; background: var(--surface-border); margin-bottom: 0.75rem; }
.preview-docs { display: flex; flex-direction: column; gap: 4px; }
.preview-doc-row { display: flex; gap: 8px; align-items: baseline; }
.preview-doc-label {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-color-secondary); opacity: 0.6;
width: 28px; flex-shrink: 0;
}
.preview-doc-value { font-size: 0.8rem; color: var(--text-color); font-family: monospace; }
.preview-info-row { display: flex; align-items: flex-start; gap: 8px; }
.preview-info-icon { font-size: 0.75rem; color: var(--primary-color,#6366f1); opacity: 0.7; margin-top: 2px; flex-shrink: 0; }
.preview-info-text { font-size: 0.8rem; color: var(--text-color); line-height: 1.4; }
.preview-rede {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
color: var(--text-color);
font-size: 0.72rem;
font-weight: 600;
text-decoration: none;
transition: border-color 0.15s, background 0.15s;
}
.preview-rede:hover {
border-color: color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
background: color-mix(in srgb, var(--primary-color,#6366f1) 6%, var(--surface-card));
}
.preview-rede i { font-size: 0.75rem; }
/* ── Form cards ────────────────────────────────────────────── */
.cfg-card {
border: 1px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-card__head {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.82rem;
font-weight: 700;
}
.cfg-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.logo-upload-preview {
width: 120px; height: 80px;
border: 2px dashed var(--surface-border);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0;
background: var(--surface-ground);
transition: border-color 0.15s;
overflow: hidden;
}
.logo-upload-preview:hover { border-color: var(--primary-color,#6366f1); }
.logo-upload-preview.empty { color: var(--text-color-secondary); }
</style>