Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+236 -261
View File
@@ -14,327 +14,302 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Toast />
<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 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 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>
<!-- Body -->
<div class="p-6 border-t border-[var(--surface-border)]">
<!-- Loading -->
<div v-if="state.loading" class="text-sm">
Processando convite
</div>
<!-- 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>
<!-- 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-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>
</template>
<script setup>
import { reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { reactive, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast'
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
/**
* Token persistence (antes do login)
* - sessionStorage: some ao fechar a aba (bom para convite)
* - se você preferir cross-tab, use localStorage
*/
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1'
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1';
function persistPendingToken (token) {
try { sessionStorage.setItem(PENDING_INVITE_TOKEN_KEY, token) } catch (_) {}
function persistPendingToken(token) {
try {
sessionStorage.setItem(PENDING_INVITE_TOKEN_KEY, token);
} catch (_) {}
}
function readPendingToken () {
try { return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY) } catch (_) { return null }
function readPendingToken() {
try {
return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY);
} catch (_) {
return null;
}
}
function clearPendingToken () {
try { sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY) } catch (_) {}
function clearPendingToken() {
try {
sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY);
} catch (_) {}
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const tenantStore = useTenantStore()
const route = useRoute();
const router = useRouter();
const toast = useToast();
const tenantStore = useTenantStore();
const state = reactive({
loading: true,
success: false,
error: '',
debugDetails: '' // mantém vazio por padrão (pode ativar quando precisar)
})
loading: true,
success: false,
error: '',
debugDetails: '' // mantém vazio por padrão (pode ativar quando precisar)
});
const tokenFromQuery = computed(() => {
const t = route.query?.token
return typeof t === 'string' ? t.trim() : ''
})
const t = route.query?.token;
return typeof t === 'string' ? t.trim() : '';
});
const tokenEffective = computed(() => tokenFromQuery.value || readPendingToken() || '')
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)}`
})
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)
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);
}
function friendlyError (err) {
const msg = (err?.message || err || '').toString()
function friendlyError(err) {
const msg = (err?.message || err || '').toString();
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|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.'
}
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.'
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|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.';
}
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 }
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 goAdmin() {
await router.replace('/admin');
}
async function goLogin () {
const token = tokenEffective.value
if (token) persistPendingToken(token)
async function goLogin() {
const token = tokenEffective.value;
if (token) persistPendingToken(token);
// ✅ garante troca de conta (somente quando usuário clica)
try {
await supabase.auth.signOut()
} catch (_) {}
// ✅ garante troca de conta (somente quando usuário clica)
try {
await supabase.auth.signOut();
} catch (_) {}
// ✅ 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 } })
// ✅ 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 } });
}
async function acceptInvite (token) {
state.loading = true
state.error = ''
state.success = false
state.debugDetails = ''
async function acceptInvite(token) {
state.loading = true;
state.error = '';
state.success = false;
state.debugDetails = '';
// 1) sessão
const { data: sessionData, error: sessionErr } = await supabase.auth.getSession()
if (sessionErr) {
state.loading = false
state.error = friendlyError(sessionErr)
return
}
// 1) sessão
const { data: sessionData, error: sessionErr } = await supabase.auth.getSession();
if (sessionErr) {
state.loading = false;
state.error = friendlyError(sessionErr);
return;
}
const session = sessionData?.session
if (!session) {
// não logado → salva token e vai pro login
persistPendingToken(token)
const session = sessionData?.session;
if (!session) {
// não logado → salva token e vai pro login
persistPendingToken(token);
const returnTo = route.fullPath || `/accept-invite?token=${encodeURIComponent(token)}`
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
const returnTo = route.fullPath || `/accept-invite?token=${encodeURIComponent(token)}`;
await router.replace({ path: '/auth/login', query: { redirect: returnTo } });
state.loading = false
return
}
state.loading = false;
return;
}
// 2) chama RPC
const { data, error } = await supabase.rpc('tenant_accept_invite', { p_token: token })
// 2) chama RPC
const { data, error } = await supabase.rpc('tenant_accept_invite', { p_token: token });
if (error) {
state.loading = false
if (error) {
state.loading = false;
const { friendly, raw } = safeRpcError(error)
state.error = friendly
const { friendly, raw } = safeRpcError(error);
state.error = friendly;
// Se você quiser ver a mensagem "crua" para debug, descomente a linha abaixo:
// state.debugDetails = raw
// 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
}
// Opcional: toast discreto
toast.add({
severity: 'error',
summary: 'Falha no convite',
detail: friendly,
life: 4000
});
return;
}
// 3) sucesso → limpa token pendente
clearPendingToken()
// 3) sucesso → limpa token pendente
clearPendingToken();
// 4) atualiza tenantStore
const acceptedTenantId = data?.tenant_id || data?.tenantId || null
try {
await refreshTenantContextAfterInvite(acceptedTenantId)
} catch (_) {
// Silencioso: aceite ocorreu; não vamos quebrar o fluxo.
}
// 4) atualiza tenantStore
const acceptedTenantId = data?.tenant_id || data?.tenantId || null;
try {
await refreshTenantContextAfterInvite(acceptedTenantId);
} catch (_) {
// Silencioso: aceite ocorreu; não vamos quebrar o fluxo.
}
state.loading = false
state.success = true
state.loading = false;
state.success = true;
// 5) redireciona
await router.replace('/admin')
// 5) redireciona
await router.replace('/admin');
}
async function refreshTenantContextAfterInvite (acceptedTenantId) {
if (typeof tenantStore.refreshMyTenantsAndMemberships === 'function') {
await tenantStore.refreshMyTenantsAndMemberships()
} else {
if (typeof tenantStore.fetchMyTenants === 'function') await tenantStore.fetchMyTenants()
if (typeof tenantStore.fetchMyMemberships === 'function') await tenantStore.fetchMyMemberships()
}
async function refreshTenantContextAfterInvite(acceptedTenantId) {
if (typeof tenantStore.refreshMyTenantsAndMemberships === 'function') {
await tenantStore.refreshMyTenantsAndMemberships();
} else {
if (typeof tenantStore.fetchMyTenants === 'function') await tenantStore.fetchMyTenants();
if (typeof tenantStore.fetchMyMemberships === 'function') await tenantStore.fetchMyMemberships();
}
if (acceptedTenantId && typeof tenantStore.setActiveTenantId === 'function') {
tenantStore.setActiveTenantId(acceptedTenantId)
}
if (acceptedTenantId && typeof tenantStore.setActiveTenantId === 'function') {
tenantStore.setActiveTenantId(acceptedTenantId);
}
if (typeof tenantStore.hydrateActiveTenantContext === 'function') {
await tenantStore.hydrateActiveTenantContext()
} else if (typeof tenantStore.refreshActiveTenant === 'function') {
await tenantStore.refreshActiveTenant()
}
if (typeof tenantStore.hydrateActiveTenantContext === 'function') {
await tenantStore.hydrateActiveTenantContext();
} else if (typeof tenantStore.refreshActiveTenant === 'function') {
await tenantStore.refreshActiveTenant();
}
}
async function run () {
state.loading = true
state.error = ''
state.success = false
state.debugDetails = ''
async function run() {
state.loading = true;
state.error = '';
state.success = false;
state.debugDetails = '';
const token = tokenEffective.value
const token = tokenEffective.value;
if (!token) {
state.loading = false
state.error = 'Token ausente. Abra novamente o link do convite.'
return
}
if (!token) {
state.loading = false;
state.error = 'Token ausente. Abra novamente o link do convite.';
return;
}
if (!isUuid(token)) {
state.loading = false
state.error = 'Token inválido. Verifique se o link está completo.'
return
}
if (!isUuid(token)) {
state.loading = false;
state.error = 'Token inválido. Verifique se o link está completo.';
return;
}
// Se veio da query, persiste (caso precise atravessar login)
if (tokenFromQuery.value) persistPendingToken(token)
// Se veio da query, persiste (caso precise atravessar login)
if (tokenFromQuery.value) persistPendingToken(token);
await acceptInvite(token)
await acceptInvite(token);
}
async function retry () {
await run()
async function retry() {
await run();
}
onMounted(run)
</script>
onMounted(run);
</script>
<template>
<Toast />
<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 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 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>
<!-- Body -->
<div class="p-6 border-t border-[var(--surface-border)]">
<!-- Loading -->
<div v-if="state.loading" class="text-sm">Processando convite</div>
<!-- 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>
<!-- 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-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>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+389 -485
View File
@@ -1,519 +1,423 @@
<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 { 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'
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 router = useRouter();
const brandName = 'Psi Quasar' // ajuste para o nome final do produto
const year = computed(() => new Date().getFullYear())
const brandName = 'Psi Quasar'; // ajuste para o nome final do produto
const year = computed(() => new Date().getFullYear());
function go(path) {
router.push(path)
router.push(path);
}
function scrollTo(id) {
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
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
})
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
}
if (featuredPlanKey.value) {
router.push(`/auth/signup?plan=${featuredPlanKey.value}&interval=${billingInterval.value}`);
return;
}
router.push('/auth/signup')
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 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)
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' })
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
return billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents;
}
async function fetchPricing() {
loadingPricing.value = true
loadingPricing.value = true;
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
loadingPricing.value = false;
if (!error) pricing.value = data || []
if (!error) pricing.value = data || [];
}
onMounted(fetchPricing)
onMounted(fetchPricing);
</script>
<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>
File diff suppressed because it is too large Load Diff
@@ -14,166 +14,129 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="p-4">
<!-- HEADER -->
<div class="flex flex-column md:flex-row md:align-items-center md:justify-content-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold">Cadastro Externo</div>
<div class="text-600 mt-1">
Gere um link para o paciente preencher o pré-cadastro.
</div>
</div>
<div class="flex gap-2">
<Button
label="Gerar novo link"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="rotating"
@click="rotateLink"
/>
</div>
</div>
<!-- CARD -->
<Card class="mt-4">
<template #title>Seu link</template>
<template #subtitle>Envie este link ao paciente.</template>
<template #content>
<div class="flex flex-column gap-3">
<div class="p-inputgroup">
<InputText
readonly
:value="publicUrl"
placeholder="Gerando seu link…"
/>
<Button
icon="pi pi-copy"
severity="secondary"
outlined
:disabled="!publicUrl"
@click="copyLink"
v-tooltip.bottom="'Copiar'"
/>
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
:disabled="!publicUrl"
@click="openLink"
v-tooltip.bottom="'Abrir'"
/>
</div>
<Message v-if="!inviteToken" severity="info" :closable="false">
Gerando seu link...
</Message>
</div>
</template>
</Card>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import Message from 'primevue/message'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref } from 'vue';
import Message from 'primevue/message';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client' // ajuste se seu caminho for diferente
import { supabase } from '@/lib/supabase/client'; // ajuste se seu caminho for diferente
const toast = useToast()
const toast = useToast();
const inviteToken = ref('')
const rotating = ref(false)
const inviteToken = ref('');
const rotating = ref(false);
const origin = computed(() => window.location.origin)
const origin = computed(() => window.location.origin);
const publicUrl = computed(() => {
if (!inviteToken.value) return ''
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`
})
if (!inviteToken.value) return '';
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`;
});
function newToken () {
// browsers modernos
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
// fallback simples
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
function newToken() {
// browsers modernos
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
// fallback simples
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
}
async function requireUserId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Usuário não autenticado')
return uid
async function requireUserId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado');
return uid;
}
async function loadOrCreateInvite () {
const uid = await requireUserId()
async function loadOrCreateInvite() {
const uid = await requireUserId();
const { data, error } = await supabase
.from('patient_invites')
.select('token, active')
.eq('owner_id', uid)
.eq('active', true)
.order('created_at', { ascending: false })
.limit(1)
const { data, error } = await supabase.from('patient_invites').select('token, active').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
if (error) throw error
if (error) throw error;
const token = data?.[0]?.token
if (token) {
inviteToken.value = token
return
}
const token = data?.[0]?.token;
if (token) {
inviteToken.value = token;
return;
}
const t = newToken()
const { error: insErr } = await supabase
.from('patient_invites')
.insert({ owner_id: uid, token: t, active: true })
const t = newToken();
const { error: insErr } = await supabase.from('patient_invites').insert({ owner_id: uid, token: t, active: true });
if (insErr) throw insErr
inviteToken.value = t
if (insErr) throw insErr;
inviteToken.value = t;
}
async function rotateLink () {
rotating.value = true
try {
const t = newToken()
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (error) throw error
async function rotateLink() {
rotating.value = true;
try {
const t = newToken();
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t });
if (error) throw error;
inviteToken.value = t
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
} finally {
rotating.value = false
}
inviteToken.value = t;
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 });
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 });
} finally {
rotating.value = false;
}
}
async function copyLink () {
try {
if (!publicUrl.value) return
await navigator.clipboard.writeText(publicUrl.value)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
}
async function copyLink() {
try {
if (!publicUrl.value) return;
await navigator.clipboard.writeText(publicUrl.value);
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 });
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 });
}
}
function openLink () {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
function openLink() {
if (!publicUrl.value) return;
window.open(publicUrl.value, '_blank', 'noopener');
}
onMounted(async () => {
try {
await loadOrCreateInvite()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
}
})
try {
await loadOrCreateInvite();
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 });
}
});
</script>
<template>
<div class="p-4">
<!-- HEADER -->
<div class="flex flex-column md:flex-row md:align-items-center md:justify-content-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold">Cadastro Externo</div>
<div class="text-600 mt-1">Gere um link para o paciente preencher o pré-cadastro.</div>
</div>
<div class="flex gap-2">
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined :loading="rotating" @click="rotateLink" />
</div>
</div>
<!-- CARD -->
<Card class="mt-4">
<template #title>Seu link</template>
<template #subtitle>Envie este link ao paciente.</template>
<template #content>
<div class="flex flex-column gap-3">
<div class="p-inputgroup">
<InputText readonly :value="publicUrl" placeholder="Gerando seu link…" />
<Button icon="pi pi-copy" severity="secondary" outlined :disabled="!publicUrl" @click="copyLink" v-tooltip.bottom="'Copiar'" />
<Button icon="pi pi-external-link" severity="secondary" outlined :disabled="!publicUrl" @click="openLink" v-tooltip.bottom="'Abrir'" />
</div>
<Message v-if="!inviteToken" severity="info" :closable="false"> Gerando seu link... </Message>
</div>
</template>
</Card>
</div>
</template>
+396 -454
View File
@@ -15,538 +15,480 @@
|--------------------------------------------------------------------------
-->
<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 { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import Password from 'primevue/password'
import Chip from 'primevue/chip'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import Password from 'primevue/password';
import Chip from 'primevue/chip';
import Message from 'primevue/message';
import ProgressSpinner from 'primevue/progressspinner';
const route = useRoute()
const router = useRouter()
const toast = useToast()
const route = useRoute();
const router = useRouter();
const toast = useToast();
// ============================
// Form
// ============================
const email = ref('')
const password = ref('')
const loading = ref(false)
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)
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())
const planFromQuery = computed(() =>
String(route.query.plan || '')
.trim()
.toLowerCase()
);
const intervalFromQuery = computed(() =>
String(route.query.interval || '')
.trim()
.toLowerCase()
);
function normalizeInterval (v) {
if (v === 'monthly') return 'month'
if (v === 'annual' || v === 'annually' || v === 'yearly') return 'year'
return v
function normalizeInterval(v) {
if (v === 'monthly') return 'month';
if (v === 'annual' || v === 'annually' || v === 'yearly') return 'year';
return v;
}
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value))
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value));
function isValidInterval (v) {
return v === 'month' || v === 'year'
function isValidInterval(v) {
return v === 'month' || v === 'year';
}
const hasPlanQuery = computed(() => !!planFromQuery.value && isValidInterval(intervalNormalized.value))
const hasPlanQuery = computed(() => !!planFromQuery.value && isValidInterval(intervalNormalized.value));
const intervalLabel = computed(() => {
if (intervalNormalized.value === 'year') return 'Anual'
if (intervalNormalized.value === 'month') return 'Mensal'
return ''
})
if (intervalNormalized.value === 'year') return 'Anual';
if (intervalNormalized.value === 'month') return 'Mensal';
return '';
});
// ============================
// Fetch pricing from v_public_pricing
// ============================
const selectedPlanRow = ref(null)
const pricingLoading = ref(false)
const selectedPlanRow = ref(null);
const pricingLoading = ref(false);
const selectedPlanName = computed(() => selectedPlanRow.value?.public_name || selectedPlanRow.value?.plan_name || null)
const selectedBadge = computed(() => selectedPlanRow.value?.badge || null)
const selectedDescription = computed(() => selectedPlanRow.value?.public_description || null)
const selectedPlanName = computed(() => selectedPlanRow.value?.public_name || selectedPlanRow.value?.plan_name || null);
const selectedBadge = computed(() => selectedPlanRow.value?.badge || null);
const selectedDescription = computed(() => selectedPlanRow.value?.public_description || null);
const bullets = computed(() => {
const b = selectedPlanRow.value?.bullets
return Array.isArray(b) ? b : []
})
const b = selectedPlanRow.value?.bullets;
return Array.isArray(b) ? b : [];
});
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
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;
}
function currencyForInterval (row, interval) {
if (!row) return 'BRL'
const cur = interval === 'year' ? (row.yearly_currency || 'BRL') : (row.monthly_currency || 'BRL')
return cur || '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 amountCents = computed(() => amountForInterval(selectedPlanRow.value, intervalNormalized.value));
const currency = computed(() => currencyForInterval(selectedPlanRow.value, intervalNormalized.value));
const formattedPrice = computed(() => {
if (amountCents.value == null) return null
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency.value || 'BRL' })
.format(Number(amountCents.value) / 100)
} catch {
return null
}
})
if (amountCents.value == null) return null;
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)
const showPlanCard = computed(() => hasPlanQuery.value && !!selectedPlanRow.value);
async function loadSelectedPlanRow () {
selectedPlanRow.value = null
if (!planFromQuery.value) return
async function loadSelectedPlanRow() {
selectedPlanRow.value = null;
if (!planFromQuery.value) return;
pricingLoading.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select(
'plan_key, plan_name, public_name, public_description, badge, is_featured, is_visible, monthly_cents, yearly_cents, monthly_currency, yearly_currency, bullets'
)
.eq('plan_key', planFromQuery.value)
.eq('is_visible', true)
.maybeSingle()
pricingLoading.value = true;
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select('plan_key, plan_name, public_name, public_description, badge, is_featured, is_visible, monthly_cents, yearly_cents, monthly_currency, yearly_currency, bullets')
.eq('plan_key', planFromQuery.value)
.eq('is_visible', true)
.maybeSingle();
if (error) throw error
if (!data) return
selectedPlanRow.value = data
} catch (err) {
console.error('[Signup] loadSelectedPlanRow:', err)
} finally {
pricingLoading.value = false
}
if (error) throw error;
if (!data) return;
selectedPlanRow.value = data;
} catch (err) {
console.error('[Signup] loadSelectedPlanRow:', err);
} finally {
pricingLoading.value = false;
}
}
onMounted(loadSelectedPlanRow)
onMounted(loadSelectedPlanRow);
watch(
() => [planFromQuery.value, intervalNormalized.value],
() => loadSelectedPlanRow()
)
() => [planFromQuery.value, intervalNormalized.value],
() => loadSelectedPlanRow()
);
// ============================
// subscription_intent (MODELO B: tenant)
// ============================
async function getActiveTenantIdForUser (userId) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', userId)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
async function getActiveTenantIdForUser(userId) {
const { data, error } = await supabase.from('tenant_members').select('tenant_id').eq('user_id', userId).eq('status', 'active').order('created_at', { ascending: false }).limit(1).maybeSingle();
if (error) throw error
return data?.tenant_id || null
if (error) throw error;
return data?.tenant_id || null;
}
async function createSubscriptionIntentAfterSignup (userId, preferredTenantId = null) {
if (!hasPlanQuery.value) return
if (!selectedPlanRow.value) return
if (amountCents.value == null) return
async function createSubscriptionIntentAfterSignup(userId, preferredTenantId = null) {
if (!hasPlanQuery.value) return;
if (!selectedPlanRow.value) return;
if (amountCents.value == null) return;
const tenantId = preferredTenantId || (await getActiveTenantIdForUser(userId))
if (!tenantId) throw new Error('Não encontrei tenant ativo para este usuário.')
const 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,
const payload = {
tenant_id: tenantId,
created_by_user_id: userId,
// opcional (se sua tabela ainda tem user_id)
user_id: userId,
// opcional (se sua tabela ainda tem user_id)
user_id: userId,
email: String(email.value || '').trim().toLowerCase() || null,
plan_key: selectedPlanRow.value.plan_key,
interval: intervalNormalized.value,
amount_cents: amountCents.value,
currency: currency.value || 'BRL',
status: 'new',
source: 'landing'
}
email:
String(email.value || '')
.trim()
.toLowerCase() || null,
plan_key: selectedPlanRow.value.plan_key,
interval: intervalNormalized.value,
amount_cents: amountCents.value,
currency: currency.value || 'BRL',
status: 'new',
source: 'landing'
};
const { error } = await supabase.from('subscription_intents').insert(payload)
if (error) throw error
const { error } = await supabase.from('subscription_intents').insert(payload);
if (error) throw error;
}
// ============================
// Nav
// ============================
function goLogin () {
router.push({
path: '/auth/login',
query: email.value ? { email: String(email.value).trim() } : undefined
})
function goLogin() {
router.push({
path: '/auth/login',
query: email.value ? { email: String(email.value).trim() } : undefined
});
}
function goBackPricing () {
// você usa /lp#pricing — mantive
router.push('/lp#pricing')
function goBackPricing() {
// você usa /lp#pricing — mantive
router.push('/lp#pricing');
}
// ============================
// Signup
// ============================
async function onSignup () {
if (!canSubmit.value) return
async function onSignup() {
if (!canSubmit.value) return;
loading.value = true
try {
const cleanEmail = String(email.value || '').trim().toLowerCase()
loading.value = true;
try {
const cleanEmail = String(email.value || '')
.trim()
.toLowerCase();
const { data, error } = await supabase.auth.signUp({
email: cleanEmail,
password: password.value
})
const { data, error } = await supabase.auth.signUp({
email: cleanEmail,
password: password.value
});
if (error) throw error
if (error) throw error;
const userId = data?.user?.id || null
const userId = data?.user?.id || null;
// ✅ Modelo B: garante tenant pessoal (não aborta se falhar)
let tenantId = null
if (userId) {
try {
const resTenant = await supabase.rpc('ensure_personal_tenant')
tenantId = resTenant?.data || null
} catch (e) {
console.warn('[Signup] ensure_personal_tenant falhou:', e)
}
// ✅ Modelo B: garante tenant pessoal (não aborta se falhar)
let tenantId = null;
if (userId) {
try {
const resTenant = await supabase.rpc('ensure_personal_tenant');
tenantId = resTenant?.data || null;
} catch (e) {
console.warn('[Signup] ensure_personal_tenant falhou:', e);
}
// ✅ intent (não quebra signup se falhar)
try {
await createSubscriptionIntentAfterSignup(userId, tenantId);
} catch (e) {
console.error('[Signup] subscription_intent failed:', e);
toast.add({
severity: 'warn',
summary: 'Conta criada',
detail: 'Não consegui registrar a intenção do plano. Você pode seguir normalmente.',
life: 4500
});
}
}
// ✅ intent (não quebra signup se falhar)
try {
await createSubscriptionIntentAfterSignup(userId, tenantId)
} catch (e) {
console.error('[Signup] subscription_intent failed:', e)
toast.add({
severity: 'warn',
summary: 'Conta criada',
detail: 'Não consegui registrar a intenção do plano. Você pode seguir normalmente.',
life: 4500
})
}
severity: 'success',
summary: 'Conta criada',
detail: 'Agora vamos para os próximos passos.',
life: 2500
});
router.push({
path: '/auth/welcome',
query: {
plan: planFromQuery.value || undefined,
interval: intervalNormalized.value || undefined
}
});
} catch (err) {
console.error(err);
const msg = String(err?.message || '');
const isAlreadyRegistered = err?.name === 'AuthApiError' && /User already registered/i.test(msg);
if (isAlreadyRegistered) {
toast.add({
severity: 'warn',
summary: 'Esse email já tem conta',
detail: 'Faça login para continuar.',
life: 4500
});
goLogin();
return;
}
toast.add({
severity: 'error',
summary: 'Erro ao criar conta',
detail: err?.message || 'Tente novamente.',
life: 4500
});
} finally {
loading.value = false;
}
toast.add({
severity: 'success',
summary: 'Conta criada',
detail: 'Agora vamos para os próximos passos.',
life: 2500
})
router.push({
path: '/auth/welcome',
query: {
plan: planFromQuery.value || undefined,
interval: intervalNormalized.value || undefined
}
})
} catch (err) {
console.error(err)
const msg = String(err?.message || '')
const isAlreadyRegistered = err?.name === 'AuthApiError' && /User already registered/i.test(msg)
if (isAlreadyRegistered) {
toast.add({
severity: 'warn',
summary: 'Esse email já tem conta',
detail: 'Faça login para continuar.',
life: 4500
})
goLogin()
return
}
toast.add({
severity: 'error',
summary: 'Erro ao criar conta',
detail: err?.message || 'Tente novamente.',
life: 4500
})
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)] flex items-center justify-center p-4">
<!-- fundo suave (noir glow) -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<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="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 -->
<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">
<i class="pi pi-sparkles opacity-80 text-lg" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">Psi Quasar</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<Divider class="my-6" />
<div class="text-3xl md:text-4xl font-semibold leading-tight">
Menos dispersão. Mais presença.
</div>
<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>
<div class="mt-6 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-ground)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">Agenda</div>
<div class="text-xl font-semibold mt-1">Organizada</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">encaixes, bloqueios e visão clara</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">Financeiro</div>
<div class="text-xl font-semibold mt-1">Respirável</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">sem planilhas espalhadas</div>
</div>
</div>
<div class="col-span-12">
<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="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Histórico por sessão</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">linha do tempo por paciente</div>
</div>
<i class="pi pi-file-edit opacity-60" />
</div>
</div>
</div>
</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 class="mt-6 text-xs text-[var(--text-color-secondary)]">
* Painel conceitual inspirado em layouts PrimeBlocks.
</div>
</div>
<!-- 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>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
tem conta?
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
</div>
<!-- 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 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 v-if="intervalLabel" :label="intervalLabel" />
</div>
<div class="mt-2 text-2xl font-semibold leading-none">
{{ formattedPrice || '—' }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ intervalNormalized === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<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"
severity="secondary"
text
rounded
aria-label="Trocar plano"
@click="goBackPricing"
/>
</div>
<Divider class="my-4" />
<ul v-if="bullets.length" class="space-y-2 text-sm">
<li v-for="b in bullets" :key="b.id ?? b.text" 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>
<Message v-else severity="info" class="mt-2">
Benefícios ainda não cadastrados para esse plano.
</Message>
</template>
</Card>
<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"
/>
</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"
/>
</div>
</Message>
<Message
v-if="hasPlanQuery && selectedPlanRow && amountCents == null && !pricingLoading"
severity="warn"
class="mt-3"
>
Esse plano não tem preço configurado para {{ intervalLabel }}. Você ainda pode criar a conta normalmente.
</Message>
</div>
<Divider class="my-6" />
<!-- 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>
<div v-if="email && !emailOk" class="mt-2 text-xs text-orange-600">
Informe um e-mail válido.
</div>
</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>
<Button
label="CRIAR CONTA"
class="w-full"
severity="success"
:loading="loading"
:disabled="!canSubmit"
icon="pi pi-arrow-right"
@click="onSignup"
/>
<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">
<a class="cursor-pointer text-[var(--text-color-secondary)] hover:underline" @click.prevent="goLogin">
tenho conta entrar
</a>
</div>
</div>
</div>
</div>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)] flex items-center justify-center p-4">
<!-- fundo suave (noir glow) -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<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>
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">
Agência PSI gestão clínica sem ruído.
</div>
<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 -->
<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">
<i class="pi pi-sparkles opacity-80 text-lg" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">Psi Quasar</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<Divider class="my-6" />
<div class="text-3xl md:text-4xl font-semibold leading-tight">Menos dispersão. Mais presença.</div>
<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>
<div class="mt-6 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-ground)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">Agenda</div>
<div class="text-xl font-semibold mt-1">Organizada</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">encaixes, bloqueios e visão clara</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">Financeiro</div>
<div class="text-xl font-semibold mt-1">Respirável</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">sem planilhas espalhadas</div>
</div>
</div>
<div class="col-span-12">
<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="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Histórico por sessão</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">linha do tempo por paciente</div>
</div>
<i class="pi pi-file-edit opacity-60" />
</div>
</div>
</div>
</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 class="mt-6 text-xs text-[var(--text-color-secondary)]">* Painel conceitual inspirado em layouts PrimeBlocks.</div>
</div>
<!-- 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>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
tem conta?
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
</div>
<!-- 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 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 v-if="intervalLabel" :label="intervalLabel" />
</div>
<div class="mt-2 text-2xl font-semibold leading-none">
{{ formattedPrice || '—' }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ intervalNormalized === 'month' ? 'mês' : 'ano' }} </span>
</div>
<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" severity="secondary" text rounded aria-label="Trocar plano" @click="goBackPricing" />
</div>
<Divider class="my-4" />
<ul v-if="bullets.length" class="space-y-2 text-sm">
<li v-for="b in bullets" :key="b.id ?? b.text" 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>
<Message v-else severity="info" class="mt-2"> Benefícios ainda não cadastrados para esse plano. </Message>
</template>
</Card>
<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" />
</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" />
</div>
</Message>
<Message v-if="hasPlanQuery && selectedPlanRow && amountCents == null && !pricingLoading" severity="warn" class="mt-3">
Esse plano não tem preço configurado para {{ intervalLabel }}. Você ainda pode criar a conta normalmente.
</Message>
</div>
<Divider class="my-6" />
<!-- 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>
<div v-if="email && !emailOk" class="mt-2 text-xs text-orange-600">Informe um e-mail válido.</div>
</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>
<Button label="CRIAR CONTA" class="w-full" severity="success" :loading="loading" :disabled="!canSubmit" icon="pi pi-arrow-right" @click="onSignup" />
<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">
<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">Agência PSI gestão clínica sem ruído.</div>
</div>
</div>
</div>
</template>
</template>