diff --git a/src/router/guards.js b/src/router/guards.js index 2352b94..efcead1 100644 --- a/src/router/guards.js +++ b/src/router/guards.js @@ -632,6 +632,14 @@ export function applyGuards(router) { const firstActive = preferred || mem.find((m) => m && m.status === 'active' && m.tenant_id); 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) if (isTenantArea) { sessionStorage.setItem('redirect_after_login', to.fullPath); diff --git a/src/router/routes.public.js b/src/router/routes.public.js index 67a0f22..cac8371 100644 --- a/src/router/routes.public.js +++ b/src/router/routes.public.js @@ -58,6 +58,15 @@ export default { name: 'shared.document', component: () => import('@/views/pages/public/SharedDocumentPage.vue'), 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 } } ] }; diff --git a/src/views/pages/auth/OnboardingPage.vue b/src/views/pages/auth/OnboardingPage.vue new file mode 100644 index 0000000..c2ddc08 --- /dev/null +++ b/src/views/pages/auth/OnboardingPage.vue @@ -0,0 +1,168 @@ + + + + diff --git a/src/views/pages/public/Signup.vue b/src/views/pages/public/Signup.vue index 453b620..2cd2d8b 100644 --- a/src/views/pages/public/Signup.vue +++ b/src/views/pages/public/Signup.vue @@ -24,6 +24,7 @@ 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(); @@ -34,12 +35,88 @@ const toast = useToast(); // ============================ const email = ref(''); const password = ref(''); +const displayName = ref(''); +const businessName = ref(''); +const slug = ref(''); +const slugTouched = 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 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) @@ -148,46 +225,11 @@ watch( () => loadSelectedPlanRow() ); -// ============================ -// subscription_intent (MODELO B: tenant) -// ============================ -async function getActiveTenantIdForUser(userId) { - 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(); - - 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; -} +// 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 @@ -216,53 +258,40 @@ async function onSignup() { .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 + 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; - const userId = data?.user?.id || null; - - // ✅ Modelo B: garante tenant pessoal (não aborta se falhar) - let tenantId = null; - if (userId) { - try { - const resTenant = await supabase.rpc('ensure_personal_tenant'); - tenantId = resTenant?.data || null; - } catch (e) { - 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 - }); - } + // ⚠️ 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; } - toast.add({ - severity: 'success', - summary: 'Conta criada', - detail: 'Agora vamos para os próximos passos.', - life: 2500 - }); - - router.push({ - path: '/auth/welcome', - query: { - plan: planFromQuery.value || undefined, - interval: intervalNormalized.value || undefined - } - }); + // 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); @@ -366,7 +395,23 @@ async function onSignup() {
-
Criar conta
+ +
+
+ +
+
Confirme seu e-mail
+
+ Enviamos um link de confirmação para {{ signedUpEmail }}. + Clique no link para ativar sua conta e entrar — seu ambiente é criado automaticamente no primeiro acesso. +
+ Não recebeu? Verifique a caixa de spam. O link expira em 1 hora. +
+ + +
+
Criar conta grátis
Já tem conta? Entrar @@ -445,6 +490,40 @@ async function onSignup() {
+ +
+ + +
+ + +
+ + + + +
+ + +
+ + + + +
+ + +
+ + + + +
+ {{ slugMessage }} +
+
Vira o endereço do seu ambiente. Escolha com calma — não dá pra mudar depois.
+
+
@@ -475,14 +554,15 @@ async function onSignup() {
Use pelo menos 6 caracteres.
-
+