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