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 @@
+
+
+
+
+
+
+
+
+
+
Preparando seu ambiente…
+
Criando seu espaço e ativando o plano gratuito. Leva só um instante.
+
+
+
+
+
+
+
+
Escolha outro identificador
+
O identificador que você escolheu já está em uso. Escolha outro — ele é definitivo.
+
+
+
+
+ {{ slugMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
Algo deu errado
+ {{ errorMsg }}
+
+
+
+
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.
-
+
-
Ao criar a conta, registramos sua intenção de assinatura. Pagamento é manual (PIX/boleto) por enquanto.
+
Plano gratuito ativado na hora, sem cartão. Você pode fazer upgrade quando quiser.