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
+147 -92
View File
@@ -1,50 +1,109 @@
<!-- src/views/pages/public/AcceptInvitePage.vue -->
<template>
<div class="min-h-screen flex items-center justify-center p-6 bg-[var(--surface-ground)] text-[var(--text-color)]">
<div class="w-full max-w-lg rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 shadow-sm">
<h1 class="text-xl font-semibold mb-2">Aceitar convite</h1>
<p class="text-sm opacity-80 mb-6">
Vamos validar seu convite e ativar seu acesso ao tenant.
</p>
<Toast />
<div v-if="state.loading" class="text-sm">
Processando convite
</div>
<div class="w-full max-w-lg overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<!-- Header / Hero -->
<div class="relative p-6">
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-64 w-64 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-20 h-64 w-64 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-16 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div v-else-if="state.success" class="space-y-3">
<div class="text-sm">
Convite aceito com sucesso. Redirecionando
<div class="relative">
<h1 class="text-xl font-semibold mb-2">Aceitar convite</h1>
<p class="text-sm opacity-80">
Vamos validar seu convite e ativar seu acesso ao tenant.
</p>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs opacity-80">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-ticket" />
Token:
<b class="font-mono">{{ shortToken }}</b>
</span>
<span
v-if="state.loading"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1"
>
<i class="pi pi-spin pi-spinner" />
Processando
</span>
<span
v-else-if="state.success"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1"
>
<i class="pi pi-check" />
Confirmado
</span>
</div>
</div>
</div>
<div v-else-if="state.error" class="space-y-4">
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm">
<div class="font-semibold mb-1">Não foi possível aceitar o convite</div>
<div class="opacity-90">{{ state.error }}</div>
<!-- Body -->
<div class="p-6 border-t border-[var(--surface-border)]">
<!-- Loading -->
<div v-if="state.loading" class="text-sm">
Processando convite
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 rounded-xl border border-[var(--surface-border)] hover:opacity-90 text-sm"
@click="retry"
>
Tentar novamente
</button>
<button
class="px-4 py-2 rounded-xl bg-[var(--primary-color)] text-[var(--primary-color-text)] hover:opacity-90 text-sm"
@click="goLogin"
>
Ir para login
</button>
<!-- Success -->
<div v-else-if="state.success" class="space-y-3">
<div class="text-sm">
Convite aceito com sucesso. Redirecionando
</div>
<div class="text-xs opacity-70">
Se você não for redirecionado, clique abaixo.
</div>
<div class="flex gap-2">
<Button
label="Ir para o painel"
icon="pi pi-arrow-right"
@click="goAdmin"
/>
</div>
</div>
<p class="text-xs opacity-70">
Se você recebeu um convite, confirme se está logado com o mesmo e-mail do convite.
</p>
</div>
<!-- Error -->
<div v-else-if="state.error" class="space-y-4">
<div class="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm">
<div class="font-semibold mb-1">Não foi possível aceitar o convite</div>
<div class="opacity-90">{{ state.error }}</div>
<div v-else class="text-sm opacity-80">
Preparando
<div v-if="state.debugDetails" class="mt-3 text-xs opacity-70">
<div class="font-semibold mb-1">Detalhes (debug)</div>
<pre class="m-0 whitespace-pre-wrap break-words">{{ state.debugDetails }}</pre>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
label="Tentar novamente"
icon="pi pi-refresh"
severity="secondary"
outlined
@click="retry"
/>
<Button
label="Ir para login"
icon="pi pi-sign-in"
@click="goLogin"
/>
</div>
<p class="text-xs opacity-70 leading-relaxed">
Se você recebeu um convite, confirme se está logado com o mesmo e-mail do convite.
</p>
</div>
<!-- Idle -->
<div v-else class="text-sm opacity-80">
Preparando
</div>
</div>
</div>
</div>
@@ -54,6 +113,8 @@
import { reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
@@ -76,12 +137,14 @@ function clearPendingToken () {
const route = useRoute()
const router = useRouter()
const toast = useToast()
const tenantStore = useTenantStore()
const state = reactive({
loading: true,
success: false,
error: ''
error: '',
debugDetails: '' // mantém vazio por padrão (pode ativar quando precisar)
})
const tokenFromQuery = computed(() => {
@@ -89,6 +152,15 @@ const tokenFromQuery = computed(() => {
return typeof t === 'string' ? t.trim() : ''
})
const tokenEffective = computed(() => tokenFromQuery.value || readPendingToken() || '')
const shortToken = computed(() => {
const t = tokenEffective.value
if (!t) return '—'
if (t.length <= 14) return t
return `${t.slice(0, 8)}${t.slice(-4)}`
})
function isUuid (v) {
// UUID v1v5 (aceita maiúsculas/minúsculas)
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
@@ -97,29 +169,39 @@ function isUuid (v) {
function friendlyError (err) {
const msg = (err?.message || err || '').toString()
// Ajuste esses “match” conforme as mensagens/raises do seu SQL.
if (/expired|expirad/i.test(msg)) return 'Este convite expirou. Peça para a clínica reenviar o convite.'
if (/invalid|inval/i.test(msg)) return 'Token inválido. Verifique se você copiou o link corretamente.'
if (/not found|não encontrado|nao encontrado/i.test(msg)) return 'Convite não encontrado ou já utilizado.'
if (/not found|não encontrado|nao encontrado|used|utilizad/i.test(msg)) return 'Convite não encontrado ou já utilizado.'
if (/email/i.test(msg) && /mismatch|diferent|different|bate|match/i.test(msg)) {
return 'Você está logado com um e-mail diferente do convite. Faça login com o e-mail correto.'
}
// cobre Postgres raise not_authenticated (P0001) e mensagens de JWT
if (/not_authenticated|not authenticated|jwt|auth/i.test(msg)) {
return 'Você precisa estar logado para aceitar este convite.'
}
return 'Não foi possível concluir o aceite. Tente novamente ou peça para reenviar o convite.'
}
function safeRpcError (rpcError) {
const raw = (rpcError?.message || '').toString().trim()
// Por padrão: mensagem amigável. Se quiser ver a “real”, coloque em debugDetails.
const friendly = friendlyError(rpcError)
return { friendly, raw }
}
async function goAdmin () {
await router.replace('/admin')
}
async function goLogin () {
const token = tokenFromQuery.value || readPendingToken()
const token = tokenEffective.value
if (token) persistPendingToken(token)
// ✅ garante troca de conta
await supabase.auth.signOut()
// ✅ garante troca de conta (somente quando usuário clica)
try {
await supabase.auth.signOut()
} catch (_) {}
// ✅ volta para o accept com token (ou com o storage pendente)
// (mantém o link “real” para o login conseguir retornar certo)
// ✅ volta para o accept com token (ou storage pendente)
const returnTo = token ? `/accept-invite?token=${encodeURIComponent(token)}` : '/accept-invite'
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
}
@@ -128,10 +210,9 @@ async function acceptInvite (token) {
state.loading = true
state.error = ''
state.success = false
state.debugDetails = ''
// 1) sessão
// Obs: getSession lê do storage; não use pra “autorizar” no client,
// mas aqui é só fluxo/UX; o servidor valida de verdade.
const { data: sessionData, error: sessionErr } = await supabase.auth.getSession()
if (sessionErr) {
state.loading = false
@@ -144,56 +225,44 @@ async function acceptInvite (token) {
// não logado → salva token e vai pro login
persistPendingToken(token)
// ✅ importante: /login dá 404 no seu projeto; use /auth/login
// ✅ preserve o returnTo com querystring (token)
const returnTo = route.fullPath || `/accept-invite?token=${encodeURIComponent(token)}`
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
// não seta erro: é fluxo normal
state.loading = false
return
}
// (debug útil: garante que a aba anônima realmente tem user/session)
try {
const s = await supabase.auth.getSession()
const u = await supabase.auth.getUser()
console.log('[accept-invite] session user:', s?.data?.session?.user?.id, s?.data?.session?.user?.email)
console.log('[accept-invite] getUser:', u?.data?.user?.id, u?.data?.user?.email)
} catch (_) {}
// 2) chama RPC
// IMPORTANTÍSSIMO: a função deve validar:
// - token existe, status=invited, não expirou
// - email do invite == auth.email do caller
// - cria/ativa tenant_members (status=active)
// - revoga/consome invite
//
// A assinatura de args depende do seu SQL:
// - se for tenant_accept_invite(token uuid) → { token }
// - se for tenant_accept_invite(p_token uuid) → { p_token: token }
//
// ✅ NO SEU CASO: a assinatura existente é p_token (confirmado no SQL Editor).
const { data, error } = await supabase.rpc('tenant_accept_invite', { p_token: token })
if (error) {
state.loading = false
// mostra o motivo real na tela (e não uma mensagem genérica)
state.error = error?.message ? error.message : friendlyError(error)
const { friendly, raw } = safeRpcError(error)
state.error = friendly
// Se você quiser ver a mensagem “crua” para debug, descomente a linha abaixo:
// state.debugDetails = raw
// Opcional: toast discreto
toast.add({
severity: 'error',
summary: 'Falha no convite',
detail: friendly,
life: 4000
})
return
}
// 3) sucesso → limpa token pendente
clearPendingToken()
// 4) atualiza tenantStore (boa prática: refresh completo do “contexto do usuário”)
// Ideal: sua RPC retorna tenant_id (e opcionalmente role/status)
// 4) atualiza tenantStore
const acceptedTenantId = data?.tenant_id || data?.tenantId || null
try {
await refreshTenantContextAfterInvite(acceptedTenantId)
} catch (e) {
// mesmo que refresh falhe, o aceite ocorreu; ainda redireciona, mas você pode avisar
// (mantive silencioso para não “quebrar” o fluxo).
} catch (_) {
// Silencioso: aceite ocorreu; não vamos quebrar o fluxo.
}
state.loading = false
@@ -203,20 +272,7 @@ async function acceptInvite (token) {
await router.replace('/admin')
}
/**
* Melhor prática de atualização do tenantStore após aceite:
* - 1) refetch “meus tenants + memberships” (fonte da verdade)
* - 2) setActiveTenantId (se veio no retorno; senão, escolha um padrão)
* - 3) carregar contexto do tenant ativo (permissões/entitlements/branding/etc)
*/
async function refreshTenantContextAfterInvite (acceptedTenantId) {
// Ajuste para os métodos reais do seu tenantStore:
// Exemplo recomendado de API do store:
// - await tenantStore.fetchMyTenants()
// - await tenantStore.fetchMyMemberships()
// - tenantStore.setActiveTenantId(...)
// - await tenantStore.hydrateActiveTenantContext()
if (typeof tenantStore.refreshMyTenantsAndMemberships === 'function') {
await tenantStore.refreshMyTenantsAndMemberships()
} else {
@@ -239,9 +295,9 @@ async function run () {
state.loading = true
state.error = ''
state.success = false
state.debugDetails = ''
// 1) token: query > pendente (pós-login)
const token = tokenFromQuery.value || readPendingToken()
const token = tokenEffective.value
if (!token) {
state.loading = false
@@ -258,7 +314,6 @@ async function run () {
// Se veio da query, persiste (caso precise atravessar login)
if (tokenFromQuery.value) persistPendingToken(token)
// 2) tenta aceitar
await acceptInvite(token)
}
@@ -790,21 +790,15 @@
import { computed, reactive, ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import InputMask from 'primevue/inputmask'
import Textarea from 'primevue/textarea'
import Checkbox from 'primevue/checkbox'
import Message from 'primevue/message'
import Toast from 'primevue/toast'
import Popover from 'primevue/popover'
import Accordion from 'primevue/accordion'
import AccordionPanel from 'primevue/accordionpanel'
import AccordionHeader from 'primevue/accordionheader'
import AccordionContent from 'primevue/accordioncontent'
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import Select from 'primevue/select'
import { useToast } from 'primevue/usetoast'
@@ -0,0 +1,519 @@
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)]">
<!-- TOPBAR -->
<div
class="sticky top-0 z-40 border-b border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] backdrop-blur"
>
<div class="mx-auto max-w-7xl px-4 md:px-6 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shadow-sm"
>
<i class="pi pi-sparkles text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Entrar" icon="pi pi-sign-in" severity="secondary" outlined @click="go('/auth/login')" />
<Button label="Começar" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</div>
<!-- HERO -->
<section class="relative overflow-hidden">
<!-- blobs / noir glow -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
</div>
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-8 md:pb-14 relative">
<div class="grid grid-cols-12 gap-6 items-center">
<div class="col-span-12 lg:col-span-7">
<Chip class="mb-4" label="Para psicólogos e clínicas" icon="pi pi-shield" />
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">
Uma agenda inteligente, um prontuário organizado, um financeiro respirável.
</h1>
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl">
Centralize a rotina clínica em um lugar : pacientes, sessões, lembretes e indicadores. Menos dispersão.
Mais presença.
</p>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
<Button
label="Criar conta grátis"
icon="pi pi-arrow-right"
class="w-full sm:w-auto"
@click="goStart()"
/>
<Button
label="Ver planos"
icon="pi pi-credit-card"
severity="secondary"
outlined
class="w-full sm:w-auto"
@click="scrollTo('pricing')"
/>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<Tag severity="secondary" value="Agenda online (PRO)" />
<Tag severity="secondary" value="Controle de sessões" />
<Tag severity="secondary" value="Financeiro integrado" />
<Tag severity="secondary" value="Clínica / multi-profissional" />
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<Card class="overflow-hidden">
<template #content>
<div class="p-1">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-semibold text-lg">Painel de hoje</div>
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
</div>
<i class="pi pi-chart-line opacity-70" />
</div>
<Divider class="my-4" />
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
<div class="text-2xl font-semibold mt-1">6</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
<div class="text-2xl font-semibold mt-1">R$ 840</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex items-center justify-between">
<div>
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Anotações e histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
organizado por paciente, sessão e linha do tempo
</div>
</div>
<i class="pi pi-file-edit opacity-70" />
</div>
</div>
</div>
</div>
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">
* Ilustração conceitual do produto.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
</section>
<!-- TRUST / VALUE STRIP -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-calendar opacity-80" />
</div>
<div>
<div class="font-semibold">Agenda e autoagendamento</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
O paciente confirma, agenda e reagenda com autonomia (PRO).
</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-wallet opacity-80" />
</div>
<div>
<div class="font-semibold">Financeiro integrado</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Receita/despesa junto da agenda sem planilhas espalhadas.
</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-lock opacity-80" />
</div>
<div>
<div class="font-semibold">Prontuário e controle de sessões</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Registro clínico e histórico acessíveis, com backups e organização.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de
clínica.
</div>
</section>
<!-- FEATURES -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
O foco é tirar o excesso de fricção sem invadir o que é do seu método.
</div>
</div>
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
</div>
<div class="grid grid-cols-12 gap-4">
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i :class="f.icon" class="opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold">{{ f.title }}</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
{{ f.desc }}
</div>
<div v-if="f.pro" class="mt-2">
<Tag severity="warning" value="PRO" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<Divider class="my-8" />
<Accordion :activeIndex="0">
<AccordionTab header="Como fica o fluxo na prática?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento.
O sistema existe para manter o consultório respirando não para virar uma burocracia nova.
</div>
</AccordionTab>
<AccordionTab header="E para clínica (multi-profissionais)?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Perfis por função, agendas separadas, repasses e visão gerencial quando você estiver pronto para crescer.
</div>
</AccordionTab>
<AccordionTab header="Privacidade e segurança">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes
de conformidade você pode expor numa página própria de segurança/LGPD.)
</div>
</AccordionTab>
</Accordion>
</section>
<!-- PRICING (dinâmico do SaaS) -->
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">
Comece simples. Suba para PRO quando a agenda pedir automação.
</div>
<!-- header conceitual + toggle -->
<div class="flex flex-col items-center text-center mt-6">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png"
shape="circle"
/>
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
</div>
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<Button
label="Mensal"
size="small"
:severity="billingInterval === 'month' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'month'"
@click="billingInterval = 'month'"
/>
<Button
label="Anual"
size="small"
:severity="billingInterval === 'year' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'year'"
class="ml-1"
@click="billingInterval = 'year'"
/>
</div>
<div v-if="billingInterval === 'year'" class="mt-2">
<Tag severity="success" value="Economize até 20%" />
</div>
</div>
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">
Carregando planos...
</div>
<div v-else class="mt-8 grid grid-cols-12 gap-4">
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
<Card
class="h-full overflow-hidden transition-transform"
:class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''"
>
<template #content>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ p.badge || 'Plano' }}
</div>
<div class="text-xl font-semibold">
{{ p.public_name || p.plan_name || p.plan_key }}
</div>
</div>
<Tag v-if="p.is_featured" severity="success" value="Popular" />
</div>
<div class="mt-4 text-3xl font-semibold leading-none">
{{ formatBRLFromCents(priceFor(p)) }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ billingInterval === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<div
v-if="billingInterval === 'year'"
class="text-xs text-emerald-500 mt-1 font-medium"
>
Melhor custo-benefício
</div>
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
{{ p.public_description }}
</div>
<Divider class="my-4" />
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-emerald-500"></i>
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
<div class="mt-5">
<Button
label="Começar"
class="w-full"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
icon="pi pi-arrow-right"
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
/>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO)
sem mexer no código.
</div>
</section>
<!-- FOOTER -->
<footer class="border-t border-[var(--surface-border)]">
<div
class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4"
>
<div>
<div class="font-semibold">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} Todos os direitos reservados.</div>
</div>
<div class="flex flex-wrap gap-2">
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Chip from 'primevue/chip'
import Accordion from 'primevue/accordion'
import AccordionTab from 'primevue/accordiontab'
import Avatar from 'primevue/avatar'
import AvatarGroup from 'primevue/avatargroup'
const router = useRouter()
const brandName = 'Psi Quasar' // ajuste para o nome final do produto
const year = computed(() => new Date().getFullYear())
function go(path) {
router.push(path)
}
function scrollTo(id) {
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const featuredPlanKey = computed(() => {
const list = Array.isArray(pricing.value) ? pricing.value : []
const featured = list.find(p => p && p.is_featured && p.is_visible)
return featured?.plan_key || null
})
function goStart() {
if (featuredPlanKey.value) {
router.push(`/auth/signup?plan=${featuredPlanKey.value}&interval=${billingInterval.value}`)
return
}
router.push('/auth/signup')
}
const features = ref([
{
title: 'Agenda inteligente',
desc: 'Configure sua semana, encaixes, bloqueios e visão por dia/semana.',
icon: 'pi pi-calendar'
},
{
title: 'Autoagendamento (PRO)',
desc: 'Página para o paciente confirmar, agendar e reagendar sem fricção.',
icon: 'pi pi-globe',
pro: true
},
{
title: 'Prontuário e sessões',
desc: 'Registro por paciente, histórico por sessão e organização por linha do tempo.',
icon: 'pi pi-file-edit'
},
{
title: 'Financeiro integrado',
desc: 'Receitas, despesas e visão do mês conectadas ao que acontece na agenda.',
icon: 'pi pi-wallet'
},
{
title: 'Pacientes e tags',
desc: 'Segmentação por grupos, etiquetas e filtros práticos para achar rápido.',
icon: 'pi pi-users'
},
{
title: 'Clínica / multi-profissional',
desc: 'Múltiplos profissionais, agendas separadas, papéis e visão gerencial.',
icon: 'pi pi-building'
}
])
/** PRICING dinâmico do SaaS */
const billingInterval = ref('year') // 'month' | 'year'
const pricing = ref([])
const loadingPricing = ref(false)
function formatBRLFromCents(cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function priceFor(p) {
return billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents
}
async function fetchPricing() {
loadingPricing.value = true
const { data, error } = await supabase
.from('v_public_pricing')
.select('*')
.eq('is_visible', true)
.order('sort_order', { ascending: true })
loadingPricing.value = false
if (!error) pricing.value = data || []
}
onMounted(fetchPricing)
</script>
+386 -233
View File
@@ -1,3 +1,4 @@
<!-- src/views/pages/public/LandingPage.vue -->
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)]">
<!-- TOPBAR -->
@@ -5,46 +6,65 @@
class="sticky top-0 z-40 border-b border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] backdrop-blur"
>
<div class="mx-auto max-w-7xl px-4 md:px-6 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<button class="flex items-center gap-3 min-w-0" @click="scrollTo('top')">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shadow-sm"
>
<i class="pi pi-sparkles text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="min-w-0 text-left">
<div class="font-semibold leading-tight truncate">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">
Gestão clínica sem ruído.
</div>
</div>
</div>
</button>
<div class="flex items-center gap-2">
<Button label="Entrar" icon="pi pi-sign-in" severity="secondary" outlined @click="go('/auth/login')" />
<Button label="Começar" icon="pi pi-bolt" @click="goStart()" />
<Button
label="Entrar"
icon="pi pi-sign-in"
severity="secondary"
outlined
@click="go('/auth/login')"
/>
<Button
label="Começar"
icon="pi pi-bolt"
@click="goStart()"
/>
</div>
</div>
</div>
<!-- HERO -->
<section class="relative overflow-hidden">
<!-- blobs / noir glow -->
<section id="top" class="relative overflow-hidden">
<!-- background: noir grid + glow -->
<div class="pointer-events-none absolute inset-0">
<div class="hero-grid absolute inset-0 opacity-[0.35]" />
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute top-20 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
<div class="hero-noise absolute inset-0 opacity-[0.12]" />
</div>
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-8 md:pb-14 relative">
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-10 md:pb-14 relative">
<div class="grid grid-cols-12 gap-6 items-center">
<div class="col-span-12 lg:col-span-7">
<Chip class="mb-4" label="Para psicólogos e clínicas" icon="pi pi-shield" />
<div class="flex flex-wrap items-center gap-2 mb-4">
<Chip label="Para psicólogos e clínicas" icon="pi pi-shield" />
<span class="text-xs text-[var(--text-color-secondary)]">
menos dispersão mais presença mais previsibilidade
</span>
</div>
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">
Uma agenda inteligente, um prontuário organizado, um financeiro respirável.
Um sistema que <span class="hero-underline">reduz ruído</span> sem roubar seu método.
</h1>
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl">
Centralize a rotina clínica em um lugar : pacientes, sessões, lembretes e indicadores. Menos dispersão.
Mais presença.
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl leading-relaxed">
Centralize a rotina clínica em um lugar : pacientes, sessões, lembretes e indicadores.
O objetivo não é burocratizar: é deixar o consultório respirável.
</p>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
@@ -62,6 +82,14 @@
class="w-full sm:w-auto"
@click="scrollTo('pricing')"
/>
<Button
label="Como funciona"
icon="pi pi-compass"
severity="secondary"
text
class="w-full sm:w-auto"
@click="scrollTo('how')"
/>
</div>
<div class="mt-6 flex flex-wrap gap-2">
@@ -69,18 +97,25 @@
<Tag severity="secondary" value="Controle de sessões" />
<Tag severity="secondary" value="Financeiro integrado" />
<Tag severity="secondary" value="Clínica / multi-profissional" />
<Tag severity="secondary" value="Separação por tenant + RLS" />
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
A diferença entre ter uma agenda e ter um sistema mora nos detalhes.
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<Card class="overflow-hidden">
<Card class="overflow-hidden rounded-[2rem] border border-[var(--surface-border)]">
<template #content>
<div class="p-1">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-semibold text-lg">Painel de hoje</div>
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
<div class="text-sm text-[var(--text-color-secondary)]">
Um recorte: o essencial, sem excesso.
</div>
</div>
<i class="pi pi-chart-line opacity-70" />
</div>
@@ -92,7 +127,9 @@
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
<div class="text-2xl font-semibold mt-1">6</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
com lembretes automáticos
</div>
</div>
</div>
@@ -100,18 +137,20 @@
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
<div class="text-2xl font-semibold mt-1">R$ 840</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
visão clara do mês
</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex items-center justify-between">
<div>
<div class="min-w-0">
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Anotações e histórico</div>
<div class="font-semibold mt-1 truncate">Anotações e histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
organizado por paciente, sessão e linha do tempo
por paciente por sessão linha do tempo
</div>
</div>
<i class="pi pi-file-edit opacity-70" />
@@ -127,21 +166,40 @@
</div>
</template>
</Card>
<div class="mt-4 grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_10%)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">Promessa</div>
<div class="font-semibold mt-1">Organizar sem invadir.</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
Você define o método. O sistema remove ruído.
</div>
</div>
</div>
<div class="col-span-12 sm:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_10%)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">Arquitetura</div>
<div class="font-semibold mt-1">Multi-tenant de verdade.</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
Menus + guards + RLS alinhados.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- TRUST / VALUE STRIP -->
<!-- VALUE STRIP -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<Card class="h-full rounded-[2rem]">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-calendar opacity-80" />
</div>
<div>
@@ -156,12 +214,10 @@
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<Card class="h-full rounded-[2rem]">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-wallet opacity-80" />
</div>
<div>
@@ -176,18 +232,16 @@
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<Card class="h-full rounded-[2rem]">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-lock opacity-80" />
</div>
<div>
<div class="font-semibold">Prontuário e controle de sessões</div>
<div class="font-semibold">Prontuário e sessões</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Registro clínico e histórico acessíveis, com backups e organização.
Registro e histórico acessíveis, com organização e backup.
</div>
</div>
</div>
@@ -197,40 +251,82 @@
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de
clínica.
Módulos: agenda, prontuário/sessões, financeiro e gestão multi-profissional.
</div>
</section>
<!-- FEATURES -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
<!-- HOW IT WORKS -->
<section id="how" class="mx-auto max-w-7xl px-4 md:px-6 pb-12 scroll-mt-24">
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
<div class="text-2xl md:text-3xl font-semibold">Como funciona</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
O foco é tirar o excesso de fricção sem invadir o que é do seu método.
Três movimentos: preparar, atender, acompanhar sem fricção.
</div>
</div>
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
</div>
<div class="grid grid-cols-12 gap-4">
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
<Card class="h-full">
<div class="col-span-12 md:col-span-4">
<Card class="h-full rounded-[2rem]">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i :class="f.icon" class="opacity-80" />
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-sliders-h opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold">{{ f.title }}</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
{{ f.desc }}
<div>
<div class="font-semibold">1) Preparar</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed">
Configure agenda, encaixes e regras. Se quiser, habilite autoagendamento (PRO).
</div>
<div v-if="f.pro" class="mt-2">
<Tag severity="warning" value="PRO" />
<div class="mt-2">
<Tag severity="secondary" value="Bloqueios" />
<Tag class="ml-2" severity="secondary" value="Encaixes" />
</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full rounded-[2rem]">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-comments opacity-80" />
</div>
<div>
<div class="font-semibold">2) Atender</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed">
Sessão acontece. Registro fica onde precisa ficar: no prontuário, no tempo certo.
</div>
<div class="mt-2">
<Tag severity="secondary" value="Sessões" />
<Tag class="ml-2" severity="secondary" value="Prontuário" />
</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full rounded-[2rem]">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-chart-bar opacity-80" />
</div>
<div>
<div class="font-semibold">3) Acompanhar</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed">
Financeiro e indicadores acompanham o movimento. Menos cadê?, mais previsibilidade.
</div>
<div class="mt-2">
<Tag severity="secondary" value="Recebimentos" />
<Tag class="ml-2" severity="secondary" value="Indicadores" />
</div>
</div>
</div>
@@ -244,58 +340,71 @@
<Accordion :activeIndex="0">
<AccordionTab header="Como fica o fluxo na prática?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento.
Você abre a agenda, a sessão acontece, o registro vai para o prontuário, e o financeiro acompanha.
O sistema existe para manter o consultório respirando não para virar uma burocracia nova.
</div>
</AccordionTab>
<AccordionTab header="E para clínica (multi-profissionais)?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Perfis por função, agendas separadas, repasses e visão gerencial quando você estiver pronto para crescer.
Perfis por função, agendas separadas, visão gerencial e convites (Modelo B).
Você cresce sem quebrar a estrutura.
</div>
</AccordionTab>
<AccordionTab header="Privacidade e segurança">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes
de conformidade você pode expor numa página própria de segurança/LGPD.)
Separação por clínica/tenant, controle de acesso e políticas no banco (RLS).
(Quando quiser, a gente cria uma página dedicada de LGPD/Segurança.)
</div>
</AccordionTab>
</Accordion>
</section>
<!-- FEATURES -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
O foco é tirar fricção sem invadir o que é do seu método.
</div>
</div>
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
</div>
<div class="grid grid-cols-12 gap-4">
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
<Card class="h-full rounded-[2rem]">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i :class="f.icon" class="opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold">{{ f.title }}</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed">
{{ f.desc }}
</div>
<div v-if="f.pro" class="mt-2">
<Tag severity="warning" value="PRO" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</section>
<!-- PRICING (dinâmico do SaaS) -->
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">
<div class="text-3xl md:text-4xl font-semibold text-center">Planos</div>
<div class="text-base md:text-lg text-[var(--text-color-secondary)] mt-2 text-center">
Comece simples. Suba para PRO quando a agenda pedir automação.
</div>
<!-- header conceitual + toggle -->
<!-- toggle -->
<div class="flex flex-col items-center text-center mt-6">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png"
shape="circle"
/>
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
</div>
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_10%)] p-1">
<Button
label="Mensal"
size="small"
@@ -312,95 +421,132 @@
@click="billingInterval = 'year'"
/>
</div>
<div v-if="billingInterval === 'year'" class="mt-2">
<Tag severity="success" value="Economize até 20%" />
</div>
</div>
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">
Carregando planos...
</div>
<div v-else class="mt-8 grid grid-cols-12 gap-4">
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
<Card
class="h-full overflow-hidden transition-transform"
:class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''"
>
<template #content>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ p.badge || 'Plano' }}
</div>
<div class="text-xl font-semibold">
{{ p.public_name || p.plan_name || p.plan_key }}
</div>
</div>
<Tag v-if="p.is_featured" severity="success" value="Popular" />
</div>
<div class="mt-4 text-3xl font-semibold leading-none">
{{ formatBRLFromCents(priceFor(p)) }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ billingInterval === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<div
v-if="billingInterval === 'year'"
class="text-xs text-emerald-500 mt-1 font-medium"
>
Melhor custo-benefício
</div>
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
{{ p.public_description }}
</div>
<Divider class="my-4" />
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-emerald-500"></i>
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
<div class="mt-5">
<Button
label="Começar"
class="w-full"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
icon="pi pi-arrow-right"
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
/>
</div>
</template>
</Card>
<div v-if="billingInterval === 'year'" class="mt-2">
<Tag severity="success" value="Economize até 20%" />
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO)
sem mexer no código.
<div v-if="loadingPricing" class="mt-8">
<div class="grid grid-cols-12 gap-4">
<div v-for="i in 3" :key="i" class="col-span-12 md:col-span-4">
<div class="rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="h-4 w-24 bg-[color-mix(in_srgb,var(--surface-200),transparent_40%)] rounded mb-3 animate-pulse" />
<div class="h-7 w-40 bg-[color-mix(in_srgb,var(--surface-200),transparent_40%)] rounded mb-4 animate-pulse" />
<div class="h-10 w-48 bg-[color-mix(in_srgb,var(--surface-200),transparent_40%)] rounded mb-4 animate-pulse" />
<div class="space-y-2">
<div class="h-3 w-full bg-[color-mix(in_srgb,var(--surface-200),transparent_40%)] rounded animate-pulse" />
<div class="h-3 w-11/12 bg-[color-mix(in_srgb,var(--surface-200),transparent_40%)] rounded animate-pulse" />
<div class="h-3 w-10/12 bg-[color-mix(in_srgb,var(--surface-200),transparent_40%)] rounded animate-pulse" />
</div>
<div class="h-10 w-full bg-[color-mix(in_srgb,var(--surface-200),transparent_40%)] rounded-xl mt-6 animate-pulse" />
</div>
</div>
</div>
</div>
<div v-else class="mt-8">
<div v-if="!pricing.length" class="rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 text-center">
<div class="text-lg font-semibold">Planos em preparação</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Ainda não itens visíveis na vitrine pública. Publique no painel SaaS para aparecer aqui.
</div>
<div class="mt-4 flex justify-center gap-2 flex-wrap">
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
<div v-else class="grid grid-cols-12 gap-4">
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
<div
class="h-full rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden transition-transform duration-300"
:class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : 'hover:-translate-y-1'"
>
<div class="relative p-5">
<div v-if="p.is_featured" class="pointer-events-none absolute inset-0 opacity-70">
<div class="absolute -top-20 -right-24 h-72 w-72 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-24 left-10 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
</div>
<div class="relative">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm text-[var(--text-color-secondary)]">
{{ p.badge || 'Plano' }}
</div>
<div class="text-xl font-semibold truncate">
{{ p.public_name || p.plan_name || p.plan_key }}
</div>
</div>
<Tag v-if="p.is_featured" severity="success" value="Popular" />
</div>
<div class="mt-4 text-3xl font-semibold leading-none">
{{ formatBRLFromCents(priceFor(p)) }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ billingInterval === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<div v-if="billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">
Melhor custo-benefício
</div>
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px] leading-relaxed">
{{ p.public_description || '—' }}
</div>
<Divider class="my-4" />
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-emerald-500"></i>
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="text-sm text-[var(--text-color-secondary)]">
Benefícios em breve.
</div>
<div class="mt-5">
<Button
label="Começar"
class="w-full"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
icon="pi pi-arrow-right"
@click="go(`/auth/signup?plan=${encodeURIComponent(p.plan_key)}&interval=${billingInterval}`)"
/>
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
Plano vem da vitrine SaaS sem mexer no código.
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
Dica: esses planos podem mapear diretamente para entitlements (FREE/PRO) e features (plan_features).
</div>
</div>
</section>
<!-- FOOTER -->
<footer class="border-t border-[var(--surface-border)]">
<div
class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4"
>
<div class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<div class="font-semibold">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} Todos os direitos reservados.</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
© {{ year }} Todos os direitos reservados.
</div>
</div>
<div class="flex flex-wrap gap-2">
@@ -417,77 +563,31 @@ import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Button from 'primevue/button'
import Card from 'primevue/card'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Chip from 'primevue/chip'
import Accordion from 'primevue/accordion'
import AccordionTab from 'primevue/accordiontab'
import Avatar from 'primevue/avatar'
import AvatarGroup from 'primevue/avatargroup'
const router = useRouter()
const brandName = 'Psi Quasar' // ajuste para o nome final do produto
const brandName = 'Agência PSI' // ajuste para o nome final do produto
const year = computed(() => new Date().getFullYear())
function go(path) {
function go (path) {
router.push(path)
}
function scrollTo(id) {
function scrollTo (id) {
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const featuredPlanKey = computed(() => {
const list = Array.isArray(pricing.value) ? pricing.value : []
const featured = list.find(p => p && p.is_featured && p.is_visible)
return featured?.plan_key || null
})
function goStart() {
if (featuredPlanKey.value) {
router.push(`/auth/signup?plan=${featuredPlanKey.value}&interval=${billingInterval.value}`)
return
}
router.push('/auth/signup')
}
const features = ref([
{
title: 'Agenda inteligente',
desc: 'Configure sua semana, encaixes, bloqueios e visão por dia/semana.',
icon: 'pi pi-calendar'
},
{
title: 'Autoagendamento (PRO)',
desc: 'Página para o paciente confirmar, agendar e reagendar sem fricção.',
icon: 'pi pi-globe',
pro: true
},
{
title: 'Prontuário e sessões',
desc: 'Registro por paciente, histórico por sessão e organização por linha do tempo.',
icon: 'pi pi-file-edit'
},
{
title: 'Financeiro integrado',
desc: 'Receitas, despesas e visão do mês conectadas ao que acontece na agenda.',
icon: 'pi pi-wallet'
},
{
title: 'Pacientes e tags',
desc: 'Segmentação por grupos, etiquetas e filtros práticos para achar rápido.',
icon: 'pi pi-users'
},
{
title: 'Clínica / multi-profissional',
desc: 'Múltiplos profissionais, agendas separadas, papéis e visão gerencial.',
icon: 'pi pi-building'
}
{ title: 'Agenda inteligente', desc: 'Configure semana, encaixes, bloqueios e visão por dia/semana.', icon: 'pi pi-calendar' },
{ title: 'Autoagendamento (PRO)', desc: 'Página para o paciente confirmar, agendar e reagendar sem fricção.', icon: 'pi pi-globe', pro: true },
{ title: 'Prontuário e sessões', desc: 'Registro por paciente, histórico por sessão e linha do tempo.', icon: 'pi pi-file-edit' },
{ title: 'Financeiro integrado', desc: 'Receitas e despesas conectadas ao que acontece na agenda.', icon: 'pi pi-wallet' },
{ title: 'Pacientes e tags', desc: 'Segmentação por grupos, etiquetas e filtros para achar rápido.', icon: 'pi pi-users' },
{ title: 'Clínica / multi-profissional', desc: 'Múltiplos profissionais, papéis, convites e visão gerencial.', icon: 'pi pi-building' }
])
/** PRICING dinâmico do SaaS */
@@ -495,29 +595,82 @@ const billingInterval = ref('year') // 'month' | 'year'
const pricing = ref([])
const loadingPricing = ref(false)
function formatBRLFromCents(cents) {
const featuredPlanKey = computed(() => {
const list = Array.isArray(pricing.value) ? pricing.value : []
const featured = list.find(p => p && p.is_featured && p.is_visible)
return featured?.plan_key || null
})
function goStart () {
if (featuredPlanKey.value) {
router.push(`/auth/signup?plan=${encodeURIComponent(featuredPlanKey.value)}&interval=${billingInterval.value}`)
return
}
router.push('/auth/signup')
}
function formatBRLFromCents (cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
if (!Number.isFinite(v)) return '—'
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function priceFor(p) {
return billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents
function priceFor (p) {
if (!p) return null
const cents = billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents
// fallback: se não existir anual, mostra mensal (e vice-versa)
if (cents == null) return billingInterval.value === 'year' ? p.monthly_cents : p.yearly_cents
return cents
}
async function fetchPricing() {
async function fetchPricing () {
loadingPricing.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select('*')
.eq('is_visible', true)
.order('sort_order', { ascending: true })
const { data, error } = await supabase
.from('v_public_pricing')
.select('*')
.eq('is_visible', true)
.order('sort_order', { ascending: true })
loadingPricing.value = false
if (!error) pricing.value = data || []
if (!error) pricing.value = data || []
} finally {
loadingPricing.value = false
}
}
onMounted(fetchPricing)
</script>
<style scoped>
.hero-grid {
background-image:
linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(circle at 30% 20%, black 0%, transparent 65%);
}
.hero-noise {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E");
background-size: 180px 180px;
}
.hero-underline {
position: relative;
display: inline-block;
white-space: nowrap;
}
.hero-underline::after {
content: "";
position: absolute;
left: -2%;
right: -2%;
bottom: 0.12em;
height: 0.5em;
background: linear-gradient(90deg, rgba(16,185,129,0.0), rgba(16,185,129,0.22), rgba(99,102,241,0.18), rgba(16,185,129,0.0));
border-radius: 999px;
z-index: -1;
filter: blur(0.2px);
}
</style>
@@ -63,9 +63,6 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import Button from 'primevue/button'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { useToast } from 'primevue/usetoast'
+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>