This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
+134 -172
View File
@@ -1,16 +1,11 @@
<!-- src/views/pages/auth/SignupPage.vue -->
<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 Card from 'primevue/card'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import FloatLabel from 'primevue/floatlabel'
import Password from 'primevue/password'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Chip from 'primevue/chip'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
@@ -26,13 +21,18 @@ const email = ref('')
const password = ref('')
const loading = ref(false)
// validação simples (sem “viajar”)
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)
// ============================
// Query (plan / interval)
// ============================
const planFromQuery = computed(() => String(route.query.plan || '').trim().toLowerCase())
const intervalFromQuery = computed(() => String(route.query.interval || '').trim().toLowerCase())
function normalizeInterval(v) {
function normalizeInterval (v) {
if (v === 'monthly') return 'month'
if (v === 'annual' || v === 'annually' || v === 'yearly') return 'year'
return v
@@ -40,7 +40,7 @@ function normalizeInterval(v) {
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value))
function isValidInterval(v) {
function isValidInterval (v) {
return v === 'month' || v === 'year'
}
@@ -67,29 +67,36 @@ const bullets = computed(() => {
return Array.isArray(b) ? b : []
})
const amountCents = computed(() => {
if (!selectedPlanRow.value) return null
return intervalNormalized.value === 'year'
? selectedPlanRow.value.yearly_cents
: selectedPlanRow.value.monthly_cents
})
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
}
const currency = computed(() => {
if (!selectedPlanRow.value) return 'BRL'
return intervalNormalized.value === 'year'
? (selectedPlanRow.value.yearly_currency || 'BRL')
: (selectedPlanRow.value.monthly_currency || 'BRL')
})
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
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency.value || 'BRL' })
.format(amountCents.value / 100)
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() {
async function loadSelectedPlanRow () {
selectedPlanRow.value = null
if (!planFromQuery.value) return
@@ -117,59 +124,14 @@ async function loadSelectedPlanRow() {
onMounted(loadSelectedPlanRow)
watch(
() => planFromQuery.value,
() => [planFromQuery.value, intervalNormalized.value],
() => loadSelectedPlanRow()
)
// ============================
// Create subscription_intent after signup
// subscription_intent (MODELO B: tenant)
// ============================
/*
async function createSubscriptionIntentAfterSignup(userId, tenantIdFromRpc) {
if (!hasPlanQuery.value) return
if (!selectedPlanRow.value) return
if (amountCents.value == null) return
let tenantId = tenantIdFromRpc || null
// fallback (se a RPC não retornou)
if (!tenantId) {
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
tenantId = data?.tenant_id || null
}
if (!tenantId) throw new Error('Não encontrei tenant ativo para este usuário.')
const payload = {
tenant_id: tenantId,
created_by_user_id: userId,
email: email.value || 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
}
*/
// ============================
// Create subscription_intent after signup (MODELO B: tenant)
// ============================
async function getActiveTenantIdForUser(userId) {
async function getActiveTenantIdForUser (userId) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
@@ -183,22 +145,22 @@ async function getActiveTenantIdForUser(userId) {
return data?.tenant_id || null
}
async function createSubscriptionIntentAfterSignup(userId) {
async function createSubscriptionIntentAfterSignup (userId, preferredTenantId = null) {
if (!hasPlanQuery.value) return
if (!selectedPlanRow.value) return
if (amountCents.value == null) return
const tenantId = await getActiveTenantIdForUser(userId)
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: manter user_id por compat/telemetria (se sua tabela ainda tem a coluna)
// opcional (se sua tabela ainda tem user_id)
user_id: userId,
email: email.value || null,
email: String(email.value || '').trim().toLowerCase() || null,
plan_key: selectedPlanRow.value.plan_key,
interval: intervalNormalized.value,
amount_cents: amountCents.value,
@@ -211,30 +173,33 @@ async function createSubscriptionIntentAfterSignup(userId) {
if (error) throw error
}
// ============================
// Actions
// Nav
// ============================
function goLogin() {
function goLogin () {
router.push({
path: '/auth/login',
query: email.value ? { email: email.value } : undefined
query: email.value ? { email: String(email.value).trim() } : undefined
})
}
function goBackPricing() {
function goBackPricing () {
// você usa /lp#pricing — mantive
router.push('/lp#pricing')
}
// ============================
// Signup
// ============================
async function onSignup() {
async function onSignup () {
if (!canSubmit.value) return
loading.value = true
try {
const cleanEmail = String(email.value || '').trim().toLowerCase()
const { data, error } = await supabase.auth.signUp({
email: email.value,
email: cleanEmail,
password: password.value
})
@@ -242,7 +207,7 @@ async function onSignup() {
const userId = data?.user?.id || null
// ✅ Modelo B: garante tenant pessoal e captura tenant_id
// ✅ Modelo B: garante tenant pessoal (não aborta se falhar)
let tenantId = null
if (userId) {
try {
@@ -250,7 +215,6 @@ async function onSignup() {
tenantId = resTenant?.data || null
} catch (e) {
console.warn('[Signup] ensure_personal_tenant falhou:', e)
// não aborta signup por isso
}
// ✅ intent (não quebra signup se falhar)
@@ -308,7 +272,6 @@ async function onSignup() {
loading.value = false
}
}
</script>
<template>
@@ -323,7 +286,7 @@ async function onSignup() {
<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: conceitual (estilo PrimeBlocks) -->
<!-- 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">
@@ -341,7 +304,7 @@ async function onSignup() {
Menos dispersão. Mais presença.
</div>
<div class="mt-3 text-sm md:text-base text-[var(--text-color-secondary)] max-w-lg">
<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>
@@ -387,7 +350,7 @@ async function onSignup() {
</div>
</div>
<!-- RIGHT: Card login/signup + plano no topo -->
<!-- RIGHT -->
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
<div class="max-w-md mx-auto">
<div class="text-2xl font-semibold">Criar conta</div>
@@ -396,25 +359,26 @@ async function onSignup() {
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
</div>
<!-- Plano (card único, dentro da direita) -->
<!-- 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">
<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 :label="intervalLabel" />
<Chip v-if="intervalLabel" :label="intervalLabel" />
</div>
<div class="mt-2 text-2xl font-semibold leading-none">
@@ -424,14 +388,14 @@ async function onSignup() {
</span>
</div>
<div v-if="selectedDescription" class="mt-2 text-sm text-[var(--text-color-secondary)]">
<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"
label="Trocar"
severity="secondary"
text
rounded
@@ -460,14 +424,28 @@ async function onSignup() {
<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" />
<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" />
<Button
label="Ver planos"
icon="pi pi-credit-card"
severity="secondary"
outlined
class="w-full"
@click="goBackPricing"
/>
</div>
</Message>
@@ -480,96 +458,80 @@ async function onSignup() {
</Message>
</div>
<!-- Form -->
<Divider class="my-6" />
<div class="mt-5">
<!-- Social (opcional: deixa visual, mas desabilitado por enquanto) -->
<div class="grid grid-cols-12 gap-2">
<div class="col-span-12 md:col-span-6">
<Button
label="Sign up with Facebook"
icon="pi pi-facebook"
severity="secondary"
outlined
class="w-full"
disabled
/>
</div>
<div class="col-span-12 md:col-span-6">
<Button
label="Sign up with Google"
icon="pi pi-google"
severity="secondary"
outlined
class="w-full"
disabled
/>
</div>
</div>
<!-- Form -->
<div class="space-y-4">
<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>
<!-- Divider OR -->
<div class="flex items-center gap-3 my-5">
<div class="flex-1 h-px bg-[var(--surface-border)]"></div>
<div class="text-sm text-[var(--text-color-secondary)] font-medium">or</div>
<div class="flex-1 h-px bg-[var(--surface-border)]"></div>
</div>
<div v-if="email && !emailOk" class="mt-2 text-xs text-orange-600">
Informe um e-mail válido.
</div>
</div>
<!-- Email -->
<div class="mb-4">
<FloatLabel variant="on">
<InputText
id="email"
v-model="email"
class="w-full"
autocomplete="email"
/>
<label for="email">Seu melhor e-mail</label>
</FloatLabel>
</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>
<!-- Password -->
<div class="mb-3">
<FloatLabel variant="on">
<Password
v-model="password"
toggleMask
:feedback="true"
inputClass="w-full pr-5"
autocomplete="current-password"
:disabled="loading || loadingRecovery"
:pt="{
root: { class: 'w-full' },
input: { class: 'w-full' },
icon: { class: 'right-3' }
}"
/>
<label for="password">Password</label>
</FloatLabel>
</div>
<Button
label="CRIAR CONTA"
class="w-full"
severity="success"
:loading="loading"
:disabled="!canSubmit"
icon="pi pi-arrow-right"
@click="onSignup"
/>
<!-- CTA -->
<Button
label="CRIAR CONTA"
class="w-full"
:loading="loading"
@click="onSignup"
style="background: #10b981; border-color: #10b981"
/>
<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)] mt-3">
Ao criar a conta, registramos sua intenção de assinatura. Pagamento é manual (PIX/boleto) por enquanto.
</div>
</div>
<div class="text-xs text-center">
<a class="cursor-pointer text-[var(--text-color-secondary)] hover:underline" @click.prevent="goLogin">
tenho conta entrar
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">
Psi Quasar gestão clínica sem ruído.
Agência PSI gestão clínica sem ruído.
</div>
</div>
</div>
</template>
</template>