freemium F2: signup self-service com confirmacao + /onboarding
- 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>
This commit is contained in:
@@ -632,6 +632,14 @@ export function applyGuards(router) {
|
|||||||
const firstActive = preferred || mem.find((m) => m && m.status === 'active' && m.tenant_id);
|
const firstActive = preferred || mem.find((m) => m && m.status === 'active' && m.tenant_id);
|
||||||
|
|
||||||
if (!firstActive) {
|
if (!firstActive) {
|
||||||
|
// ✅ Freemium F2: logado mas SEM nenhum tenant ativo → /onboarding,
|
||||||
|
// que provisiona o plano gratuito e resolve estados (slug colidiu,
|
||||||
|
// pago aguardando, erro). saas_admin não passa por aqui.
|
||||||
|
if (globalRole !== 'saas_admin' && to.path !== '/onboarding') {
|
||||||
|
_perfEnd();
|
||||||
|
return { path: '/onboarding' };
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 race/sem vínculo: em área tenant, trate como login necessário (não AccessDenied)
|
// 🔥 race/sem vínculo: em área tenant, trate como login necessário (não AccessDenied)
|
||||||
if (isTenantArea) {
|
if (isTenantArea) {
|
||||||
sessionStorage.setItem('redirect_after_login', to.fullPath);
|
sessionStorage.setItem('redirect_after_login', to.fullPath);
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ export default {
|
|||||||
name: 'shared.document',
|
name: 'shared.document',
|
||||||
component: () => import('@/views/pages/public/SharedDocumentPage.vue'),
|
component: () => import('@/views/pages/public/SharedDocumentPage.vue'),
|
||||||
meta: { public: true }
|
meta: { public: true }
|
||||||
|
},
|
||||||
|
// ✅ Freemium F2: onboarding pós-confirmação (provisiona o tenant gratuito).
|
||||||
|
// meta.public p/ não passar pela lógica de tenant do guard; a própria
|
||||||
|
// página exige sessão (redireciona pra /auth/login se não houver).
|
||||||
|
{
|
||||||
|
path: '/onboarding',
|
||||||
|
name: 'onboarding',
|
||||||
|
component: () => import('@/views/pages/auth/OnboardingPage.vue'),
|
||||||
|
meta: { public: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!--
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — OnboardingPage (Freemium F2)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Tela do 1º login pós-confirmação. Provisiona o tenant gratuito via
|
||||||
|
| auto_provision_free_tenant (lê o raw_user_meta_data) e resolve estados:
|
||||||
|
| provisionando, slug colidiu (deixa reescolher), erro (retry). Pega o
|
||||||
|
| caminho pago via processar_pos_signup (best-effort). Pegadinha #3: um
|
||||||
|
| logado-sem-tenant nunca pode cair num painel quebrado.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
-->
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Message from 'primevue/message';
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const tenant = useTenantStore();
|
||||||
|
|
||||||
|
const state = ref('provisioning'); // provisioning | slug_collision | error | done
|
||||||
|
const errorMsg = ref('');
|
||||||
|
|
||||||
|
// slug colidiu — reescolher
|
||||||
|
const slug = ref('');
|
||||||
|
const slugStatus = ref('idle'); // idle|checking|ok|curto|longo|invalido|reservado|em_uso|bloqueado|erro
|
||||||
|
let slugTimer = null;
|
||||||
|
const slugOk = computed(() => slugStatus.value === 'ok');
|
||||||
|
const slugMessage = computed(() => ({
|
||||||
|
checking: 'Verificando…',
|
||||||
|
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] || ''));
|
||||||
|
|
||||||
|
function slugify(s) {
|
||||||
|
let b = String(s || '').toLowerCase().trim();
|
||||||
|
b = b.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSlugInput() {
|
||||||
|
slug.value = slugify(slug.value);
|
||||||
|
if (slugTimer) clearTimeout(slugTimer);
|
||||||
|
if (!slug.value || slug.value.length < 3) { slugStatus.value = slug.value ? 'curto' : 'idle'; return; }
|
||||||
|
slugStatus.value = 'checking';
|
||||||
|
slugTimer = setTimeout(async () => {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function homePathForKind(kind) {
|
||||||
|
return kind === 'therapist' ? '/therapist' : '/admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishAndRedirect(kind) {
|
||||||
|
// recarrega o tenant store (pega a nova membership) e entra no painel
|
||||||
|
tenant.reset();
|
||||||
|
await tenant.loadSessionAndTenant();
|
||||||
|
state.value = 'done';
|
||||||
|
router.replace(homePathForKind(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function provision(slugOverride = null) {
|
||||||
|
state.value = 'provisioning';
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('auto_provision_free_tenant', {
|
||||||
|
p_slug_override: slugOverride
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// caminho pago (intent) — best-effort, não bloqueia
|
||||||
|
try { await supabase.rpc('processar_pos_signup'); } catch (e) { console.warn('[onboarding] processar_pos_signup:', e?.message || e); }
|
||||||
|
|
||||||
|
await finishAndRedirect(data?.kind || 'therapist');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = String(err?.message || '');
|
||||||
|
if (/SLUG_TAKEN/i.test(msg)) {
|
||||||
|
state.value = 'slug_collision';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// sem sessão → manda pro login
|
||||||
|
if (/sem sess|28000|JWT|not authenticated/i.test(msg)) {
|
||||||
|
router.replace('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errorMsg.value = msg || 'Não consegui preparar seu ambiente.';
|
||||||
|
state.value = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryWithSlug() {
|
||||||
|
if (!slugOk.value) return;
|
||||||
|
await provision(slug.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// exige sessão
|
||||||
|
const { data } = await supabase.auth.getSession();
|
||||||
|
if (!data?.session?.user) {
|
||||||
|
router.replace('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await provision();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)] flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm p-8 text-center">
|
||||||
|
<!-- provisionando -->
|
||||||
|
<template v-if="state === 'provisioning' || state === 'done'">
|
||||||
|
<ProgressSpinner style="width: 48px; height: 48px" strokeWidth="4" />
|
||||||
|
<div class="text-xl font-semibold mt-5">Preparando seu ambiente…</div>
|
||||||
|
<div class="text-sm text-[var(--text-color-secondary)] mt-2">Criando seu espaço e ativando o plano gratuito. Leva só um instante.</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- slug colidiu -->
|
||||||
|
<template v-else-if="state === 'slug_collision'">
|
||||||
|
<div class="h-12 w-12 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] mx-auto grid place-items-center">
|
||||||
|
<i class="pi pi-pencil text-xl opacity-80" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-semibold mt-4">Escolha outro identificador</div>
|
||||||
|
<div class="text-sm text-[var(--text-color-secondary)] mt-2">O identificador que você escolheu já está em uso. Escolha outro — ele é definitivo.</div>
|
||||||
|
|
||||||
|
<div class="mt-5 text-left">
|
||||||
|
<InputText v-model="slug" class="w-full" placeholder="meu_consultorio" @input="onSlugInput" @blur="onSlugInput" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Button label="Continuar" icon="pi pi-arrow-right" iconPos="right" class="w-full mt-4" :disabled="!slugOk" @click="retryWithSlug" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- erro -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="h-12 w-12 rounded-2xl border border-red-200 bg-red-50 mx-auto grid place-items-center">
|
||||||
|
<i class="pi pi-exclamation-triangle text-xl text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-semibold mt-4">Algo deu errado</div>
|
||||||
|
<Message severity="error" class="mt-3 text-left">{{ errorMsg }}</Message>
|
||||||
|
<Button label="Tentar de novo" icon="pi pi-refresh" class="w-full mt-4" @click="() => provision()" />
|
||||||
|
<Button label="Sair" text class="w-full mt-2" @click="() => router.replace('/auth/login')" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -24,6 +24,7 @@ import Password from 'primevue/password';
|
|||||||
import Chip from 'primevue/chip';
|
import Chip from 'primevue/chip';
|
||||||
import Message from 'primevue/message';
|
import Message from 'primevue/message';
|
||||||
import ProgressSpinner from 'primevue/progressspinner';
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
|
import SelectButton from 'primevue/selectbutton';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -34,12 +35,88 @@ const toast = useToast();
|
|||||||
// ============================
|
// ============================
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
const displayName = ref('');
|
||||||
|
const businessName = ref('');
|
||||||
|
const slug = ref('');
|
||||||
|
const slugTouched = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
// validação simples (sem "viajar")
|
// 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 emailOk = computed(() => /\S+@\S+\.\S+/.test(String(email.value || '').trim()));
|
||||||
const passwordOk = computed(() => String(password.value || '').length >= 6);
|
const passwordOk = computed(() => String(password.value || '').length >= 6);
|
||||||
const canSubmit = computed(() => !loading.value && emailOk.value && passwordOk.value);
|
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)
|
// Query (plan / interval)
|
||||||
@@ -148,46 +225,11 @@ watch(
|
|||||||
() => loadSelectedPlanRow()
|
() => loadSelectedPlanRow()
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================
|
// NOTA F2: provisionamento de tenant + criação de intent NÃO acontecem mais
|
||||||
// subscription_intent (MODELO B: tenant)
|
// 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
|
||||||
async function getActiveTenantIdForUser(userId) {
|
// signUp e é processada no 1º login pós-confirmação por auto_provision_free_tenant
|
||||||
const { data, error } = await supabase.from('tenant_members').select('tenant_id').eq('user_id', userId).eq('status', 'active').order('created_at', { ascending: false }).limit(1).maybeSingle();
|
// / processar_pos_signup (ver session.js / OnboardingPage).
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data?.tenant_id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSubscriptionIntentAfterSignup(userId, preferredTenantId = null) {
|
|
||||||
if (!hasPlanQuery.value) return;
|
|
||||||
if (!selectedPlanRow.value) return;
|
|
||||||
if (amountCents.value == null) return;
|
|
||||||
|
|
||||||
const tenantId = preferredTenantId || (await getActiveTenantIdForUser(userId));
|
|
||||||
if (!tenantId) throw new Error('Não encontrei tenant ativo para este usuário.');
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
tenant_id: tenantId,
|
|
||||||
created_by_user_id: userId,
|
|
||||||
|
|
||||||
// opcional (se sua tabela ainda tem user_id)
|
|
||||||
user_id: userId,
|
|
||||||
|
|
||||||
email:
|
|
||||||
String(email.value || '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase() || null,
|
|
||||||
plan_key: selectedPlanRow.value.plan_key,
|
|
||||||
interval: intervalNormalized.value,
|
|
||||||
amount_cents: amountCents.value,
|
|
||||||
currency: currency.value || 'BRL',
|
|
||||||
status: 'new',
|
|
||||||
source: 'landing'
|
|
||||||
};
|
|
||||||
|
|
||||||
const { error } = await supabase.from('subscription_intents').insert(payload);
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Nav
|
// Nav
|
||||||
@@ -216,53 +258,40 @@ async function onSignup() {
|
|||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.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({
|
const { data, error } = await supabase.auth.signUp({
|
||||||
email: cleanEmail,
|
email: cleanEmail,
|
||||||
password: password.value
|
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;
|
if (error) throw error;
|
||||||
|
|
||||||
const userId = data?.user?.id || null;
|
// ⚠️ 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
|
||||||
// ✅ Modelo B: garante tenant pessoal (não aborta se falhar)
|
// sessão anterior (ex: dev testando) vazaria e o push pro painel mandaria
|
||||||
let tenantId = null;
|
// o usuário pro ambiente da sessão antiga.
|
||||||
if (userId) {
|
if (!data?.session) {
|
||||||
try {
|
try { await supabase.auth.signOut({ scope: 'local' }); } catch { /* ignore */ }
|
||||||
const resTenant = await supabase.rpc('ensure_personal_tenant');
|
signedUpEmail.value = cleanEmail;
|
||||||
tenantId = resTenant?.data || null;
|
signedUp.value = true;
|
||||||
} catch (e) {
|
return;
|
||||||
console.warn('[Signup] ensure_personal_tenant falhou:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ intent (não quebra signup se falhar)
|
|
||||||
try {
|
|
||||||
await createSubscriptionIntentAfterSignup(userId, tenantId);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Signup] subscription_intent failed:', e);
|
|
||||||
toast.add({
|
|
||||||
severity: 'warn',
|
|
||||||
summary: 'Conta criada',
|
|
||||||
detail: 'Não consegui registrar a intenção do plano. Você pode seguir normalmente.',
|
|
||||||
life: 4500
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.add({
|
// confirmação desligada (auto-confirm): o guard manda pra /onboarding,
|
||||||
severity: 'success',
|
// que provisiona o tenant gratuito.
|
||||||
summary: 'Conta criada',
|
toast.add({ severity: 'success', summary: 'Conta criada', detail: 'Preparando seu ambiente…', life: 2500 });
|
||||||
detail: 'Agora vamos para os próximos passos.',
|
router.push('/onboarding');
|
||||||
life: 2500
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push({
|
|
||||||
path: '/auth/welcome',
|
|
||||||
query: {
|
|
||||||
plan: planFromQuery.value || undefined,
|
|
||||||
interval: intervalNormalized.value || undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@@ -366,7 +395,23 @@ async function onSignup() {
|
|||||||
<!-- RIGHT -->
|
<!-- RIGHT -->
|
||||||
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
|
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
|
||||||
<div class="max-w-md mx-auto">
|
<div class="max-w-md mx-auto">
|
||||||
<div class="text-2xl font-semibold">Criar conta</div>
|
<!-- 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">
|
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||||
Já tem conta?
|
Já tem conta?
|
||||||
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
|
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
|
||||||
@@ -445,6 +490,40 @@ async function onSignup() {
|
|||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<div class="space-y-4">
|
<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>
|
<div>
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<InputText id="signup_email" v-model="email" class="w-full" autocomplete="email" :disabled="loading" @keydown.enter.prevent="onSignup" />
|
<InputText id="signup_email" v-model="email" class="w-full" autocomplete="email" :disabled="loading" @keydown.enter.prevent="onSignup" />
|
||||||
@@ -475,14 +554,15 @@ async function onSignup() {
|
|||||||
<div v-if="password && !passwordOk" class="mt-2 text-xs text-orange-600">Use pelo menos 6 caracteres.</div>
|
<div v-if="password && !passwordOk" class="mt-2 text-xs text-orange-600">Use pelo menos 6 caracteres.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button label="CRIAR CONTA" class="w-full" severity="success" :loading="loading" :disabled="!canSubmit" icon="pi pi-arrow-right" @click="onSignup" />
|
<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)]">Ao criar a conta, registramos sua intenção de assinatura. Pagamento é manual (PIX/boleto) por enquanto.</div>
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user