a2f3b9fae4
- Signup.vue reescrito: coleta tipo de conta + nome + nome do negocio + slug (disponibilidade ao vivo via slug_disponivel) + email/senha; grava tudo no raw_user_meta_data do signUp; PEGADINHA #2: signOut scope:local se nao veio sessao + tela "confirme seu e-mail". Removido provisionamento/intent inline. - OnboardingPage.vue (PEGADINHA #3): 1o login chama auto_provision_free_tenant + processar_pos_signup; resolve estados provisionando/slug-colidiu/erro; redireciona pro painel conforme kind. - guard: logado-sem-tenant (nao saas_admin) -> /onboarding em vez de /login - rota /onboarding (meta.public; a pagina exige sessao) NOTA: supabase/config.toml e gitignored — enable_confirmations=true foi setado local (ativa no proximo restart do stack). No hosted, ligar em Auth>Email>Confirm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
575 lines
28 KiB
Vue
575 lines
28 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/views/pages/public/Signup.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
|
|
import Password from 'primevue/password';
|
|
import Chip from 'primevue/chip';
|
|
import Message from 'primevue/message';
|
|
import ProgressSpinner from 'primevue/progressspinner';
|
|
import SelectButton from 'primevue/selectbutton';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const toast = useToast();
|
|
|
|
// ============================
|
|
// Form
|
|
// ============================
|
|
const email = ref('');
|
|
const password = ref('');
|
|
const displayName = ref('');
|
|
const businessName = ref('');
|
|
const slug = ref('');
|
|
const slugTouched = ref(false);
|
|
const loading = ref(false);
|
|
|
|
// tela "confirme seu e-mail" (quando a confirmação está ligada e o signUp não
|
|
// retorna sessão)
|
|
const signedUp = ref(false);
|
|
const signedUpEmail = ref('');
|
|
|
|
// tipo de conta — deriva do plano da query, default terapeuta
|
|
const kindOptions = [
|
|
{ label: 'Sou terapeuta', value: 'therapist' },
|
|
{ label: 'Somos uma clínica', value: 'clinic_full' }
|
|
];
|
|
const accountKind = ref('therapist');
|
|
|
|
// ============================
|
|
// Slug do tenant (= nome do schema físico, IMUTÁVEL)
|
|
// ============================
|
|
// espelha public.generate_tenant_slug pra a sugestão local; a verdade é a RPC.
|
|
function slugify(s) {
|
|
let b = String(s || '').toLowerCase().trim();
|
|
b = b.normalize('NFD').replace(/[̀-ͯ]/g, ''); // tira acentos
|
|
b = b.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
b = b.slice(0, 48);
|
|
if (!b || !/^[a-z]/.test(b)) b = ('t_' + b).slice(0, 48);
|
|
return b;
|
|
}
|
|
|
|
const slugStatus = ref('idle'); // idle|checking|ok|curto|longo|invalido|reservado|em_uso|bloqueado|erro
|
|
let slugTimer = null;
|
|
|
|
function onSlugInput() {
|
|
slugTouched.value = true;
|
|
slug.value = slugify(slug.value);
|
|
}
|
|
|
|
async function checkSlug() {
|
|
try {
|
|
const { data, error } = await supabase.rpc('slug_disponivel', { p_slug: slug.value });
|
|
if (error) throw error;
|
|
slugStatus.value = data?.ok ? 'ok' : (data?.motivo || 'invalido');
|
|
} catch {
|
|
slugStatus.value = 'erro';
|
|
}
|
|
}
|
|
|
|
watch(businessName, (v) => {
|
|
if (!slugTouched.value) slug.value = slugify(v);
|
|
});
|
|
|
|
watch(slug, (v) => {
|
|
if (slugTimer) clearTimeout(slugTimer);
|
|
if (!v) { slugStatus.value = 'idle'; return; }
|
|
if (v.length < 3) { slugStatus.value = 'curto'; return; }
|
|
slugStatus.value = 'checking';
|
|
slugTimer = setTimeout(checkSlug, 400);
|
|
});
|
|
|
|
const slugOk = computed(() => slugStatus.value === 'ok');
|
|
const slugMessage = computed(() => ({
|
|
checking: 'Verificando disponibilidade…',
|
|
ok: 'Disponível ✓',
|
|
curto: 'Mínimo de 3 caracteres.',
|
|
longo: 'Máximo de 48 caracteres.',
|
|
invalido: 'Use letras minúsculas, números e _ (começando com letra).',
|
|
reservado: 'Esse identificador é reservado.',
|
|
em_uso: 'Esse identificador já está em uso.',
|
|
bloqueado: 'Esse identificador não está disponível.',
|
|
erro: 'Não consegui verificar agora.'
|
|
}[slugStatus.value] || ''));
|
|
|
|
// validação
|
|
const emailOk = computed(() => /\S+@\S+\.\S+/.test(String(email.value || '').trim()));
|
|
const passwordOk = computed(() => String(password.value || '').length >= 6);
|
|
const nameOk = computed(() => String(displayName.value || '').trim().length >= 2);
|
|
const businessOk = computed(() => String(businessName.value || '').trim().length >= 2);
|
|
const canSubmit = computed(() =>
|
|
!loading.value && emailOk.value && passwordOk.value && nameOk.value && businessOk.value && slugOk.value
|
|
);
|
|
|
|
// ============================
|
|
// Query (plan / interval)
|
|
// ============================
|
|
const planFromQuery = computed(() =>
|
|
String(route.query.plan || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
);
|
|
const intervalFromQuery = computed(() =>
|
|
String(route.query.interval || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
);
|
|
|
|
function normalizeInterval(v) {
|
|
if (v === 'monthly') return 'month';
|
|
if (v === 'annual' || v === 'annually' || v === 'yearly') return 'year';
|
|
return v;
|
|
}
|
|
|
|
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value));
|
|
|
|
function isValidInterval(v) {
|
|
return v === 'month' || v === 'year';
|
|
}
|
|
|
|
const hasPlanQuery = computed(() => !!planFromQuery.value && isValidInterval(intervalNormalized.value));
|
|
|
|
const intervalLabel = computed(() => {
|
|
if (intervalNormalized.value === 'year') return 'Anual';
|
|
if (intervalNormalized.value === 'month') return 'Mensal';
|
|
return '';
|
|
});
|
|
|
|
// ============================
|
|
// Fetch pricing from v_public_pricing
|
|
// ============================
|
|
const selectedPlanRow = ref(null);
|
|
const pricingLoading = ref(false);
|
|
|
|
const selectedPlanName = computed(() => selectedPlanRow.value?.public_name || selectedPlanRow.value?.plan_name || null);
|
|
const selectedBadge = computed(() => selectedPlanRow.value?.badge || null);
|
|
const selectedDescription = computed(() => selectedPlanRow.value?.public_description || null);
|
|
|
|
const bullets = computed(() => {
|
|
const b = selectedPlanRow.value?.bullets;
|
|
return Array.isArray(b) ? b : [];
|
|
});
|
|
|
|
function amountForInterval(row, interval) {
|
|
if (!row) return null;
|
|
const cents = interval === 'year' ? row.yearly_cents : row.monthly_cents;
|
|
// fallback (se não existir preço no intervalo escolhido)
|
|
if (cents == null) return interval === 'year' ? row.monthly_cents : row.yearly_cents;
|
|
return cents;
|
|
}
|
|
|
|
function currencyForInterval(row, interval) {
|
|
if (!row) return 'BRL';
|
|
const cur = interval === 'year' ? row.yearly_currency || 'BRL' : row.monthly_currency || 'BRL';
|
|
return cur || 'BRL';
|
|
}
|
|
|
|
const amountCents = computed(() => amountForInterval(selectedPlanRow.value, intervalNormalized.value));
|
|
const currency = computed(() => currencyForInterval(selectedPlanRow.value, intervalNormalized.value));
|
|
|
|
const formattedPrice = computed(() => {
|
|
if (amountCents.value == null) return null;
|
|
try {
|
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency.value || 'BRL' }).format(Number(amountCents.value) / 100);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const showPlanCard = computed(() => hasPlanQuery.value && !!selectedPlanRow.value);
|
|
|
|
async function loadSelectedPlanRow() {
|
|
selectedPlanRow.value = null;
|
|
if (!planFromQuery.value) return;
|
|
|
|
pricingLoading.value = true;
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('v_public_pricing')
|
|
.select('plan_key, plan_name, public_name, public_description, badge, is_featured, is_visible, monthly_cents, yearly_cents, monthly_currency, yearly_currency, bullets')
|
|
.eq('plan_key', planFromQuery.value)
|
|
.eq('is_visible', true)
|
|
.maybeSingle();
|
|
|
|
if (error) throw error;
|
|
if (!data) return;
|
|
selectedPlanRow.value = data;
|
|
} catch (err) {
|
|
console.error('[Signup] loadSelectedPlanRow:', err);
|
|
} finally {
|
|
pricingLoading.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(loadSelectedPlanRow);
|
|
|
|
watch(
|
|
() => [planFromQuery.value, intervalNormalized.value],
|
|
() => loadSelectedPlanRow()
|
|
);
|
|
|
|
// NOTA F2: provisionamento de tenant + criação de intent NÃO acontecem mais
|
|
// aqui (com confirmação de e-mail ligada o signUp não tem sessão e tudo que
|
|
// depende de auth.uid() quebraria em silêncio). A escolha vai no metadata do
|
|
// signUp e é processada no 1º login pós-confirmação por auto_provision_free_tenant
|
|
// / processar_pos_signup (ver session.js / OnboardingPage).
|
|
|
|
// ============================
|
|
// Nav
|
|
// ============================
|
|
function goLogin() {
|
|
router.push({
|
|
path: '/auth/login',
|
|
query: email.value ? { email: String(email.value).trim() } : undefined
|
|
});
|
|
}
|
|
|
|
function goBackPricing() {
|
|
// você usa /lp#pricing — mantive
|
|
router.push('/lp#pricing');
|
|
}
|
|
|
|
// ============================
|
|
// Signup
|
|
// ============================
|
|
async function onSignup() {
|
|
if (!canSubmit.value) return;
|
|
|
|
loading.value = true;
|
|
try {
|
|
const cleanEmail = String(email.value || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
// grava a escolha no raw_user_meta_data — processada no 1º login
|
|
// (auto_provision_free_tenant / processar_pos_signup)
|
|
const { data, error } = await supabase.auth.signUp({
|
|
email: cleanEmail,
|
|
password: password.value,
|
|
options: {
|
|
data: {
|
|
account_kind: accountKind.value,
|
|
tenant_name: String(businessName.value || '').trim(),
|
|
tenant_slug: String(slug.value || '').trim(),
|
|
display_name: String(displayName.value || '').trim(),
|
|
plan_key: planFromQuery.value || null,
|
|
billing_interval: intervalNormalized.value || null
|
|
}
|
|
}
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
// ⚠️ PEGADINHA #2: se NÃO veio sessão (confirmação pendente), encerra
|
|
// qualquer sessão local e mostra "confirme seu e-mail". Sem isso, uma
|
|
// sessão anterior (ex: dev testando) vazaria e o push pro painel mandaria
|
|
// o usuário pro ambiente da sessão antiga.
|
|
if (!data?.session) {
|
|
try { await supabase.auth.signOut({ scope: 'local' }); } catch { /* ignore */ }
|
|
signedUpEmail.value = cleanEmail;
|
|
signedUp.value = true;
|
|
return;
|
|
}
|
|
|
|
// confirmação desligada (auto-confirm): o guard manda pra /onboarding,
|
|
// que provisiona o tenant gratuito.
|
|
toast.add({ severity: 'success', summary: 'Conta criada', detail: 'Preparando seu ambiente…', life: 2500 });
|
|
router.push('/onboarding');
|
|
} catch (err) {
|
|
console.error(err);
|
|
|
|
const msg = String(err?.message || '');
|
|
const isAlreadyRegistered = err?.name === 'AuthApiError' && /User already registered/i.test(msg);
|
|
|
|
if (isAlreadyRegistered) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Esse email já tem conta',
|
|
detail: 'Faça login para continuar.',
|
|
life: 4500
|
|
});
|
|
goLogin();
|
|
return;
|
|
}
|
|
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Erro ao criar conta',
|
|
detail: err?.message || 'Tente novamente.',
|
|
life: 4500
|
|
});
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)] flex items-center justify-center p-4">
|
|
<!-- fundo suave (noir glow) -->
|
|
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
|
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
|
|
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
|
|
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
|
|
</div>
|
|
|
|
<div class="relative w-full max-w-6xl">
|
|
<div class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
|
<div class="grid grid-cols-12">
|
|
<!-- LEFT -->
|
|
<div class="col-span-12 lg:col-span-6 p-6 md:p-10 bg-[color-mix(in_srgb,var(--surface-card),transparent_6%)] border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]">
|
|
<div class="flex items-center gap-3">
|
|
<div class="h-11 w-11 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
|
<i class="pi pi-sparkles opacity-80 text-lg" />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-semibold leading-tight truncate">Psi Quasar</div>
|
|
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider class="my-6" />
|
|
|
|
<div class="text-3xl md:text-4xl font-semibold leading-tight">Menos dispersão. Mais presença.</div>
|
|
|
|
<div class="mt-3 text-sm md:text-base text-[var(--text-color-secondary)] max-w-lg leading-relaxed">
|
|
Crie sua conta e siga para o pagamento manual (PIX/boleto). Assim que confirmado, seu plano é ativado e as funcionalidades liberadas.
|
|
</div>
|
|
|
|
<div class="mt-6 grid grid-cols-12 gap-3">
|
|
<div class="col-span-12 md:col-span-6">
|
|
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
|
<div class="text-xs text-[var(--text-color-secondary)]">Agenda</div>
|
|
<div class="text-xl font-semibold mt-1">Organizada</div>
|
|
<div class="text-xs text-[var(--text-color-secondary)] mt-1">encaixes, bloqueios e visão clara</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-span-12 md:col-span-6">
|
|
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
|
<div class="text-xs text-[var(--text-color-secondary)]">Financeiro</div>
|
|
<div class="text-xl font-semibold mt-1">Respirável</div>
|
|
<div class="text-xs text-[var(--text-color-secondary)] mt-1">sem planilhas espalhadas</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-span-12">
|
|
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
|
|
<div class="font-semibold mt-1">Histórico por sessão</div>
|
|
<div class="text-xs text-[var(--text-color-secondary)] mt-1">linha do tempo por paciente</div>
|
|
</div>
|
|
<i class="pi pi-file-edit opacity-60" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6 flex flex-wrap gap-2">
|
|
<Tag severity="secondary" value="Agenda online (PRO)" />
|
|
<Tag severity="secondary" value="Controle de sessões" />
|
|
<Tag severity="secondary" value="Financeiro integrado" />
|
|
<Tag severity="secondary" value="Clínica / multi-profissional" />
|
|
</div>
|
|
|
|
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">* Painel conceitual inspirado em layouts PrimeBlocks.</div>
|
|
</div>
|
|
|
|
<!-- RIGHT -->
|
|
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
|
|
<div class="max-w-md mx-auto">
|
|
<!-- Tela: confirme seu e-mail (confirmação ligada) -->
|
|
<div v-if="signedUp" class="text-center py-6">
|
|
<div class="h-14 w-14 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] mx-auto grid place-items-center">
|
|
<i class="pi pi-envelope text-2xl opacity-80" />
|
|
</div>
|
|
<div class="text-2xl font-semibold mt-4">Confirme seu e-mail</div>
|
|
<div class="text-sm text-[var(--text-color-secondary)] mt-2 leading-relaxed">
|
|
Enviamos um link de confirmação para <span class="font-semibold">{{ signedUpEmail }}</span>.
|
|
Clique no link para ativar sua conta e entrar — seu ambiente é criado automaticamente no primeiro acesso.
|
|
</div>
|
|
<Message severity="info" class="mt-4 text-left">Não recebeu? Verifique a caixa de spam. O link expira em 1 hora.</Message>
|
|
<Button label="Ir para o login" icon="pi pi-arrow-right" iconPos="right" class="w-full mt-4" @click="goLogin" />
|
|
</div>
|
|
|
|
<!-- Form de cadastro -->
|
|
<div v-else>
|
|
<div class="text-2xl font-semibold">Criar conta grátis</div>
|
|
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
|
Já tem conta?
|
|
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
|
|
</div>
|
|
|
|
<!-- Plano -->
|
|
<div class="mt-5">
|
|
<div v-if="pricingLoading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
|
|
<ProgressSpinner style="width: 18px; height: 18px" strokeWidth="4" />
|
|
Carregando plano…
|
|
</div>
|
|
|
|
<Card v-else-if="showPlanCard" class="overflow-hidden rounded-[2rem]">
|
|
<template #content>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="text-xs text-[var(--text-color-secondary)]">Plano selecionado</div>
|
|
|
|
<div class="mt-1 flex items-center gap-2 flex-wrap">
|
|
<div class="text-lg font-semibold truncate">
|
|
{{ selectedPlanName }}
|
|
</div>
|
|
<Tag v-if="selectedPlanRow?.is_featured" severity="success" value="Popular" />
|
|
<Tag v-if="selectedBadge" severity="secondary" :value="selectedBadge" />
|
|
<Chip v-if="intervalLabel" :label="intervalLabel" />
|
|
</div>
|
|
|
|
<div class="mt-2 text-2xl font-semibold leading-none">
|
|
{{ formattedPrice || '—' }}
|
|
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ intervalNormalized === 'month' ? 'mês' : 'ano' }} </span>
|
|
</div>
|
|
|
|
<div v-if="selectedDescription" class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
|
{{ selectedDescription }}
|
|
</div>
|
|
</div>
|
|
|
|
<Button icon="pi pi-pencil" label="Trocar" severity="secondary" text rounded aria-label="Trocar plano" @click="goBackPricing" />
|
|
</div>
|
|
|
|
<Divider class="my-4" />
|
|
|
|
<ul v-if="bullets.length" class="space-y-2 text-sm">
|
|
<li v-for="b in bullets" :key="b.id ?? b.text" class="flex items-start gap-2">
|
|
<i class="pi pi-check mt-1 text-emerald-500"></i>
|
|
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
|
|
{{ b.text }}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
|
|
<Message v-else severity="info" class="mt-2"> Benefícios ainda não cadastrados para esse plano. </Message>
|
|
</template>
|
|
</Card>
|
|
|
|
<Message v-else-if="hasPlanQuery && !selectedPlanRow" severity="warn" class="mb-0">
|
|
Não encontrei esse plano na vitrine pública. Você ainda pode criar a conta normalmente.
|
|
<div class="mt-2">
|
|
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full" @click="goBackPricing" />
|
|
</div>
|
|
</Message>
|
|
|
|
<Message v-else severity="info" class="mb-0">
|
|
Você está criando a conta sem seleção de plano.
|
|
<div class="mt-2">
|
|
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full" @click="goBackPricing" />
|
|
</div>
|
|
</Message>
|
|
|
|
<Message v-if="hasPlanQuery && selectedPlanRow && amountCents == null && !pricingLoading" severity="warn" class="mt-3">
|
|
Esse plano não tem preço configurado para {{ intervalLabel }}. Você ainda pode criar a conta normalmente.
|
|
</Message>
|
|
</div>
|
|
|
|
<Divider class="my-6" />
|
|
|
|
<!-- Form -->
|
|
<div class="space-y-4">
|
|
<!-- Tipo de conta -->
|
|
<div>
|
|
<label class="text-xs text-[var(--text-color-secondary)] mb-1 block">Você é…</label>
|
|
<SelectButton v-model="accountKind" :options="kindOptions" optionLabel="label" optionValue="value" :allowEmpty="false" :disabled="loading" class="w-full" />
|
|
</div>
|
|
|
|
<!-- Seu nome -->
|
|
<div>
|
|
<FloatLabel variant="on">
|
|
<InputText id="signup_name" v-model="displayName" class="w-full" autocomplete="name" :disabled="loading" />
|
|
<label for="signup_name">Seu nome</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<!-- Nome do negócio -->
|
|
<div>
|
|
<FloatLabel variant="on">
|
|
<InputText id="signup_business" v-model="businessName" class="w-full" :disabled="loading" />
|
|
<label for="signup_business">{{ accountKind === 'therapist' ? 'Nome do seu consultório' : 'Nome da clínica' }}</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<!-- Slug (identificador definitivo) -->
|
|
<div>
|
|
<FloatLabel variant="on">
|
|
<InputText id="signup_slug" v-model="slug" class="w-full" :disabled="loading" @input="onSlugInput" @blur="onSlugInput" />
|
|
<label for="signup_slug">Identificador (definitivo)</label>
|
|
</FloatLabel>
|
|
<div v-if="slug" class="mt-1 text-xs" :class="slugOk ? 'text-emerald-600' : (slugStatus === 'checking' ? 'text-[var(--text-color-secondary)]' : 'text-orange-600')">
|
|
{{ slugMessage }}
|
|
</div>
|
|
<div v-else class="mt-1 text-xs text-[var(--text-color-secondary)]">Vira o endereço do seu ambiente. Escolha com calma — não dá pra mudar depois.</div>
|
|
</div>
|
|
|
|
<div>
|
|
<FloatLabel variant="on">
|
|
<InputText id="signup_email" v-model="email" class="w-full" autocomplete="email" :disabled="loading" @keydown.enter.prevent="onSignup" />
|
|
<label for="signup_email">Seu melhor e-mail</label>
|
|
</FloatLabel>
|
|
|
|
<div v-if="email && !emailOk" class="mt-2 text-xs text-orange-600">Informe um e-mail válido.</div>
|
|
</div>
|
|
|
|
<div>
|
|
<FloatLabel variant="on">
|
|
<Password
|
|
v-model="password"
|
|
inputId="signup_password"
|
|
toggleMask
|
|
:feedback="true"
|
|
autocomplete="new-password"
|
|
:disabled="loading"
|
|
@keydown.enter.prevent="onSignup"
|
|
:pt="{
|
|
root: { class: 'w-full' },
|
|
input: { class: 'w-full' }
|
|
}"
|
|
/>
|
|
<label for="signup_password">Senha</label>
|
|
</FloatLabel>
|
|
|
|
<div v-if="password && !passwordOk" class="mt-2 text-xs text-orange-600">Use pelo menos 6 caracteres.</div>
|
|
</div>
|
|
|
|
<Button label="CRIAR CONTA GRÁTIS" class="w-full" severity="success" :loading="loading" :disabled="!canSubmit" icon="pi pi-arrow-right" @click="onSignup" />
|
|
|
|
<div class="text-xs text-center text-[var(--text-color-secondary)]">Plano gratuito ativado na hora, sem cartão. Você pode fazer upgrade quando quiser.</div>
|
|
|
|
<div class="text-xs text-center">
|
|
<a class="cursor-pointer text-[var(--text-color-secondary)] hover:underline" @click.prevent="goLogin"> Já tenho conta — entrar </a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">Agência PSI — gestão clínica sem ruído.</div>
|
|
</div>
|
|
</div>
|
|
</template>
|