52c34cf63a
- edge function send-welcome-email: e-mail de boas-vindas ao DONO do tenant recem-provisionado (destinatario do JWT, SMTP global/sistema, defaults Mailpit). Best-effort, disparada fire-and-forget no OnboardingPage so no provisionamento novo. - vitrine: seed plan_public + bullets dos planos free (cartao "Gratis"); Landingpage passa a mostrar "Gratis para sempre" (isFreePlan) em vez de "—". - build OK Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
7.3 KiB
Vue
174 lines
7.3 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| 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); }
|
|
|
|
// welcome email — só no provisionamento NOVO, fire-and-forget (não bloqueia)
|
|
if (data?.status === 'provisioned') {
|
|
supabase.functions.invoke('send-welcome-email').catch(() => { /* best-effort */ });
|
|
}
|
|
|
|
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>
|