Files
agenciapsilmno/src/features/setup/SetupWizardPage.vue
T
Leonardo a7f6bcbe66 F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00

2648 lines
109 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/setup/SetupWizardPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans';
import { useServices } from '@/features/agenda/composables/useServices';
import { useLayout } from '@/layout/composables/layout';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { applyThemeEngine } from '@/theme/theme.options';
import InputMask from 'primevue/inputmask';
import StepList from 'primevue/steplist';
import StepPanel from 'primevue/steppanel';
import StepPanels from 'primevue/steppanels';
import Stepper from 'primevue/stepper';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const toast = useToast();
const { toggleDarkMode, isDarkTheme, layoutConfig } = useLayout();
function isDarkNow() {
return document.documentElement.classList.contains('app-dark');
}
async function toggleTheme() {
toggleDarkMode();
try {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user?.id) return;
await supabase.from('user_settings').upsert({ user_id: user.id, theme_mode: isDarkNow() ? 'dark' : 'light' }, { onConflict: 'user_id' });
} catch {}
}
const tenant = useTenantStore();
const { services, load: loadServices, save: saveService, remove: removeSvc } = useServices();
const { plans: insurancePlans, load: loadPlans, save: savePlanItem, removeDefinitivo: removePlanItem } = useInsurancePlans();
const uid = ref(null);
const tenantId = computed(() => tenant.activeTenantId);
const isClinicRole = computed(() => {
const r = tenant.activeRole || '';
return r === 'clinic_admin' || r.startsWith('clinic') || (r === 'tenant_admin' && tenant.memberships?.find((m) => m.tenant_id === tenant.activeTenantId)?.kind?.startsWith('clinic'));
});
const agendaRoute = computed(() => (isClinicRole.value ? '/admin/agenda/clinica' : '/therapist/agenda'));
const configRoute = computed(() => (isClinicRole.value ? '/admin' : '/configuracoes'));
const userEmail = ref('');
const done = ref(false);
const dialogVisible = ref(false);
function openDialog() {
dialogVisible.value = true;
}
// ── 3 STEPS ───────────────────────────────────────────────────────────────
const steps = [
{ id: 'voce', label: 'Você', icon: 'pi-user', color: '#60A5FA', macroAt: 40, description: 'Seu perfil básico.' },
{ id: 'negocio', label: 'Negócio', icon: 'pi-building', color: '#4ADE80', macroAt: 70, description: 'Crie seu negócio.' },
{ id: 'atendimento', label: 'Atendimento', icon: 'pi-heart', color: '#A78BFA', macroAt: 100, description: 'Como você atende.' }
];
const currentIdx = ref(0);
const currentStep = computed(() => steps[currentIdx.value]);
const firstName = computed(() => perfil.value.fullName?.split(' ')[0] || 'você');
async function navigateTo(idx) {
if (idx < 0 || idx >= steps.length || idx === currentIdx.value) return;
if (idx > currentIdx.value) {
const id = currentStep.value?.id;
if (id === 'voce') {
if (!validateStepForward('voce')) return;
await saveVoce(true);
}
if (id === 'negocio') {
if (!validateStepForward('negocio')) return;
await saveNegocio(true);
}
if (id === 'atendimento' && atendimento.value.mode) await saveAtendimento(true);
}
currentIdx.value = idx;
}
// ── STEP STATES ────────────────────────────────────────────────────────────
const stepStates = ref({ voce: 'not_started', negocio: 'not_started', atendimento: 'not_started' });
function markStarted(id) {
if (stepStates.value[id] === 'not_started') stepStates.value[id] = 'started';
}
function markSaved(id) {
stepStates.value[id] = 'saved';
}
// ── HYBRID PROGRESS ────────────────────────────────────────────────────────
const progressPct = computed(() => {
let p = 0;
if (stepStates.value.voce === 'saved') {
p += 40;
} else {
const filled = [perfil.value.fullName.trim().split(/\s+/).filter(Boolean).length >= 2, !!perfil.value.nickname.trim(), String(perfil.value.whatsapp || '').replace(/\D/g, '').length >= 10].filter(Boolean).length;
p += Math.round((filled / 3) * 40);
}
if (stepStates.value.negocio === 'saved') {
p += 30;
} else if (stepStates.value.voce === 'saved') {
const filled = [!!negocio.value.name.trim(), !!negocio.value.type].filter(Boolean).length;
p += Math.round((filled / 2) * 30);
}
if (stepStates.value.atendimento === 'saved') {
p += 30;
} else if (stepStates.value.negocio === 'saved' && atendimento.value.mode) {
p += 30;
}
let bonus = 0;
if (perfil.value.avatarPreview) bonus += 10;
if (perfil.value.bio?.trim()) bonus += 5;
if (layoutConfig.primary && layoutConfig.primary !== 'noir') bonus += 2;
if (isDarkTheme.value) bonus += 2;
if (negocio.value.logoPreview) bonus += 10;
if (negocio.value.logradouro?.trim() && negocio.value.cidade?.trim()) bonus += 5;
if (negocio.value.phone?.trim()) bonus += 3;
if (negocio.value.siteUrl?.trim() || negocio.value.instagram?.trim()) bonus += 2;
return Math.min(130, p + bonus);
});
const progressMsg = computed(() => {
const p = progressPct.value;
if (p > 100) return 'Perfil otimizado! 🎉';
if (p >= 100) return 'Tudo pronto! ✨';
if (p >= 70) return 'Quase lá! Último passo 🚀';
if (p >= 40) return 'Bom começo! Configure seu negócio';
if (p > 0) return 'Preenchendo seu perfil...';
return 'Vamos começar! 👋';
});
const canFinish = computed(() => steps.every((s) => stepStates.value[s.id] === 'saved'));
const finishing = ref(false);
const saving = ref({ voce: false, negocio: false, atendimento: false });
const isSaving = computed(() => saving.value[currentStep.value?.id] ?? false);
async function saveCurrentStep() {
const id = currentStep.value?.id;
if (id === 'voce') return saveVoce();
if (id === 'negocio') return saveNegocio();
if (id === 'atendimento') return saveAtendimento();
}
// ── AUTOSAVE ───────────────────────────────────────────────────────────────
let autoSaveTimer = null;
function scheduleAutosave(stepId) {
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => {
if (stepId === 'voce' && canSaveVoce.value) saveVoce(true);
if (stepId === 'negocio' && canSaveNegocio.value && tenantId.value) saveNegocio(true);
if (stepId === 'atendimento' && atendimento.value.mode) saveAtendimento(true);
}, 2500);
}
// ── THEME / COLORS ─────────────────────────────────────────────────────────
const swRootEl = ref(null);
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' } }
];
function getPrimaryHex(name) {
if (!name || name === 'noir') return '#18181b';
return primaryColors.find((c) => c.name === name)?.palette['500'] || '#6154e8';
}
function applyWizardPrimary(name) {
const el = swRootEl.value;
if (!el) return;
const hex = getPrimaryHex(name);
const r = parseInt(hex.slice(1, 3), 16),
g = parseInt(hex.slice(3, 5), 16),
b = parseInt(hex.slice(5, 7), 16);
el.style.setProperty('--sw-primary', hex);
el.style.setProperty('--sw-primary-dim', `rgba(${r},${g},${b},0.12)`);
el.style.setProperty('--sw-primary-border', `rgba(${r},${g},${b},0.30)`);
el.style.setProperty('--sw-primary-rgb', `${r},${g},${b}`);
}
watch(() => layoutConfig.primary, applyWizardPrimary);
function updatePrimaryColor(pc) {
layoutConfig.primary = pc.name;
applyThemeEngine(layoutConfig);
applyWizardPrimary(pc.name);
markStarted('voce');
scheduleAutosave('voce');
}
const themeOptions = [
{ label: 'Claro', value: 'light' },
{ label: 'Escuro', value: 'dark' }
];
const themeModel = computed(() => (isDarkTheme.value ? 'dark' : 'light'));
async function onThemeChange(val) {
if ((val === 'dark') !== isDarkNow()) toggleDarkMode();
try {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user?.id) return;
await supabase.from('user_settings').upsert({ user_id: user.id, theme_mode: val }, { onConflict: 'user_id' });
} catch {}
markStarted('voce');
scheduleAutosave('voce');
}
// ── PERFIL (VOCÊ) ──────────────────────────────────────────────────────────
const avatarInput = ref(null);
const perfil = ref({
fullName: '',
nickname: '',
workDescription: '',
workDescriptionOther: '',
bio: '',
whatsapp: '',
avatarUrl: null,
avatarPreview: null,
avatarFile: null
});
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', value: 'psicologo_escolar' },
{ label: 'Psicólogo(a) Hospitalar', value: 'psicologo_hospitalar' },
{ label: 'Coach / Mentor(a)', value: 'coach_mentor' },
{ label: 'Terapeuta Holístico(a)', value: 'terapeuta_holistico' },
{ label: 'Outro', value: 'outro' }
];
const initials = computed(() => {
const p = String(perfil.value.fullName || '')
.trim()
.split(/\s+/)
.filter(Boolean);
if (!p.length) return '?';
return p.length === 1 ? p[0].slice(0, 2).toUpperCase() : (p[0][0] + p[p.length - 1][0]).toUpperCase();
});
const canSaveVoce = computed(() => perfil.value.fullName.trim().length > 0 && perfil.value.nickname.trim().length > 0 && String(perfil.value.whatsapp || '').replace(/\D/g, '').length >= 10);
// ── VALIDAÇÃO INLINE ────────────────────────────────────────────────────────
const fieldErrors = ref({});
function clearErr(key) {
fieldErrors.value = { ...fieldErrors.value, [key]: null };
}
function validateStepForward(id) {
fieldErrors.value = {};
if (id === 'voce') {
const e = {};
const nameParts = perfil.value.fullName.trim().split(/\s+/).filter(Boolean);
if (!nameParts.length) e.fullName = 'Nome completo é obrigatório.';
else if (nameParts.length < 2) e.fullName = 'Informe nome e sobrenome.';
if (!perfil.value.nickname.trim()) e.nickname = 'Apelido é obrigatório.';
if (String(perfil.value.whatsapp || '').replace(/\D/g, '').length < 10) e.whatsapp = 'WhatsApp é obrigatório.';
fieldErrors.value = e;
return !Object.keys(e).length;
}
if (id === 'negocio') {
const e = {};
if (!negocio.value.name.trim()) e.negocioName = 'Nome do negócio é obrigatório.';
if (!negocio.value.type) e.negocioType = 'Selecione o tipo do negócio.';
fieldErrors.value = e;
return !Object.keys(e).length;
}
return true;
}
function triggerAvatarUpload() {
avatarInput.value?.click();
}
function onAvatarChange(e) {
const file = e.target.files?.[0];
if (!file) return;
perfil.value.avatarFile = file;
perfil.value.avatarPreview = URL.createObjectURL(file);
markStarted('voce');
scheduleAutosave('voce');
}
// ── NEGÓCIO ────────────────────────────────────────────────────────────────
const logoInput = ref(null);
const negocio = ref({
name: '',
type: '',
logoFile: null,
logoPreview: null,
logoUrl: null,
cep: '',
logradouro: '',
numero: '',
complemento: '',
bairro: '',
cidade: '',
estado: '',
phone: '',
email: '',
siteUrl: '',
instagram: ''
});
const fetchingCep = ref(false);
async function fetchCep(cepRaw) {
const cep = String(cepRaw ?? '').replace(/\D/g, '');
if (cep.length !== 8) return null;
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const data = await res.json();
if (!data || data.erro) return null;
return data;
}
async function onCepBlur() {
fetchingCep.value = true;
try {
const d = await fetchCep(negocio.value.cep);
if (!d) return;
negocio.value.logradouro = d.logradouro || negocio.value.logradouro;
negocio.value.bairro = d.bairro || negocio.value.bairro;
negocio.value.cidade = d.localidade || negocio.value.cidade;
negocio.value.estado = d.uf || negocio.value.estado;
if (!negocio.value.complemento) negocio.value.complemento = d.complemento || '';
markStarted('negocio');
scheduleAutosave('negocio');
} catch (_) {
} finally {
fetchingCep.value = false;
}
}
const businessTypes = [
{ label: 'Consultório', value: 'consultorio', icon: 'pi-user' },
{ label: 'Clínica', value: 'clinica', icon: 'pi-building' },
{ label: 'Instituto', value: 'instituto', icon: 'pi-home' },
{ label: 'Grupo', value: 'grupo', icon: 'pi-users' }
];
const negocioInitials = computed(() => {
const p = String(negocio.value.name || '')
.trim()
.split(/\s+/)
.filter(Boolean);
if (!p.length) return '?';
return p.length === 1 ? p[0].slice(0, 2).toUpperCase() : (p[0][0] + p[p.length - 1][0]).toUpperCase();
});
const canSaveNegocio = computed(() => negocio.value.name.trim().length > 0 && negocio.value.type.length > 0);
function triggerLogoUpload() {
logoInput.value?.click();
}
function onLogoChange(e) {
const file = e.target.files?.[0];
if (!file) return;
negocio.value.logoFile = file;
negocio.value.logoPreview = URL.createObjectURL(file);
markStarted('negocio');
scheduleAutosave('negocio');
}
// ── ATENDIMENTO ────────────────────────────────────────────────────────────
const atendimento = ref({ mode: null });
const isConveniado = computed(() => atendimento.value.mode === 'convenio' || atendimento.value.mode === 'ambos');
const isParticular = computed(() => atendimento.value.mode === 'particular' || atendimento.value.mode === 'ambos');
const atendimentoModes = [
{ value: 'particular', label: 'Particular', icon: 'pi-wallet', desc: 'Apenas atendimentos privados.' },
{ value: 'convenio', label: 'Convênio', icon: 'pi-id-card', desc: 'Aceito planos de saúde.' },
{ value: 'ambos', label: 'Ambos', icon: 'pi-check-square', desc: 'Particular e convênio.' }
];
const newService = ref({ id: null, name: '', price: null, duration_min: 50 });
const newPlan = ref({ id: null, name: '', notes: '' });
function editService(svc) {
newService.value = { id: svc.id, name: svc.name, price: svc.price, duration_min: svc.duration_min ?? 50 };
}
function cancelEditService() {
newService.value = { id: null, name: '', price: null, duration_min: 50 };
}
function formatCurrency(v) {
if (v == null) return '—';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v);
}
// ── CONFETTI ───────────────────────────────────────────────────────────────
function confettiStyle(i) {
const colors = ['#7C6AF7', '#60A5FA', '#4ADE80', '#FB923C', '#F43F5E', '#C084FC', '#FCD34D'];
return { '--x': `${Math.random() * 100}%`, '--color': colors[i % colors.length], '--delay': `${(i * 0.12).toFixed(2)}s`, '--rot': `${Math.floor(Math.random() * 360)}deg` };
}
// ── LOAD ───────────────────────────────────────────────────────────────────
onMounted(async () => {
await tenant.ensureLoaded();
const {
data: { user }
} = await supabase.auth.getUser();
uid.value = user?.id || null;
if (!uid.value) return;
userEmail.value = user.email || '';
const { data: cfg } = await tenantDb().from('agenda_configuracoes').select('setup_concluido, setup_clinica_concluido, atendimento_mode').eq('owner_id', uid.value).maybeSingle();
if (cfg && (cfg.setup_concluido || cfg.setup_clinica_concluido || !!cfg.atendimento_mode)) {
router.replace('/pages/notfound');
return;
}
applyWizardPrimary(layoutConfig.primary);
await Promise.all([loadVoce(), loadNegocio(), loadAtendimento()]);
});
async function loadVoce() {
const { data } = await supabase.from('profiles').select('full_name,avatar_url,bio,nickname,work_description,work_description_other,phone').eq('id', uid.value).maybeSingle();
if (!data) return;
perfil.value.fullName = data.full_name || '';
perfil.value.bio = data.bio || '';
perfil.value.nickname = data.nickname || '';
perfil.value.workDescription = data.work_description || '';
perfil.value.workDescriptionOther = data.work_description_other || '';
perfil.value.whatsapp = data.phone || '';
perfil.value.avatarUrl = data.avatar_url || null;
perfil.value.avatarPreview = data.avatar_url || null;
if (data.full_name && data.nickname && data.phone) markSaved('voce');
else if (data.full_name) markStarted('voce');
}
async function loadNegocio() {
if (!tenantId.value) return;
// Fonte única: company_profiles
const { data } = await tenantDb().from('company_profiles').select('nome_fantasia,tipo_empresa,logo_url,cep,logradouro,numero,complemento,bairro,cidade,estado,telefone,email,site,redes_sociais').maybeSingle();
if (!data) return;
negocio.value.name = data.nome_fantasia || '';
negocio.value.type = data.tipo_empresa || '';
let resolvedLogo = data.logo_url || null;
if (resolvedLogo && !resolvedLogo.startsWith('http')) {
const {
data: { publicUrl }
} = supabase.storage.from('logos').getPublicUrl(resolvedLogo);
resolvedLogo = publicUrl;
}
negocio.value.logoUrl = resolvedLogo;
negocio.value.logoPreview = resolvedLogo;
negocio.value.cep = data.cep || '';
negocio.value.logradouro = data.logradouro || '';
negocio.value.numero = data.numero || '';
negocio.value.complemento = data.complemento || '';
negocio.value.bairro = data.bairro || '';
negocio.value.cidade = data.cidade || '';
negocio.value.estado = data.estado || '';
negocio.value.phone = data.telefone || '';
negocio.value.email = data.email || '';
negocio.value.siteUrl = data.site || '';
// instagram: busca em redes_sociais[]
const redes = Array.isArray(data.redes_sociais) ? data.redes_sociais : [];
const igRede = redes.find((r) => /instagram/i.test(r.name || ''));
negocio.value.instagram = igRede?.url || '';
if (data.nome_fantasia && data.tipo_empresa) markSaved('negocio');
else if (data.nome_fantasia) markStarted('negocio');
}
async function loadAtendimento() {
if (!uid.value) return;
const { data } = await tenantDb().from('agenda_configuracoes').select('atendimento_mode').eq('owner_id', uid.value).maybeSingle();
if (data?.atendimento_mode) {
atendimento.value.mode = data.atendimento_mode;
markSaved('atendimento');
}
await loadServices(uid.value);
await loadPlans(uid.value);
}
// ── SAVE VOCE ──────────────────────────────────────────────────────────────
async function saveVoce(silent = false) {
if (!canSaveVoce.value) {
if (!silent) toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome completo, apelido e WhatsApp são obrigatórios.', life: 3500 });
return;
}
saving.value.voce = true;
try {
let avatarUrl = perfil.value.avatarUrl;
if (perfil.value.avatarFile) {
const ext = perfil.value.avatarFile.name.split('.').pop();
const path = `${uid.value}/avatar.${ext}`;
const { error: upErr } = await supabase.storage.from('avatars').upload(path, perfil.value.avatarFile, { upsert: true });
if (upErr) throw upErr;
const {
data: { publicUrl }
} = supabase.storage.from('avatars').getPublicUrl(path);
avatarUrl = publicUrl;
perfil.value.avatarUrl = avatarUrl;
}
const wd = String(perfil.value.workDescription || '').trim() || null;
await supabase
.from('profiles')
.update({
full_name: perfil.value.fullName.trim(),
bio: perfil.value.bio.trim() || null,
avatar_url: avatarUrl || null,
nickname: perfil.value.nickname.trim() || null,
work_description: wd,
work_description_other: wd === 'outro' ? perfil.value.workDescriptionOther.trim() || null : null,
phone: perfil.value.whatsapp || null
})
.eq('id', uid.value);
await supabase.from('user_settings').upsert(
{
user_id: uid.value,
primary_color: layoutConfig.primary || 'noir',
theme_mode: isDarkNow() ? 'dark' : 'light',
updated_at: new Date().toISOString()
},
{ onConflict: 'user_id' }
);
markSaved('voce');
if (!silent) toast.add({ severity: 'success', summary: 'Perfil salvo!', detail: `Progresso: ${progressPct.value}%`, life: 2500 });
} catch (e) {
if (!silent) toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
saving.value.voce = false;
}
}
// ── SAVE NEGOCIO ──────────────────────────────────────────────────────────
async function saveNegocio(silent = false) {
if (!canSaveNegocio.value) {
if (!silent) toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome e tipo do negócio são obrigatórios.', life: 3000 });
return;
}
if (!tenantId.value) {
if (!silent) toast.add({ severity: 'error', summary: 'Erro', detail: 'Tenant não identificado. Tente recarregar.', life: 4000 });
return;
}
saving.value.negocio = true;
try {
// Upload logo se selecionado
let logoUrl = negocio.value.logoUrl;
if (negocio.value.logoFile) {
const ext = negocio.value.logoFile.name.split('.').pop();
const path = `${tenantId.value}/logo.${ext}`;
const { error: upErr } = await supabase.storage.from('logos').upload(path, negocio.value.logoFile, { upsert: true });
if (upErr) throw upErr;
const {
data: { publicUrl }
} = supabase.storage.from('logos').getPublicUrl(path);
logoUrl = publicUrl;
negocio.value.logoUrl = logoUrl;
}
// Instagram salvo em redes_sociais[]
const redes = [];
if (negocio.value.instagram?.trim()) {
redes.push({ name: 'Instagram', url: negocio.value.instagram.trim() });
}
const payload = {
nome_fantasia: negocio.value.name.trim() || null,
tipo_empresa: negocio.value.type || null,
logo_url: logoUrl || null,
cep: negocio.value.cep?.trim() || null,
logradouro: negocio.value.logradouro?.trim() || null,
numero: negocio.value.numero?.trim() || null,
complemento: negocio.value.complemento?.trim() || null,
bairro: negocio.value.bairro?.trim() || null,
cidade: negocio.value.cidade?.trim() || null,
estado: negocio.value.estado?.trim() || null,
telefone: negocio.value.phone?.trim() || null,
email: negocio.value.email?.trim() || null,
site: negocio.value.siteUrl?.trim() || null,
redes_sociais: redes
};
// Verificar se já existe registro para este tenant
const { data: existing } = await tenantDb().from('company_profiles').select('id').maybeSingle();
let error;
if (existing?.id) {
// Já existe — usar UPDATE direto pelo id
({ error } = await tenantDb().from('company_profiles').update(payload).eq('id', existing.id));
} else {
// Não existe — INSERT
({ error } = await tenantDb().from('company_profiles').insert(payload));
}
if (error) throw error;
markSaved('negocio');
if (!silent) toast.add({ severity: 'success', summary: 'Negócio salvo!', detail: `Progresso: ${progressPct.value}%`, life: 2500 });
} catch (e) {
if (!silent) toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
saving.value.negocio = false;
}
}
// ── SAVE ATENDIMENTO ──────────────────────────────────────────────────────
async function saveAtendimento(silent = false) {
if (!atendimento.value.mode) {
if (!silent) toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Selecione como você atende seus clientes.', life: 3000 });
return;
}
saving.value.atendimento = true;
try {
if (isParticular.value && services.value.length === 0) {
await saveService({ name: 'Atendimento padrão', price: 0, duration_min: 50, owner_id: uid.value, tenant_id: tenantId.value });
await loadServices(uid.value);
}
const { error } = await tenantDb().from('agenda_configuracoes').upsert(
{
owner_id: uid.value,
atendimento_mode: atendimento.value.mode,
updated_at: new Date().toISOString()
},
{ onConflict: 'owner_id' }
);
if (error) throw error;
markSaved('atendimento');
if (!silent) toast.add({ severity: 'success', summary: 'Atendimento configurado!', detail: '100% concluído — pronto para ativar!', life: 3000 });
} catch (e) {
if (!silent) toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
saving.value.atendimento = false;
}
}
// ── SERVICES & PLANS ──────────────────────────────────────────────────────
async function saveServiceItem() {
if (!newService.value.name.trim()) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome obrigatório.', life: 3000 });
return;
}
try {
const { id: svcId, ...svcFields } = newService.value;
const payload = { ...svcFields, price: svcFields.price ?? 0, owner_id: uid.value, tenant_id: tenantId.value };
if (svcId) payload.id = svcId;
await saveService(payload);
await loadServices(uid.value);
cancelEditService();
if (atendimento.value.mode) await saveAtendimento(true);
toast.add({ severity: 'success', summary: svcId ? 'Serviço atualizado' : 'Serviço adicionado', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
}
}
async function removeServiceItem(id) {
try {
await removeSvc(id);
toast.add({ severity: 'info', summary: 'Serviço removido', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
}
}
async function savePlanItemWizard() {
if (!newPlan.value.name.trim()) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome obrigatório.', life: 3000 });
return;
}
try {
await savePlanItem({ ...newPlan.value, owner_id: uid.value, tenant_id: tenantId.value });
newPlan.value = { id: null, name: '', notes: '' };
await loadPlans(uid.value);
if (atendimento.value.mode) await saveAtendimento(true);
toast.add({ severity: 'success', summary: 'Convênio adicionado', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
}
}
async function removePlanItemWizard(id) {
try {
await removePlanItem(id);
await loadPlans(uid.value);
toast.add({ severity: 'info', summary: 'Convênio removido', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
}
}
// ── FINISH ─────────────────────────────────────────────────────────────────
async function onFinish() {
if (!canFinish.value) return;
finishing.value = true;
try {
const now = new Date().toISOString();
const { error: finErr } = await tenantDb().from('agenda_configuracoes').upsert({ owner_id: uid.value, setup_concluido: true, setup_concluido_em: now }, { onConflict: 'owner_id' });
if (finErr) throw finErr;
// Fecha o dialog ANTES de mostrar a tela de parabéns
dialogVisible.value = false;
// Pequeno delay para o dialog fechar suavemente
setTimeout(() => {
done.value = true;
}, 250);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
finishing.value = false;
}
}
</script>
<template>
<div ref="swRootEl" class="sw-root">
<Toast />
<!--
LANDING PAGE fundo gradiente + grid
-->
<div v-if="!done" class="sw-landing">
<!-- Fundo gradiente -->
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grid overlay -->
<div class="absolute inset-0 opacity-[0.08]" style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(white 1px, transparent 1px); background-size: 48px 48px" />
<!-- Noise -->
<div class="absolute inset-0 sw-noise" />
<!-- Orbs decorativos -->
<div class="sw-orb sw-orb--1" />
<div class="sw-orb sw-orb--2" />
<!-- Conteúdo centralizado -->
<div class="sw-landing__content">
<!-- Brand -->
<div class="sw-landing__brand">
<span class="sw-landing__psi">Ψ</span>
<span class="sw-landing__brand-name">Agência PSI</span>
<span class="sw-landing__brand-sep">·</span>
<span class="sw-landing__brand-label">Ativação</span>
</div>
<!-- Texto principal -->
<h1 class="sw-landing__title">
👋 Olá,
<span class="sw-landing__title-name">{{ perfil.fullName?.split(' ')[0] || 'bem-vindo' }}</span>
</h1>
<p class="sw-landing__sub">Vamos ativar seu sistema em {{ steps.length }} passos rápidos.</p>
<!-- Steps resumo -->
<div class="sw-landing__steps">
<div v-for="(step, i) in steps" :key="step.id" class="sw-landing__step">
<div class="sw-landing__step-icon" :style="{ background: `color-mix(in srgb,${step.color} 22%,transparent)`, border: `1px solid color-mix(in srgb,${step.color} 40%,transparent)`, color: step.color }">
<i :class="`pi ${step.icon} text-xl`" />
</div>
<div>
<div class="sw-landing__step-label">{{ step.label }}</div>
<div class="sw-landing__step-desc">{{ step.description }}</div>
</div>
<div v-if="i < steps.length - 1" class="sw-landing__step-arrow"><i class="pi pi-chevron-right" /></div>
</div>
</div>
<!-- Botão CTA -->
<button class="sw-landing__cta" @click="openDialog">
<i class="pi pi-bolt text-lg" />
Iniciar Ativação
<i class="pi pi-arrow-right text-lg" />
</button>
<!-- Progress se iniciou -->
<div v-if="progressPct > 0" class="sw-landing__progress-hint">
<div class="sw-landing__progress-bar">
<div class="sw-landing__progress-fill" :style="{ width: Math.min(progressPct, 100) + '%' }" />
</div>
<span>{{ Math.min(progressPct, 130) }}% concluído</span>
</div>
</div>
<!-- Theme toggle -->
<button class="sw-theme-btn" @click="toggleTheme" :title="isDarkTheme ? 'Modo claro' : 'Modo escuro'">
<i :class="['pi text-sm', isDarkTheme ? 'pi-sun' : 'pi-moon']" />
</button>
</div>
<!--
DONE SCREEN
-->
<Transition name="sw-fade">
<div v-if="done" class="sw-done">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<div class="absolute inset-0 opacity-[0.08]" style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(white 1px, transparent 1px); background-size: 48px 48px" />
<!-- Confetes -->
<div class="sw-done__confetti" aria-hidden="true">
<span v-for="i in 60" :key="i" class="sw-confetti" :style="confettiStyle(i)" />
</div>
<div class="sw-done__card">
<div class="sw-done__emoji">🎉</div>
<h1 class="sw-done__title text-white">
Parabéns, <span class="sw-done__name">{{ firstName }}</span
>!
</h1>
<p class="sw-done__sub">Seu sistema está ativo e pronto para uso.<br />Bem-vindo à Agência PSI!</p>
<div class="sw-done__badges">
<span v-for="s in steps" :key="s.id" v-show="stepStates[s.id] === 'saved'" class="sw-done__badge" :style="{ '--c': s.color }"> <i :class="`pi ${s.icon}`" /> {{ s.label }} </span>
</div>
<div class="sw-done__actions">
<button class="sw-cta-btn" @click="router.push(agendaRoute)"><i class="pi pi-home" /> Ir para Home</button>
<button class="sw-ghost-btn" @click="router.push('/configuracoes/agenda')"><i class="pi pi-sliders-h" /> Continuar configurando</button>
</div>
</div>
</div>
</Transition>
<!--
DIALOG WIZARD
-->
<Dialog
v-model:visible="dialogVisible"
:modal="false"
:closable="false"
:draggable="false"
:style="{ width: '92vw', maxWidth: '900px', margin: '0' }"
:pt="{
root: { class: 'sw-dialog' },
header: { class: 'sw-dialog__header' },
content: { class: 'sw-dialog__content' },
footer: { class: 'sw-dialog__footer' }
}"
>
<template #header>
<!-- Header glassmorphism: ícone + label + título + contador + autosave -->
<div class="sw-dialog__head-inner">
<div class="sw-dialog__head-left">
<!-- Ícone + label do step -->
<div class="sw-dialog__head-identity">
<div
class="sw-dialog__head-avatar"
:style="{ background: `color-mix(in srgb,${currentStep?.color} 18%,transparent)`, border: `1px solid color-mix(in srgb,${currentStep?.color} 35%,transparent)`, color: currentStep?.color }"
>
<i :class="`pi ${currentStep?.icon} text-base`" />
</div>
<div>
<div class="sw-dialog__head-title">{{ currentStep?.label }}</div>
<div class="sw-dialog__head-sub">{{ currentStep?.description }}</div>
</div>
</div>
</div>
<!-- Direita: contador + autosave -->
<div class="sw-dialog__head-right">
<!-- Autosave -->
<div class="sw-dialog__head-save">
<i v-if="isSaving" class="pi pi-spin pi-spinner" style="color: var(--sw-primary)" />
<i v-else-if="stepStates[currentStep?.id] === 'saved'" class="pi pi-check-circle" style="color: #10b981" />
<i v-else class="pi pi-bolt" style="opacity: 0.3" />
<span>{{ isSaving ? 'Salvando...' : stepStates[currentStep?.id] === 'saved' ? 'Salvo' : 'Auto' }}</span>
</div>
<!-- Contador de passos -->
<div class="sw-dialog__head-counter">
<span class="sw-dialog__head-counter-cur" :style="{ color: currentStep?.color }">{{ currentIdx + 1 }}</span>
<span class="sw-dialog__head-counter-sep">/</span>
<span class="sw-dialog__head-counter-tot">{{ steps.length }}</span>
</div>
</div>
</div>
<!-- Barra de progresso -->
<div class="sw-dialog__progress">
<div class="sw-dialog__progress-fill" :style="{ width: Math.min(progressPct, 100) + '%', backgroundColor: currentStep?.color }" />
</div>
</template>
<!-- BODY: sidebar steps + conteúdo -->
<div class="sw-dialog__body">
<!-- SIDEBAR -->
<aside class="sw-dialog__sidebar">
<button
v-for="(step, idx) in steps"
:key="step.id"
class="sw-sidebar-step"
:class="{
'sw-sidebar-step--active': currentIdx === idx,
'sw-sidebar-step--saved': stepStates[step.id] === 'saved',
'sw-sidebar-step--started': stepStates[step.id] === 'started' && currentIdx !== idx
}"
:style="currentIdx === idx ? { '--c': step.color } : {}"
@click="navigateTo(idx)"
>
<!-- Step indicator -->
<div class="sw-sidebar-step__indicator">
<span v-if="stepStates[step.id] === 'saved'" class="sw-sidebar-step__check">
<i class="pi pi-check" />
</span>
<span v-else class="sw-sidebar-step__num">{{ idx + 1 }}</span>
<!-- Linha conectora -->
<div v-if="idx < steps.length - 1" class="sw-sidebar-step__line" :class="{ 'sw-sidebar-step__line--done': stepStates[step.id] === 'saved' }" />
</div>
<!-- Label -->
<div class="sw-sidebar-step__text">
<span class="sw-sidebar-step__label">{{ step.label }}</span>
<span class="sw-sidebar-step__desc">{{ step.description }}</span>
</div>
</button>
</aside>
<!-- CONTEÚDO DO STEP -->
<div class="sw-dialog__main">
<Stepper :value="currentIdx" @update:value="navigateTo($event)">
<StepList class="!hidden" />
<StepPanels class="!bg-transparent !border-0 !p-0">
<!-- STEP 1: VOCÊ -->
<StepPanel :value="0">
<div class="sw-step-content">
<!-- Avatar -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-image" /><span>Foto de perfil</span><span class="sw-pts">+10%</span></div>
<div class="sw-section__body sw-avatar-row">
<div class="sw-avatar" @click="triggerAvatarUpload">
<img v-if="perfil.avatarPreview" :src="perfil.avatarPreview" class="sw-avatar__img" />
<span v-else class="sw-avatar__initials">{{ initials }}</span>
<div class="sw-avatar__overlay"><i class="pi pi-upload" /></div>
</div>
<input ref="avatarInput" type="file" accept="image/*" class="hidden" @change="onAvatarChange" />
<div>
<p class="sw-avatar-hint__title">Clique para enviar sua foto</p>
<span class="sw-avatar-hint__sub">JPG/PNG/WebP · até 5MB</span>
</div>
</div>
</div>
<!-- Identidade -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-user" /><span>Identidade</span></div>
<div class="sw-section__body sw-fields">
<div>
<label class="sw-label">Nome completo <span class="sw-req">*</span></label>
<InputText
v-model="perfil.fullName"
placeholder="Ex: Leonardo Marques"
class="w-full"
:invalid="!!fieldErrors.fullName"
@input="
markStarted('voce');
scheduleAutosave('voce');
clearErr('fullName');
"
/>
<Message v-if="fieldErrors.fullName" severity="error" size="small" variant="simple">{{ fieldErrors.fullName }}</Message>
</div>
<div>
<label class="sw-label">Como te chamamos? <span class="sw-req">*</span></label>
<InputText
v-model="perfil.nickname"
placeholder="Ex: Leo"
class="w-full"
:invalid="!!fieldErrors.nickname"
@input="
markStarted('voce');
scheduleAutosave('voce');
clearErr('nickname');
"
/>
<Message v-if="fieldErrors.nickname" severity="error" size="small" variant="simple">{{ fieldErrors.nickname }}</Message>
</div>
</div>
</div>
<!-- Contato -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-phone" /><span>Contato</span></div>
<div class="sw-section__body sw-fields sw-fields--2">
<div>
<label class="sw-label">WhatsApp <span class="sw-req">*</span></label>
<InputMask
v-model="perfil.whatsapp"
mask="(99) 99999-9999"
:autoClear="false"
class="w-full"
placeholder="(99) 99999-9999"
:invalid="!!fieldErrors.whatsapp"
@update:modelValue="
markStarted('voce');
scheduleAutosave('voce');
clearErr('whatsapp');
"
/>
<Message v-if="fieldErrors.whatsapp" severity="error" size="small" variant="simple">{{ fieldErrors.whatsapp }}</Message>
<p class="sw-hint sw-hint--trust"><i class="pi pi-lock" /> Apenas para notificações importantes.</p>
</div>
<div>
<label class="sw-label">E-mail</label>
<InputText :modelValue="userEmail" class="w-full" disabled />
</div>
</div>
</div>
<!-- Profissão -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-briefcase" /><span>Profissão</span></div>
<div class="sw-section__body sw-fields">
<div>
<label class="sw-label">O que melhor descreve seu trabalho?</label>
<Select v-model="perfil.workDescription" :options="workDescriptionOptions" optionLabel="label" optionValue="value" class="w-full" placeholder="Selecione..." @change="markStarted('voce')" />
</div>
<Transition name="sw-slide">
<div v-if="perfil.workDescription === 'outro'">
<label class="sw-label">Descreva sua atuação</label>
<InputText v-model="perfil.workDescriptionOther" placeholder="Ex: Terapeuta Sistêmica" class="w-full" @input="markStarted('voce')" />
</div>
</Transition>
<div>
<label class="sw-label">Bio curta <span class="sw-pts">+5%</span></label>
<Textarea
v-model="perfil.bio"
:maxlength="300"
rows="3"
placeholder="Conte um pouco sobre você..."
class="w-full"
@input="
markStarted('voce');
scheduleAutosave('voce');
"
/>
<span class="sw-counter">{{ perfil.bio.length }}/300</span>
</div>
</div>
</div>
<!-- Aparência -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-palette" /><span>Aparência</span><span class="sw-pts">+4%</span></div>
<div class="sw-section__body sw-fields">
<div>
<label class="sw-label">Cor principal</label>
<div class="sw-swatches">
<button
v-for="pc in primaryColors"
:key="pc.name"
type="button"
:title="pc.name"
class="sw-swatch"
:class="{ 'sw-swatch--active': layoutConfig.primary === pc.name }"
:style="{ backgroundColor: pc.name === 'noir' ? 'var(--text-color,#18181b)' : pc.palette['500'] }"
@click="updatePrimaryColor(pc)"
/>
</div>
</div>
<div>
<label class="sw-label">Tema</label>
<SelectButton :modelValue="themeModel" :options="themeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" @update:modelValue="onThemeChange" />
</div>
</div>
</div>
</div>
</StepPanel>
<!-- STEP 2: NEGÓCIO -->
<StepPanel :value="1">
<div class="sw-step-content">
<!-- Logo -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-image" /><span>Logomarca</span><span class="sw-pts">+10%</span></div>
<div class="sw-section__body sw-avatar-row">
<div class="sw-avatar" @click="triggerLogoUpload">
<img v-if="negocio.logoPreview" :src="negocio.logoPreview" class="sw-avatar__img" />
<span v-else class="sw-avatar__initials">{{ negocioInitials }}</span>
<div class="sw-avatar__overlay"><i class="pi pi-upload" /></div>
</div>
<input ref="logoInput" type="file" accept="image/*" class="hidden" @change="onLogoChange" />
<div>
<p class="sw-avatar-hint__title">PNG/SVG recomendado</p>
<span class="sw-avatar-hint__sub">até 2MB</span>
</div>
</div>
</div>
<!-- Identificação -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-building" /><span>Identificação</span></div>
<div class="sw-section__body sw-fields">
<div>
<label class="sw-label">Nome do negócio <span class="sw-req">*</span></label>
<InputText
v-model="negocio.name"
placeholder="Ex: Consultório Bem Estar"
class="w-full"
:invalid="!!fieldErrors.negocioName"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
clearErr('negocioName');
"
/>
<Message v-if="fieldErrors.negocioName" severity="error" size="small" variant="simple">{{ fieldErrors.negocioName }}</Message>
</div>
<div>
<label class="sw-label">Tipo de negócio <span class="sw-req">*</span></label>
<div class="sw-type-grid" :class="{ 'sw-type-grid--error': !!fieldErrors.negocioType }">
<button
v-for="bt in businessTypes"
:key="bt.value"
type="button"
class="sw-type-btn"
:class="{ 'sw-type-btn--active': negocio.type === bt.value }"
@click="
negocio.type = bt.value;
markStarted('negocio');
scheduleAutosave('negocio');
clearErr('negocioType');
"
>
<i :class="`pi ${bt.icon} text-lg`" />
<span>{{ bt.label }}</span>
<i v-if="negocio.type === bt.value" class="pi pi-check-circle sw-type-btn__check" />
</button>
</div>
<Message v-if="fieldErrors.negocioType" severity="error" size="small" variant="simple">{{ fieldErrors.negocioType }}</Message>
</div>
</div>
</div>
<!-- Endereço -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-map-marker" /><span>Endereço</span><span class="sw-pts">+5%</span></div>
<div class="sw-section__body sw-fields">
<div class="sw-fields--3">
<div>
<label class="sw-label">CEP</label>
<div class="relative">
<InputMask v-model="negocio.cep" mask="99999-999" :autoClear="false" placeholder="00000-000" class="w-full" @blur="onCepBlur" @update:modelValue="markStarted('negocio')" />
<i v-if="fetchingCep" class="pi pi-spin pi-spinner absolute right-3 top-1/2 -translate-y-1/2 text-xs" style="color: var(--sw-primary)" />
</div>
</div>
<div class="sw-span-2">
<label class="sw-label">Logradouro</label>
<InputText
v-model="negocio.logradouro"
placeholder="Rua, Avenida..."
class="w-full"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
</div>
</div>
<div class="sw-fields--4">
<div>
<label class="sw-label">Número</label>
<InputText
v-model="negocio.numero"
placeholder="123"
class="w-full"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
</div>
<div>
<label class="sw-label">Complemento</label>
<InputText
v-model="negocio.complemento"
placeholder="Sala 1"
class="w-full"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
</div>
<div>
<label class="sw-label">Bairro</label>
<InputText
v-model="negocio.bairro"
placeholder="Bairro"
class="w-full"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
</div>
<div>
<label class="sw-label">Cidade / UF</label>
<div class="flex gap-2">
<InputText
v-model="negocio.cidade"
placeholder="Cidade"
class="w-full"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
<InputText
v-model="negocio.estado"
placeholder="UF"
class="w-14 shrink-0"
maxlength="2"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Contato Digital -->
<div class="sw-section">
<div class="sw-section__head"><i class="pi pi-globe" /><span>Contato e Presença Digital</span><span class="sw-pts">+5%</span></div>
<div class="sw-section__body sw-fields">
<div class="sw-fields--2">
<div>
<label class="sw-label">Telefone</label>
<InputMask
v-model="negocio.phone"
mask="(99) 99999-9999"
:autoClear="false"
placeholder="(11) 99999-9999"
class="w-full"
@update:modelValue="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
</div>
<div>
<label class="sw-label">E-mail de contato</label>
<InputText
v-model="negocio.email"
type="email"
placeholder="contato@clinica.com"
class="w-full"
:invalid="negocio.email.length > 0 && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(negocio.email)"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
<Message v-if="negocio.email.length > 0 && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(negocio.email)" severity="error" size="small" variant="simple">E-mail inválido.</Message>
</div>
</div>
<div class="sw-fields--2">
<div>
<label class="sw-label">Site</label>
<InputText
v-model="negocio.siteUrl"
type="url"
placeholder="https://seunegocio.com"
class="w-full"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
</div>
<div>
<label class="sw-label">Instagram</label>
<InputText
v-model="negocio.instagram"
placeholder="@seuperfil"
class="w-full"
@input="
markStarted('negocio');
scheduleAutosave('negocio');
"
/>
</div>
</div>
</div>
</div>
</div>
</StepPanel>
<!-- STEP 3: ATENDIMENTO -->
<StepPanel :value="2">
<div class="sw-step-content">
<!-- Modalidade -->
<div class="sw-section">
<div class="sw-section__head">
<i class="pi pi-question-circle" /><span>Como você atende? <span class="sw-req">*</span></span>
</div>
<div class="sw-section__body">
<div class="sw-mode-grid">
<button
v-for="m in atendimentoModes"
:key="m.value"
type="button"
class="sw-mode-btn"
:class="{ 'sw-mode-btn--active': atendimento.mode === m.value }"
@click="
atendimento.mode = m.value;
saveAtendimento(true);
"
>
<i :class="`pi ${m.icon} text-2xl`" />
<span class="sw-mode-btn__label">{{ m.label }}</span>
<span class="sw-mode-btn__desc">{{ m.desc }}</span>
<i v-if="atendimento.mode === m.value" class="pi pi-check-circle sw-mode-btn__check" />
</button>
</div>
</div>
</div>
<!-- Serviços -->
<Transition name="sw-slide">
<div v-if="isParticular" class="sw-section">
<div class="sw-section__head"><i class="pi pi-tag" /><span>Serviços</span></div>
<div class="sw-section__body sw-fields">
<div v-if="services.length" class="sw-list">
<div v-for="svc in services" :key="svc.id" class="sw-list__item">
<div class="sw-list__info">
<span class="sw-list__name">{{ svc.name }}</span>
<span class="sw-list__meta">{{ formatCurrency(svc.price) }} · {{ svc.duration_min }}min</span>
</div>
<div class="flex gap-1">
<button class="sw-icon-btn" @click="editService(svc)"><i class="pi pi-pencil text-xs" /></button>
<button class="sw-icon-btn sw-icon-btn--danger" @click="removeServiceItem(svc.id)"><i class="pi pi-trash text-xs" /></button>
</div>
</div>
</div>
<div v-else class="sw-empty"><i class="pi pi-tag" /> Nenhum serviço ainda</div>
<div class="sw-add-form">
<label class="sw-label">{{ newService.id ? 'Editar' : 'Adicionar' }} serviço</label>
<div class="sw-fields--3 mb-2">
<div class="sw-span-2">
<InputText v-model="newService.name" placeholder="Nome do serviço" class="w-full" @input="markStarted('atendimento')" />
</div>
<div>
<InputNumber v-model="newService.price" mode="currency" currency="BRL" locale="pt-BR" placeholder="R$ 0,00" class="w-full" />
</div>
</div>
<div class="sw-fields--2">
<InputNumber v-model="newService.duration_min" :min="5" :max="480" suffix=" min" placeholder="Duração (min)" class="w-full" />
<div class="flex gap-2">
<button class="sw-btn sw-btn--primary flex-1" @click="saveServiceItem"><i class="pi pi-check" /> {{ newService.id ? 'Atualizar' : 'Adicionar' }}</button>
<button v-if="newService.id" class="sw-btn sw-btn--ghost" @click="cancelEditService"><i class="pi pi-times" /></button>
</div>
</div>
</div>
</div>
</div>
</Transition>
<!-- Convênios -->
<Transition name="sw-slide">
<div v-if="isConveniado" class="sw-section">
<div class="sw-section__head"><i class="pi pi-heart" /><span>Convênios</span></div>
<div class="sw-section__body sw-fields">
<div v-if="insurancePlans.length" class="sw-list">
<div v-for="plan in insurancePlans" :key="plan.id" class="sw-list__item">
<div class="sw-list__info">
<span class="sw-list__name">{{ plan.name }}</span>
<span v-if="plan.notes" class="sw-list__meta">{{ plan.notes }}</span>
</div>
<button class="sw-icon-btn sw-icon-btn--danger" @click="removePlanItemWizard(plan.id)"><i class="pi pi-trash text-xs" /></button>
</div>
</div>
<div v-else class="sw-empty"><i class="pi pi-heart" /> Nenhum convênio ainda</div>
<div class="sw-add-form">
<label class="sw-label">Adicionar convênio</label>
<div class="sw-fields--2 mb-2">
<InputText v-model="newPlan.name" placeholder="Ex: Unimed" class="w-full" @input="markStarted('atendimento')" />
<InputText v-model="newPlan.notes" placeholder="Observação (opcional)" class="w-full" />
</div>
<button class="sw-btn sw-btn--primary" @click="savePlanItemWizard"><i class="pi pi-plus" /> Adicionar</button>
</div>
</div>
</div>
</Transition>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</div>
</div>
<!-- Footer do dialog -->
<template #footer>
<div class="sw-dialog__foot-inner">
<button class="sw-nav-btn" :disabled="currentIdx === 0" @click="navigateTo(currentIdx - 1)"><i class="pi pi-chevron-left" /> Anterior</button>
<!-- Autosave indicator centralizado -->
<div class="sw-autosave-indicator">
<i v-if="isSaving" class="pi pi-spin pi-spinner" style="color: var(--sw-primary)" />
<i v-else-if="stepStates[currentStep?.id] === 'saved'" class="pi pi-check-circle" style="color: #10b981" />
<i v-else class="pi pi-bolt" style="opacity: 0.3" />
<span>{{ isSaving ? 'Salvando...' : stepStates[currentStep?.id] === 'saved' ? 'Salvo automaticamente' : 'Salvamento automático' }}</span>
</div>
<button v-if="currentIdx < steps.length - 1" class="sw-next-btn" @click="navigateTo(currentIdx + 1)">Próximo <i class="pi pi-chevron-right" /></button>
<button v-else class="sw-finish-btn" :class="{ 'sw-finish-btn--ready': canFinish }" :disabled="!canFinish || finishing" @click="onFinish">
<i :class="finishing ? 'pi pi-spin pi-spinner' : 'pi pi-check-circle'" />
{{ finishing ? 'Ativando...' : 'Ativar Sistema' }}
</button>
</div>
</template>
</Dialog>
</div>
</template>
<style>
/* ══════════════════════════════════════════
ROOT
══════════════════════════════════════════ */
.sw-root {
--sw-primary: #6154e8;
--sw-primary-dim: rgba(97, 84, 232, 0.12);
--sw-primary-border: rgba(97, 84, 232, 0.3);
--sw-primary-rgb: 97, 84, 232;
}
/* ══════════════════════════════════════════
LANDING
══════════════════════════════════════════ */
.sw-landing {
position: relative;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.sw-noise {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
background-size: 200px;
}
.sw-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
}
.sw-orb--1 {
width: 500px;
height: 500px;
top: -120px;
right: -120px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.12), transparent 70%);
animation: swOrb 18s ease-in-out infinite alternate;
}
.sw-orb--2 {
width: 400px;
height: 400px;
bottom: -100px;
left: -80px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.07), transparent 70%);
animation: swOrb 24s ease-in-out infinite alternate-reverse;
}
@keyframes swOrb {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(30px, 20px) scale(1.06);
}
}
.sw-landing__content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 2rem 1.5rem;
max-width: 640px;
width: 100%;
animation: swFadeUp 0.6s ease both;
}
.sw-landing__brand {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 2rem;
}
.sw-landing__psi {
font-size: 1.5rem;
font-weight: 700;
color: white;
text-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
line-height: 1;
}
.sw-landing__brand-name {
font-size: 0.9rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.sw-landing__brand-sep {
opacity: 0.4;
color: white;
}
.sw-landing__brand-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.6);
}
.sw-landing__title {
font-size: clamp(2rem, 5vw, 3.2rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
color: white;
margin: 0 0 1rem;
}
.sw-landing__title-name {
display: inline-block;
background: linear-gradient(135deg, #e0d7ff, #bfdbfe);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sw-landing__sub {
font-size: 1rem;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 2rem;
line-height: 1.6;
}
/* Steps resumo na landing */
.sw-landing__steps {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 2.5rem;
}
@media (max-width: 480px) {
.sw-landing__steps {
flex-direction: column;
gap: 0.75rem;
}
}
.sw-landing__step {
display: flex;
align-items: center;
gap: 0.625rem;
}
.sw-landing__step-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.sw-landing__step-label {
font-size: 0.95rem;
font-weight: 700;
color: white;
}
.sw-landing__step-desc {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
margin-top: 2px;
}
.sw-landing__step-arrow {
color: rgba(255, 255, 255, 0.3);
font-size: 0.7rem;
margin: 0 0.25rem;
}
@media (max-width: 480px) {
.sw-landing__step-arrow {
display: none;
}
}
/* CTA */
.sw-landing__cta {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 1.1rem 2.75rem;
border-radius: 1rem;
font-size: 1.15rem;
font-weight: 800;
cursor: pointer;
border: none;
background: white;
color: #4c1d95;
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.15);
transition: all 0.2s;
animation: swFadeUp 0.6s 0.2s ease both;
letter-spacing: -0.01em;
}
.sw-landing__cta:hover {
transform: translateY(-3px);
box-shadow:
0 18px 50px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.25);
}
/* Progress hint na landing */
.sw-landing__progress-hint {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1.5rem;
color: rgba(255, 255, 255, 0.6);
font-size: 0.75rem;
width: 100%;
max-width: 300px;
}
.sw-landing__progress-bar {
flex: 1;
height: 3px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.2);
}
.sw-landing__progress-fill {
height: 100%;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.8);
transition: width 0.6s ease;
}
/* ══════════════════════════════════════════
THEME BUTTON
══════════════════════════════════════════ */
.sw-theme-btn {
position: absolute;
top: 1.25rem;
right: 1.25rem;
z-index: 10;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.sw-theme-btn:hover {
background: rgba(255, 255, 255, 0.25);
color: white;
}
/* ══════════════════════════════════════════
DIALOG — sólido com shadow forte
══════════════════════════════════════════ */
.sw-dialog {
border-radius: 1.25rem !important;
overflow: hidden;
background: var(--surface-card) !important;
border: 1px solid var(--surface-border) !important;
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.2),
0 16px 40px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(255, 255, 255, 0.04) !important;
}
.sw-dialog__header {
padding: 0 !important;
border-bottom: 1px solid var(--surface-border) !important;
background: var(--surface-ground) !important;
}
.sw-dialog__content {
padding: 0 !important;
overflow: hidden !important;
background: var(--surface-card) !important;
}
.sw-dialog__footer {
padding: 0 !important;
border-top: 1px solid var(--surface-border) !important;
background: var(--surface-ground) !important;
}
/* ── Header ── */
.sw-dialog__head-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem 0.875rem;
gap: 1rem;
}
.sw-dialog__head-left {
flex: 1;
min-width: 0;
}
.sw-dialog__head-identity {
display: flex;
align-items: center;
gap: 0.875rem;
}
.sw-dialog__head-avatar {
width: 42px;
height: 42px;
border-radius: 12px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.sw-dialog__head-title {
font-size: 1.1rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text-color);
margin: 0 0 0.1rem;
line-height: 1.2;
}
.sw-dialog__head-sub {
font-size: 0.78rem;
color: var(--text-color-secondary);
margin: 0;
}
.sw-dialog__head-right {
display: flex;
align-items: center;
gap: 0.875rem;
flex-shrink: 0;
}
.sw-dialog__head-save {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--text-color-secondary);
}
.sw-dialog__head-counter {
display: flex;
align-items: baseline;
gap: 0.15rem;
background: var(--surface-border);
border-radius: 0.5rem;
padding: 0.35rem 0.7rem;
border: 1px solid var(--surface-border);
}
.sw-dialog__head-counter-cur {
font-size: 1.1rem;
font-weight: 900;
line-height: 1;
transition: color 0.3s;
}
.sw-dialog__head-counter-sep {
font-size: 0.75rem;
opacity: 0.3;
font-weight: 700;
}
.sw-dialog__head-counter-tot {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-color-secondary);
}
/* Barra de progresso no topo do dialog */
.sw-dialog__progress {
height: 3px;
background: var(--surface-border);
overflow: hidden;
}
.sw-dialog__progress-fill {
height: 100%;
transition:
width 0.6s ease,
background-color 0.4s;
}
/* ── Body: sidebar + main ── */
.sw-dialog__body {
display: flex;
height: calc(100dvh - 220px);
max-height: 560px;
overflow: hidden;
}
@media (max-width: 640px) {
.sw-dialog__body {
flex-direction: column;
height: auto;
max-height: none;
}
}
/* ── Sidebar ── */
.sw-dialog__sidebar {
width: 220px;
min-width: 220px;
flex-shrink: 0;
padding: 1.25rem 1rem;
border-right: 1px solid var(--surface-border);
background: var(--surface-ground);
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
}
@media (max-width: 640px) {
.sw-dialog__sidebar {
width: 100%;
min-width: unset;
flex-direction: row;
border-right: none;
border-bottom: 1px solid var(--surface-border);
padding: 0.75rem 1rem;
overflow-x: auto;
overflow-y: hidden;
gap: 0;
}
}
.sw-sidebar-step {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 0.5rem;
border-radius: 0.625rem;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
text-align: left;
width: 100%;
transition: all 0.18s;
position: relative;
}
.sw-sidebar-step:hover {
background: var(--surface-card);
}
.sw-sidebar-step--active {
background: color-mix(in srgb, var(--c, #6154e8) 10%, transparent) !important;
border-color: color-mix(in srgb, var(--c, #6154e8) 25%, transparent) !important;
}
@media (max-width: 640px) {
.sw-sidebar-step {
flex-direction: column;
align-items: center;
min-width: 80px;
text-align: center;
padding: 0.5rem;
}
}
.sw-sidebar-step__indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
flex-shrink: 0;
}
@media (max-width: 640px) {
.sw-sidebar-step__indicator {
flex-direction: row;
}
}
.sw-sidebar-step__check,
.sw-sidebar-step__num {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 700;
flex-shrink: 0;
border: 2px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color-secondary);
transition: all 0.2s;
}
.sw-sidebar-step--active .sw-sidebar-step__num {
background: var(--c, #6154e8);
border-color: var(--c, #6154e8);
color: white;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--c, #6154e8) 20%, transparent);
}
.sw-sidebar-step--saved .sw-sidebar-step__check {
background: #10b981;
border-color: #10b981;
color: white;
font-size: 0.6rem;
}
.sw-sidebar-step__line {
width: 2px;
height: 24px;
margin: 3px 0;
background: var(--surface-border);
border-radius: 9999px;
transition: background 0.3s;
}
.sw-sidebar-step__line--done {
background: #10b981;
}
@media (max-width: 640px) {
.sw-sidebar-step__line {
display: none;
}
}
.sw-sidebar-step__text {
flex: 1;
min-width: 0;
}
.sw-sidebar-step__label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color);
}
.sw-sidebar-step__desc {
display: block;
font-size: 0.68rem;
color: var(--text-color-secondary);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.sw-sidebar-step__desc {
display: none;
}
}
/* ── Main scroll area ── */
.sw-dialog__main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 1.25rem 1.5rem;
}
@media (max-width: 480px) {
.sw-dialog__main {
padding: 1rem;
}
}
/* ── Step content ── */
.sw-step-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ══════════════════════════════════════════
SECTIONS (dentro do dialog)
══════════════════════════════════════════ */
.sw-section {
border: 1px solid var(--surface-border);
border-radius: 0.875rem;
overflow: hidden;
background: var(--surface-card);
}
.sw-section__head {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.875rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.78rem;
font-weight: 700;
color: var(--text-color);
}
.sw-section__head i {
color: var(--primary-color, #6154e8);
opacity: 0.75;
font-size: 0.75rem;
}
.sw-section__body {
padding: 0.875rem;
}
/* ══════════════════════════════════════════
FIELDS / LABELS
══════════════════════════════════════════ */
.sw-fields {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.sw-fields--2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.875rem;
}
.sw-fields--3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
}
.sw-fields--4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
.sw-span-2 {
grid-column: span 2;
}
.mb-2 {
margin-bottom: 0.5rem;
}
@media (max-width: 640px) {
.sw-fields--2,
.sw-fields--3,
.sw-fields--4 {
grid-template-columns: 1fr;
}
.sw-span-2 {
grid-column: span 1;
}
}
.sw-label {
display: block;
margin-bottom: 0.3rem;
font-size: 0.73rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.sw-req {
color: #f87171;
}
.sw-pts {
display: inline-flex;
margin-left: auto;
font-size: 0.6rem;
font-weight: 700;
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
padding: 0.12rem 0.4rem;
border-radius: 9999px;
}
.sw-hint {
margin-top: 0.35rem;
font-size: 0.68rem;
color: var(--text-color-secondary);
display: flex;
align-items: center;
gap: 0.3rem;
}
.sw-hint--trust {
color: #10b981;
}
.sw-counter {
display: block;
text-align: right;
margin-top: 0.2rem;
font-size: 0.68rem;
color: var(--text-color-secondary);
}
/* ══════════════════════════════════════════
AVATAR
══════════════════════════════════════════ */
.sw-avatar-row {
display: flex;
align-items: center;
gap: 0.875rem;
}
.sw-avatar {
position: relative;
width: 3.5rem;
height: 3.5rem;
flex-shrink: 0;
border-radius: 0.875rem;
overflow: hidden;
cursor: pointer;
border: 2px dashed var(--surface-border);
background: var(--surface-ground);
transition: border-color 0.2s;
}
.sw-avatar:hover {
border-color: var(--primary-color, #6154e8);
}
.sw-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sw-avatar__initials {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
font-weight: 800;
color: var(--text-color-secondary);
}
.sw-avatar__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
opacity: 0;
transition: opacity 0.2s;
}
.sw-avatar:hover .sw-avatar__overlay {
opacity: 1;
}
.sw-avatar-hint__title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 0.2rem;
}
.sw-avatar-hint__sub {
font-size: 0.7rem;
color: var(--text-color-secondary);
}
/* ══════════════════════════════════════════
SWATCHES
══════════════════════════════════════════ */
.sw-swatches {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.25rem;
}
.sw-swatch {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
}
.sw-swatch:hover {
transform: scale(1.15);
}
.sw-swatch--active {
outline: 2px solid var(--primary-color, #6154e8);
outline-offset: 2px;
transform: scale(1.1);
}
/* ══════════════════════════════════════════
TIPO NEGÓCIO
══════════════════════════════════════════ */
.sw-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.4rem;
margin-top: 0.3rem;
}
@media (min-width: 400px) {
.sw-type-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.sw-type-grid--error {
outline: 2px solid rgba(248, 113, 113, 0.3);
border-radius: 0.875rem;
padding: 0.2rem;
}
.sw-type-btn {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.7rem 0.4rem;
border-radius: 0.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
cursor: pointer;
text-align: center;
font-size: 0.7rem;
font-weight: 600;
color: var(--text-color-secondary);
transition: all 0.15s;
}
.sw-type-btn:hover {
border-color: var(--primary-color, #6154e8);
color: var(--text-color);
}
.sw-type-btn--active {
background: rgba(97, 84, 232, 0.08);
border-color: rgba(97, 84, 232, 0.3);
color: #6154e8;
}
.sw-type-btn__check {
position: absolute;
top: 0.3rem;
right: 0.3rem;
font-size: 0.58rem;
color: #6154e8;
}
/* ══════════════════════════════════════════
MODALIDADE
══════════════════════════════════════════ */
.sw-mode-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.625rem;
}
@media (max-width: 480px) {
.sw-mode-grid {
grid-template-columns: 1fr;
}
}
.sw-mode-btn {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
padding: 1rem 0.5rem;
border-radius: 0.875rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
cursor: pointer;
text-align: center;
color: var(--text-color-secondary);
transition: all 0.15s;
}
.sw-mode-btn:hover {
border-color: var(--primary-color, #6154e8);
}
.sw-mode-btn--active {
background: rgba(97, 84, 232, 0.08);
border-color: rgba(97, 84, 232, 0.3);
color: #6154e8;
}
.sw-mode-btn__label {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-color);
}
.sw-mode-btn__desc {
font-size: 0.65rem;
color: var(--text-color-secondary);
line-height: 1.3;
}
.sw-mode-btn__check {
position: absolute;
top: 0.4rem;
right: 0.4rem;
font-size: 0.65rem;
color: #6154e8;
}
/* ══════════════════════════════════════════
LIST / FORM
══════════════════════════════════════════ */
.sw-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sw-list__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
}
.sw-list__info {
flex: 1;
min-width: 0;
}
.sw-list__name {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color);
}
.sw-list__meta {
font-size: 0.7rem;
color: var(--text-color-secondary);
margin-left: 0.35rem;
}
.sw-empty {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--text-color-secondary);
padding: 0.35rem 0;
}
.sw-add-form {
padding-top: 0.625rem;
border-top: 1px solid var(--surface-border);
}
.sw-icon-btn {
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-color-secondary);
transition: all 0.14s;
}
.sw-icon-btn:hover {
background: rgba(97, 84, 232, 0.1);
color: #6154e8;
}
.sw-icon-btn--danger:hover {
background: rgba(248, 113, 113, 0.1);
color: #f87171;
}
/* ══════════════════════════════════════════
BUTTONS (dentro do dialog)
══════════════════════════════════════════ */
.sw-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 1rem;
border-radius: 0.625rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.sw-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.sw-btn--primary {
background: var(--primary-color, #6154e8);
color: white;
}
.sw-btn--primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.sw-btn--ghost {
background: transparent;
color: var(--text-color-secondary);
border: 1px solid var(--surface-border);
}
.sw-btn--ghost:hover:not(:disabled) {
background: var(--surface-ground);
}
/* Footer buttons */
.sw-dialog__foot-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 1.5rem;
}
.sw-autosave-indicator {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: var(--text-color-secondary);
flex: 1;
justify-content: center;
}
.sw-nav-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 1.1rem;
border-radius: 0.625rem;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
color: var(--text-color-secondary);
transition: all 0.15s;
}
.sw-nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.sw-nav-btn:hover:not(:disabled) {
border-color: var(--primary-color, #6154e8);
color: var(--primary-color, #6154e8);
}
.sw-next-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 1.25rem;
border-radius: 0.625rem;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
border: none;
background: #1e1b4b;
color: white;
box-shadow: 0 3px 12px rgba(30, 27, 75, 0.3);
transition: all 0.15s;
}
.sw-next-btn:hover {
filter: brightness(1.15);
transform: translateY(-1px);
}
.sw-finish-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 1.25rem;
border-radius: 0.625rem;
font-size: 0.82rem;
font-weight: 700;
cursor: not-allowed;
border: none;
background: rgba(16, 185, 129, 0.12);
color: rgba(16, 185, 129, 0.5);
border: 1px solid rgba(16, 185, 129, 0.15);
transition: all 0.15s;
}
.sw-finish-btn--ready {
background: #10b981 !important;
color: white !important;
border-color: transparent !important;
cursor: pointer !important;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
animation: swPulseGreen 2s ease-in-out infinite;
}
.sw-finish-btn--ready:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
@keyframes swPulseGreen {
0%,
100% {
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
}
50% {
box-shadow: 0 4px 24px rgba(16, 185, 129, 0.65);
}
}
/* ══════════════════════════════════════════
DONE SCREEN
══════════════════════════════════════════ */
.sw-done {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.sw-done__confetti {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.sw-confetti {
position: absolute;
top: -10px;
left: var(--x, 50%);
width: 8px;
height: 8px;
background: var(--color, #fff);
border-radius: 2px;
animation: swConfetti 3.2s ease-in var(--delay, 0s) forwards;
}
@keyframes swConfetti {
0% {
transform: translateY(-100vh) rotate(var(--rot, 0deg));
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(calc(var(--rot, 0deg) + 720deg));
opacity: 0;
}
}
.sw-done__card {
position: relative;
z-index: 1;
overflow: hidden;
background: rgb(0 0 0 / 0%);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 1.5rem;
padding: 2.5rem;
max-width: 28rem;
width: calc(100% - 2rem);
text-align: center;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.3);
animation: swDoneIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
color: white;
}
@keyframes swDoneIn {
from {
opacity: 0;
transform: scale(0.85) translateY(24px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.sw-done__emoji {
font-size: 3rem;
margin-bottom: 0.625rem;
}
.sw-done__title {
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.2;
margin-bottom: 0.4rem;
}
.sw-done__name {
background: linear-gradient(135deg, #e0d7ff, #bfdbfe);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sw-done__sub {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 1.25rem;
}
.sw-done__badges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem;
margin-bottom: 1.5rem;
}
.sw-done__badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
background: color-mix(in srgb, var(--c) 20%, rgba(255, 255, 255, 0.1));
color: white;
padding: 0.25rem 0.6rem;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.sw-done__actions {
display: flex;
gap: 0.625rem;
justify-content: center;
flex-wrap: wrap;
}
.sw-cta-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 0.875rem;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
border: none;
background: white;
color: #4c1d95;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
transition: all 0.18s;
}
.sw-cta-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.sw-ghost-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-radius: 0.875rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
transition: all 0.18s;
}
.sw-ghost-btn:hover {
background: rgba(255, 255, 255, 0.18);
}
/* ══════════════════════════════════════════
TRANSITIONS
══════════════════════════════════════════ */
.sw-fade-enter-active,
.sw-fade-leave-active {
transition: opacity 0.3s;
}
.sw-fade-enter-from,
.sw-fade-leave-to {
opacity: 0;
}
.sw-slide-enter-active,
.sw-slide-leave-active {
transition:
opacity 0.22s ease,
max-height 0.28s ease,
margin 0.22s ease;
max-height: 14rem;
overflow: hidden;
}
.sw-slide-enter-from,
.sw-slide-leave-to {
opacity: 0;
max-height: 0;
margin: 0;
}
@keyframes swFadeUp {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>