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:
+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