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:
@@ -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 v1–v5 (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 v1–v5 (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
@@ -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 só: 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 só: 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
@@ -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">
|
||||
Já 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">
|
||||
Já 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">
|
||||
Já 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"> Já 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>
|
||||
|
||||
Reference in New Issue
Block a user