Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import Textarea from 'primevue/textarea';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Message from 'primevue/message';
|
||||
import Popover from 'primevue/popover';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
@@ -32,15 +33,32 @@ import Select from 'primevue/select';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { digitsOnly, isValidEmail, toISODate, generateCPF, fmtCPF } from '@/utils/validators';
|
||||
import MathCaptchaChallenge from '@/components/security/MathCaptchaChallenge.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
// A#20 rev2 — defesa em camadas self-hosted
|
||||
// Honeypot: campo invisível que humano nunca preenche
|
||||
const honeypot = ref('');
|
||||
// Math captcha: ativado sob demanda quando edge function devolve 403 captcha-required
|
||||
const captchaRequired = ref(false);
|
||||
const captchaId = ref('');
|
||||
const captchaAnswer = ref(null);
|
||||
|
||||
const salvando = ref(false);
|
||||
const sucesso = ref(false);
|
||||
|
||||
// A#25: dialog de política de privacidade (LGPD)
|
||||
const policyOpen = ref(false);
|
||||
|
||||
const token = computed(() => String(route.query.t || '').trim());
|
||||
const tokenOk = computed(() => token.value.length >= 10);
|
||||
// A#21: validação mais estrita — aceita UUID (com ou sem hífen, 32 hex chars).
|
||||
// Ainda é cosmético (servidor valida de verdade), mas reduz falsos "verificado".
|
||||
const TOKEN_RX = /^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const tokenOk = computed(() => TOKEN_RX.test(token.value));
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Helpers (✅ ficam ANTES do enviar())
|
||||
@@ -134,7 +152,6 @@ function preencherMock() {
|
||||
form.rg = generateRG();
|
||||
|
||||
form.observacoes = maybe(0.5) ? 'Cadastro realizado via link externo.' : 'Tenho disponibilidade no período da noite.';
|
||||
form.notas_internas = maybe(0.5) ? 'Paciente demonstrou interesse em iniciar nas próximas semanas.' : '';
|
||||
|
||||
form.onde_nos_conheceu = pick(['Instagram', 'Google', 'Indicação', 'Site', 'Threads', 'Outro']);
|
||||
form.encaminhado_por = maybe(0.45) ? `${pick(first)} ${pick(last)}` : '';
|
||||
@@ -167,63 +184,9 @@ function preencherMock() {
|
||||
toast.add({ severity: 'info', summary: 'Exemplo', detail: 'Campos preenchidos com dados simulados.', life: 1800 });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Foto (upload opcional)
|
||||
// ------------------------------------------------------
|
||||
const fotoFile = ref(null);
|
||||
const fotoPreviewUrl = ref('');
|
||||
const fotoErro = ref('');
|
||||
|
||||
function limparFoto() {
|
||||
fotoFile.value = null;
|
||||
fotoErro.value = '';
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
fotoPreviewUrl.value = '';
|
||||
form.foto_url = '';
|
||||
}
|
||||
|
||||
function onFotoPicked(evt) {
|
||||
fotoErro.value = '';
|
||||
const file = evt?.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type?.startsWith('image/')) {
|
||||
fotoErro.value = 'Arquivo inválido. Envie uma imagem.';
|
||||
return;
|
||||
}
|
||||
const maxMb = 8;
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
fotoErro.value = `Imagem muito grande. Máx: ${maxMb}MB.`;
|
||||
return;
|
||||
}
|
||||
|
||||
fotoFile.value = file;
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
fotoPreviewUrl.value = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
const AVATAR_BUCKET = 'avatars';
|
||||
|
||||
async function uploadFotoSeHouver() {
|
||||
if (!fotoFile.value) return null;
|
||||
|
||||
const ext = (fotoFile.value.name.split('.').pop() || 'jpg').toLowerCase();
|
||||
const filePath = `intakes/${token.value}/${Date.now()}.${ext}`;
|
||||
|
||||
try {
|
||||
const { error: upErr } = await supabase.storage.from(AVATAR_BUCKET).upload(filePath, fotoFile.value, {
|
||||
upsert: true,
|
||||
contentType: fotoFile.value.type
|
||||
});
|
||||
if (upErr) throw upErr;
|
||||
|
||||
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(filePath);
|
||||
return pub?.publicUrl || null;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Foto', detail: `Upload falhou: ${e?.message || 'erro desconhecido'}`, life: 4500 });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Upload de foto removido da página pública (A#15):
|
||||
// O bucket não aceita mais upload anônimo. O terapeuta pode pedir a foto
|
||||
// depois, por outro canal, se for necessário.
|
||||
|
||||
// ------------------------------------------------------
|
||||
// CEP (ViaCEP)
|
||||
@@ -299,8 +262,7 @@ const navItems = [
|
||||
{ value: '0', label: 'Informações pessoais', icon: 'pi pi-user' },
|
||||
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
|
||||
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-tags' },
|
||||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
|
||||
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' }
|
||||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' }
|
||||
];
|
||||
|
||||
const navPopover = ref(null);
|
||||
@@ -333,7 +295,6 @@ onBeforeUnmount(() => {
|
||||
if (mql.removeEventListener) mql.removeEventListener('change', mqlHandler);
|
||||
else mql.removeListener(mqlHandler);
|
||||
}
|
||||
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------
|
||||
@@ -378,7 +339,6 @@ function resetForm() {
|
||||
onde_nos_conheceu: '',
|
||||
encaminhado_por: '',
|
||||
observacoes: '',
|
||||
notas_internas: '',
|
||||
nacionalidade: '',
|
||||
|
||||
email_alternativo: '',
|
||||
@@ -396,9 +356,7 @@ function resetForm() {
|
||||
telefone_responsavel: '',
|
||||
cpf_responsavel: '',
|
||||
observacao_responsavel: '',
|
||||
cobranca_no_responsavel: false,
|
||||
|
||||
foto_url: ''
|
||||
cobranca_no_responsavel: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,53 +421,53 @@ async function enviar() {
|
||||
|
||||
salvando.value = true;
|
||||
try {
|
||||
const fotoUrl = await uploadFotoSeHouver();
|
||||
if (fotoUrl) form.foto_url = fotoUrl;
|
||||
// A#17: notas_internas NÃO é enviado (campo interno do terapeuta, não
|
||||
// deve vir do paciente). A#15: avatar_url removido (upload não é mais
|
||||
// possível anonimamente). Todos os campos texto são recortados em
|
||||
// length máximo client-side (defesa em camadas — o RPC também valida).
|
||||
const truncate = (v, n) => {
|
||||
const s = cleanStr(v);
|
||||
return s ? String(s).slice(0, n) : null;
|
||||
};
|
||||
|
||||
const payload = {
|
||||
// essenciais
|
||||
nome_completo: cleanStr(form.nome_completo),
|
||||
email_principal: cleanStr(form.email_principal)?.toLowerCase() || null,
|
||||
nome_completo: truncate(form.nome_completo, 200),
|
||||
email_principal: cleanStr(form.email_principal)?.toLowerCase().slice(0, 120) || null,
|
||||
telefone: digitsOnly(form.telefone),
|
||||
|
||||
avatar_url: form.foto_url || null,
|
||||
|
||||
// alternativos
|
||||
email_alternativo: cleanStr(form.email_alternativo)?.toLowerCase() || null,
|
||||
email_alternativo: cleanStr(form.email_alternativo)?.toLowerCase().slice(0, 120) || null,
|
||||
telefone_alternativo: digitsOnly(form.telefone_alternativo) || null,
|
||||
|
||||
// docs / endereço
|
||||
cpf: digitsOnly(form.cpf),
|
||||
rg: cleanStr(form.rg),
|
||||
rg: truncate(form.rg, 20),
|
||||
cep: digitsOnly(form.cep),
|
||||
|
||||
pais: cleanStr(form.pais) || 'Brasil',
|
||||
cidade: cleanStr(form.cidade),
|
||||
estado: cleanStr(form.estado) || 'SP',
|
||||
endereco: cleanStr(form.endereco),
|
||||
numero: cleanStr(form.numero),
|
||||
bairro: cleanStr(form.bairro),
|
||||
complemento: cleanStr(form.complemento),
|
||||
pais: truncate(form.pais, 60) || 'Brasil',
|
||||
cidade: truncate(form.cidade, 120),
|
||||
estado: truncate(form.estado, 2) || 'SP',
|
||||
endereco: truncate(form.endereco, 200),
|
||||
numero: truncate(form.numero, 20),
|
||||
bairro: truncate(form.bairro, 120),
|
||||
complemento: truncate(form.complemento, 120),
|
||||
|
||||
// pessoais
|
||||
data_nascimento: isoBirth || null,
|
||||
naturalidade: cleanStr(form.naturalidade),
|
||||
naturalidade: truncate(form.naturalidade, 120),
|
||||
genero: cleanStr(form.genero),
|
||||
estado_civil: cleanStr(form.estado_civil),
|
||||
|
||||
// adicionais (existem na tabela!)
|
||||
profissao: cleanStr(form.profissao),
|
||||
escolaridade: cleanStr(form.escolaridade),
|
||||
nacionalidade: cleanStr(form.nacionalidade),
|
||||
// adicionais
|
||||
profissao: truncate(form.profissao, 120),
|
||||
escolaridade: truncate(form.escolaridade, 120),
|
||||
nacionalidade: truncate(form.nacionalidade, 80),
|
||||
|
||||
// origem + texto
|
||||
onde_nos_conheceu: cleanStr(form.onde_nos_conheceu),
|
||||
encaminhado_por: cleanStr(form.encaminhado_por),
|
||||
observacoes: cleanStr(form.observacoes),
|
||||
|
||||
// ⚠️ eu recomendo NÃO enviar isso no externo,
|
||||
// mas a coluna existe — então deixo opcional:
|
||||
notas_internas: cleanStr(form.notas_internas),
|
||||
// origem + observações
|
||||
onde_nos_conheceu: truncate(form.onde_nos_conheceu, 80),
|
||||
encaminhado_por: truncate(form.encaminhado_por, 120),
|
||||
observacoes: truncate(form.observacoes, 2000),
|
||||
|
||||
// consent
|
||||
consent: !!consent.value
|
||||
@@ -519,28 +477,68 @@ async function enviar() {
|
||||
if (payload[k] === undefined) delete payload[k];
|
||||
});
|
||||
|
||||
let { error } = await supabase.rpc('create_patient_intake_request_v2', {
|
||||
p_token: token.value,
|
||||
p_payload: payload
|
||||
});
|
||||
// A#24: envia user_agent pra log de tentativas (sem PII).
|
||||
const clientInfo = (typeof navigator !== 'undefined' && navigator.userAgent)
|
||||
? String(navigator.userAgent).slice(0, 500)
|
||||
: null;
|
||||
|
||||
const isFnMissing = error && (error.code === '42883' || /create_patient_intake_request_v2/i.test(error.message));
|
||||
|
||||
const isOldSchemaColumn = error && (/column .* of relation .*patient_intake_requests.* does not exist/i.test(error.message) || /column "name" of relation "patient_intake_requests" does not exist/i.test(error.message));
|
||||
|
||||
if (isFnMissing || isOldSchemaColumn) {
|
||||
const { error: e2 } = await supabase.rpc('create_patient_intake_request', {
|
||||
p_token: token.value,
|
||||
p_name: payload.nome_completo,
|
||||
p_email: payload.email_principal,
|
||||
p_phone: payload.telefone,
|
||||
p_notes: payload.observacoes || null,
|
||||
p_consent: !!payload.consent
|
||||
});
|
||||
error = e2;
|
||||
// A#20 rev2: se já está exigindo captcha mas usuário não respondeu, bloqueia local
|
||||
if (captchaRequired.value && (!captchaId.value || captchaAnswer.value == null)) {
|
||||
toast.add({ severity: 'warn', summary: 'Verificação', detail: 'Responda a pergunta de verificação antes de enviar.', life: 3000 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
const { data, error } = await supabase.functions.invoke('submit-patient-intake', {
|
||||
body: {
|
||||
token: token.value,
|
||||
payload,
|
||||
website: honeypot.value, // honeypot field
|
||||
captcha_id: captchaId.value || null,
|
||||
captcha_answer: captchaAnswer.value,
|
||||
client_info: clientInfo
|
||||
}
|
||||
});
|
||||
|
||||
// Quando edge devolve 4xx/5xx, supabase-js coloca a Response em error.context.
|
||||
// O body com {error: 'captcha-required' | 'rate-limited' | ...} continua acessível.
|
||||
let errBody = null;
|
||||
if (error?.context?.json) {
|
||||
try { errBody = await error.context.json(); } catch { /* sem body */ }
|
||||
}
|
||||
const errMsg = String(errBody?.error || data?.error || error?.message || '');
|
||||
if (/captcha-required/i.test(errMsg)) {
|
||||
captchaRequired.value = true;
|
||||
captchaId.value = '';
|
||||
captchaAnswer.value = null;
|
||||
toast.add({ severity: 'warn', summary: 'Verificação extra', detail: 'Por segurança, responda a pergunta abaixo.', life: 3500 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (/captcha-wrong/i.test(errMsg)) {
|
||||
captchaId.value = '';
|
||||
captchaAnswer.value = null;
|
||||
toast.add({ severity: 'warn', summary: 'Verificação', detail: 'Resposta incorreta. Tente novamente.', life: 3500 });
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (/rate-limited/i.test(errMsg)) {
|
||||
const retryAfter = errBody?.retry_after_seconds || data?.retry_after_seconds;
|
||||
const after = retryAfter ? Math.ceil(retryAfter / 60) : null;
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Muitas tentativas',
|
||||
detail: after ? `Tente novamente em ${after} min.` : 'Tente novamente mais tarde.',
|
||||
life: 6000
|
||||
});
|
||||
salvando.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) throw new Error(data?.message || error.message || 'Falha ao enviar');
|
||||
if (data?.error) throw new Error(data?.message || data.error);
|
||||
|
||||
sucesso.value = true;
|
||||
toast.add({ severity: 'success', summary: 'Enviado', detail: 'Cadastro enviado com sucesso.', life: 2500 });
|
||||
@@ -620,7 +618,8 @@ watch(
|
||||
|
||||
<!-- actions -->
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button label="Preencher exemplo" icon="pi pi-bolt" severity="secondary" outlined class="!border-white/20 !text-slate-100" :disabled="salvando || sucesso || !tokenOk" @click="preencherMock" />
|
||||
<!-- A#26: Botão de preencher mock apenas em DEV -->
|
||||
<Button v-if="isDev" label="Preencher exemplo" icon="pi pi-bolt" severity="secondary" outlined class="!border-white/20 !text-slate-100" :disabled="salvando || sucesso || !tokenOk" @click="preencherMock" />
|
||||
<Button label="Enviar" icon="pi pi-send" class="!bg-emerald-400/90 !border-emerald-400/50 !text-slate-950" :loading="salvando" :disabled="salvando || sucesso || !tokenOk" @click="enviar" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,52 +634,8 @@ watch(
|
||||
<!-- Left panel (inside modal) -->
|
||||
<aside class="col-span-12 md:col-span-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<!-- Photo -->
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="relative">
|
||||
<div class="h-20 w-20 overflow-hidden rounded-2xl border border-white/10 bg-white/5">
|
||||
<img v-if="fotoPreviewUrl || form.foto_url" :src="fotoPreviewUrl || form.foto_url" alt="foto" class="h-full w-full object-cover" />
|
||||
<div v-else class="grid h-full w-full place-items-center text-slate-300">
|
||||
<i class="pi pi-user text-2xl opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute -inset-1 rounded-2xl bg-emerald-400/10 blur-lg" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">Foto (opcional)</div>
|
||||
<div class="mt-1 text-xs text-slate-300">Se preferir, pode deixar sem foto.</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<label class="cursor-pointer rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 hover:bg-white/10">
|
||||
<input type="file" accept="image/*" class="hidden" :disabled="salvando" @change="onFotoPicked" />
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i class="pi pi-upload" />
|
||||
Enviar imagem
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="cursor-pointer rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 hover:bg-white/10">
|
||||
<input type="file" accept="image/*" capture="user" class="hidden" :disabled="salvando" @change="onFotoPicked" />
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i class="pi pi-camera" />
|
||||
Usar câmera (celular)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="fotoFile" class="flex gap-2">
|
||||
<Button type="button" icon="pi pi-trash" severity="secondary" outlined size="small" label="Remover" class="!border-white/20 !text-slate-100" :disabled="salvando" @click="limparFoto" />
|
||||
</div>
|
||||
|
||||
<div v-if="fotoErro" class="rounded-xl border border-rose-400/30 bg-rose-400/10 px-3 py-2 text-xs text-rose-100">
|
||||
{{ fotoErro }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section chips -->
|
||||
<div class="mt-5">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate-200">Seções</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -1124,19 +1079,6 @@ watch(
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- 4 -->
|
||||
<AccordionPanel value="4">
|
||||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 4)"> 5. Anotações </AccordionHeader>
|
||||
<AccordionContent>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-file-edit" />
|
||||
<Textarea id="f_notas_internas" v-model="form.notas_internas" class="w-full" autoResize rows="6" variant="filled" :disabled="salvando" />
|
||||
</IconField>
|
||||
<label for="f_notas_internas">Notas</label>
|
||||
</FloatLabel>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
|
||||
<!-- Footer / Consent + CTA -->
|
||||
@@ -1146,7 +1088,12 @@ watch(
|
||||
<Checkbox v-model="consent" :binary="true" :disabled="salvando" inputId="ext_consent" />
|
||||
<div class="min-w-0">
|
||||
<label for="ext_consent" class="block text-sm font-semibold text-slate-100"> Concordo em enviar meus dados para o terapeuta. </label>
|
||||
<div class="mt-1 text-xs text-slate-300">Seus dados serão usados apenas para contato e organização do atendimento.</div>
|
||||
<div class="mt-1 text-xs text-slate-300">
|
||||
Seus dados serão usados apenas para contato e organização do atendimento.
|
||||
<button type="button" class="ml-1 underline text-emerald-300 hover:text-emerald-200 focus:outline-none" @click="policyOpen = true">
|
||||
Ver política de privacidade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errors.consentimento" class="mt-2 rounded-xl border border-rose-400/30 bg-rose-400/10 px-3 py-2 text-xs text-rose-100">
|
||||
{{ errors.consentimento }}
|
||||
@@ -1155,6 +1102,25 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A#20 rev2: HONEYPOT invisível — bot preenche tudo, humano nunca vê -->
|
||||
<div class="absolute -left-[9999px]" aria-hidden="true">
|
||||
<label for="ext_website">Não preencha este campo</label>
|
||||
<input
|
||||
id="ext_website"
|
||||
v-model="honeypot"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- A#20 rev2: math captcha CONDICIONAL — só aparece quando edge pedir -->
|
||||
<MathCaptchaChallenge
|
||||
v-if="captchaRequired"
|
||||
v-model:id="captchaId"
|
||||
v-model:answer="captchaAnswer"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs text-slate-300">Se algo falhar, peça um novo link ao terapeuta.</div>
|
||||
|
||||
@@ -1163,7 +1129,7 @@ watch(
|
||||
icon="pi pi-send"
|
||||
class="!bg-emerald-400/90 !border-emerald-400/50 !text-slate-950 md:!min-w-[240px]"
|
||||
:loading="salvando"
|
||||
:disabled="salvando || sucesso || !tokenOk"
|
||||
:disabled="salvando || sucesso || !tokenOk || (captchaRequired && (!captchaId || captchaAnswer == null))"
|
||||
@click="enviar"
|
||||
/>
|
||||
</div>
|
||||
@@ -1178,5 +1144,70 @@ watch(
|
||||
<div class="mt-4 text-center text-xs text-slate-400">Ao enviar, você confirma que está fornecendo informações verdadeiras e que autoriza o contato.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A#25: Política de privacidade (dialog inline) -->
|
||||
<Dialog
|
||||
v-model:visible="policyOpen"
|
||||
modal
|
||||
header="Política de privacidade"
|
||||
:style="{ width: '92vw', maxWidth: '720px' }"
|
||||
:breakpoints="{ '640px': '96vw' }"
|
||||
:draggable="false"
|
||||
:dismissableMask="true"
|
||||
>
|
||||
<div class="text-sm leading-relaxed text-[var(--text-color)] space-y-4">
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Finalidade do tratamento</h3>
|
||||
<p>Seus dados pessoais são coletados exclusivamente para viabilizar o contato do terapeuta com você e organizar o atendimento clínico. Não são usados para marketing, compartilhamento com terceiros ou perfilamento.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Dados coletados</h3>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Identificação: nome completo, CPF, RG (opcional), data de nascimento, gênero, estado civil, nacionalidade, naturalidade.</li>
|
||||
<li>Contato: telefone(s), e-mail(s), endereço.</li>
|
||||
<li>Contexto: profissão, escolaridade, parente/responsável, origem do contato, observações que você optar por fornecer.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Base legal (LGPD)</h3>
|
||||
<p>Tratamos seus dados com base no <b>consentimento</b> que você fornece ao marcar a caixa correspondente (Art. 7º, I da Lei 13.709/2018), e quando aplicável, também com base na execução de contrato de prestação de serviço terapêutico (Art. 7º, V).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Retenção</h3>
|
||||
<p>Dados de pré-cadastro são mantidos pelo tempo necessário para a conversão em paciente (ou descarte, se não houver andamento). Após início do vínculo terapêutico, seguem as regras aplicáveis ao prontuário clínico (Resolução CFP 001/2009: no mínimo 5 anos após último atendimento).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Seus direitos</h3>
|
||||
<p>A qualquer momento você pode solicitar, sem custo:</p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Confirmação da existência de tratamento;</li>
|
||||
<li>Acesso aos seus dados;</li>
|
||||
<li>Correção de dados incompletos, inexatos ou desatualizados;</li>
|
||||
<li>Anonimização, bloqueio ou eliminação de dados desnecessários;</li>
|
||||
<li>Portabilidade dos dados;</li>
|
||||
<li>Eliminação dos dados pessoais tratados com base no consentimento;</li>
|
||||
<li>Revogação do consentimento.</li>
|
||||
</ul>
|
||||
<p class="mt-2">Para exercer qualquer desses direitos, entre em contato diretamente com o profissional que enviou este link.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-base font-semibold">Segurança</h3>
|
||||
<p>Adotamos medidas técnicas (criptografia em trânsito, controle de acesso, registros de auditoria) e administrativas para proteger seus dados. Ainda assim, nenhum sistema é 100% seguro — comprometa apenas o estritamente necessário.</p>
|
||||
</section>
|
||||
|
||||
<section class="text-xs text-[var(--text-color-secondary)]">
|
||||
<p>Esta política pode ser atualizada. Data da última revisão: 18/04/2026.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Entendi" icon="pi pi-check" @click="policyOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -1,142 +0,0 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/views/pages/public/PatientsExternalLinkPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Message from 'primevue/message';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'; // ajuste se seu caminho for diferente
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const inviteToken = ref('');
|
||||
const rotating = ref(false);
|
||||
|
||||
const origin = computed(() => window.location.origin);
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return '';
|
||||
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`;
|
||||
});
|
||||
|
||||
function newToken() {
|
||||
// browsers modernos
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
// fallback simples
|
||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
async function requireUserId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado');
|
||||
return uid;
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite() {
|
||||
const uid = await requireUserId();
|
||||
|
||||
const { data, error } = await supabase.from('patient_invites').select('token, active').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const token = data?.[0]?.token;
|
||||
if (token) {
|
||||
inviteToken.value = token;
|
||||
return;
|
||||
}
|
||||
|
||||
const t = newToken();
|
||||
const { error: insErr } = await supabase.from('patient_invites').insert({ owner_id: uid, token: t, active: true });
|
||||
|
||||
if (insErr) throw insErr;
|
||||
inviteToken.value = t;
|
||||
}
|
||||
|
||||
async function rotateLink() {
|
||||
rotating.value = true;
|
||||
try {
|
||||
const t = newToken();
|
||||
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t });
|
||||
if (error) throw error;
|
||||
|
||||
inviteToken.value = t;
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 });
|
||||
} finally {
|
||||
rotating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
if (!publicUrl.value) return;
|
||||
await navigator.clipboard.writeText(publicUrl.value);
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 });
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 });
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
if (!publicUrl.value) return;
|
||||
window.open(publicUrl.value, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadOrCreateInvite();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER -->
|
||||
<div class="flex flex-column md:flex-row md:align-items-center md:justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="text-900 text-2xl font-semibold">Cadastro Externo</div>
|
||||
<div class="text-600 mt-1">Gere um link para o paciente preencher o pré-cadastro.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined :loading="rotating" @click="rotateLink" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CARD -->
|
||||
<Card class="mt-4">
|
||||
<template #title>Seu link</template>
|
||||
<template #subtitle>Envie este link ao paciente.</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div class="p-inputgroup">
|
||||
<InputText readonly :value="publicUrl" placeholder="Gerando seu link…" />
|
||||
<Button icon="pi pi-copy" severity="secondary" outlined :disabled="!publicUrl" @click="copyLink" v-tooltip.bottom="'Copiar'" />
|
||||
<Button icon="pi pi-external-link" severity="secondary" outlined :disabled="!publicUrl" @click="openLink" v-tooltip.bottom="'Abrir'" />
|
||||
</div>
|
||||
|
||||
<Message v-if="!inviteToken" severity="info" :closable="false"> Gerando seu link... </Message>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user