387 lines
21 KiB
Vue
387 lines
21 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/features/patients/cadastro/PatientsExternalLinkPage.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
|
import Message from 'primevue/message';
|
|
import Menu from 'primevue/menu';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
|
|
const toast = useToast();
|
|
|
|
const inviteToken = ref('');
|
|
const rotating = ref(false);
|
|
|
|
// ── Hero sticky ───────────────────────────────────────────
|
|
const headerEl = ref(null);
|
|
const headerSentinelRef = ref(null);
|
|
const headerStuck = ref(false);
|
|
let _observer = null;
|
|
|
|
// ── Mobile menu ───────────────────────────────────────────
|
|
const mobileMenuRef = ref(null);
|
|
|
|
const mobileMenuItems = computed(() => [
|
|
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
|
|
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
|
|
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
|
|
{ separator: true },
|
|
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
|
|
]);
|
|
|
|
// ── Conteúdo estático ─────────────────────────────────────
|
|
const howItWorks = [
|
|
{ n: 1, title: 'Você envia o link', desc: 'Por WhatsApp, e-mail ou mensagem direta.' },
|
|
{ n: 2, title: 'O paciente preenche', desc: 'Campos opcionais podem ficar em branco. Menos fricção, mais adesão.' },
|
|
{ n: 3, title: 'Você recebe e converte', desc: 'O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.' }
|
|
];
|
|
|
|
const goodPractices = ['Gere um novo link se suspeitar que ele foi repassado indevidamente.', 'Informe o paciente que campos opcionais podem ficar em branco.', 'Evite divulgar em público; é um link para compartilhamento individual.'];
|
|
|
|
// ── URL base ──────────────────────────────────────────────
|
|
const PUBLIC_BASE_URL = '';
|
|
|
|
const origin = computed(() => {
|
|
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL;
|
|
return typeof window !== 'undefined' ? window.location.origin : '';
|
|
});
|
|
|
|
const publicUrl = computed(() => {
|
|
if (!inviteToken.value) return '';
|
|
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`;
|
|
});
|
|
|
|
// ── Token helpers ─────────────────────────────────────────
|
|
function newToken() {
|
|
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
|
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 uid = await requireUserId();
|
|
const t = newToken();
|
|
|
|
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t });
|
|
if (rpc.error) {
|
|
const { error: e1 } = await supabase.from('patient_invites').update({ active: false, updated_at: new Date().toISOString() }).eq('owner_id', uid).eq('active', true);
|
|
if (e1) throw e1;
|
|
|
|
const { error: e2 } = await supabase.from('patient_invites').insert({ owner_id: uid, token: t, active: true });
|
|
if (e2) throw e2;
|
|
}
|
|
|
|
inviteToken.value = t;
|
|
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado. O anterior foi revogado.', life: 2500 });
|
|
} 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 para a área de transferência.', life: 1500 });
|
|
} catch {
|
|
window.prompt('Copie o link:', publicUrl.value);
|
|
}
|
|
}
|
|
|
|
function openLink() {
|
|
if (!publicUrl.value) return;
|
|
window.open(publicUrl.value, '_blank', 'noopener');
|
|
}
|
|
|
|
async function copyInviteMessage() {
|
|
try {
|
|
if (!publicUrl.value) return;
|
|
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`;
|
|
await navigator.clipboard.writeText(msg);
|
|
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada para a área de transferência.', life: 1500 });
|
|
} catch {
|
|
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 });
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
|
|
_observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
headerStuck.value = !entry.isIntersecting;
|
|
},
|
|
{ threshold: 0, rootMargin }
|
|
);
|
|
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
|
|
|
|
try {
|
|
await loadOrCreateInvite();
|
|
} catch (err) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 });
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
_observer?.disconnect();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Sentinel -->
|
|
<div ref="headerSentinelRef" class="h-px" />
|
|
|
|
<!-- ══════════════════════════════════════════════════════
|
|
HERO sticky
|
|
═══════════════════════════════════════════════════════ -->
|
|
<section
|
|
ref="headerEl"
|
|
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
|
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
|
>
|
|
<!-- Blobs -->
|
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
|
|
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
|
</div>
|
|
|
|
<div class="relative z-[1] flex items-center gap-3">
|
|
<!-- Brand -->
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
|
<i class="pi pi-link text-base" />
|
|
</div>
|
|
<div class="min-w-0 hidden lg:block">
|
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Link de Cadastro</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">Compartilhe com o paciente para preencher o pré-cadastro</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status + link rápido — desktop -->
|
|
<div class="hidden xl:flex flex-1 min-w-0 mx-2 items-center gap-3">
|
|
<!-- Badge de status -->
|
|
<span
|
|
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border flex-shrink-0 transition-colors"
|
|
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
|
|
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
|
|
</span>
|
|
|
|
<!-- Link inline -->
|
|
<div v-if="!inviteToken" class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]"><i class="pi pi-spin pi-spinner" /> Gerando link…</div>
|
|
<InputGroup v-else class="max-w-xl">
|
|
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
|
<InputText readonly :value="publicUrl" class="font-mono text-[0.75rem]" />
|
|
<Button icon="pi pi-copy" severity="secondary" title="Copiar link" @click="copyLink" />
|
|
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="openLink" />
|
|
</InputGroup>
|
|
</div>
|
|
|
|
<!-- Ações desktop -->
|
|
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
|
|
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined class="rounded-full" :loading="rotating" @click="rotateLink" />
|
|
</div>
|
|
|
|
<!-- Mobile -->
|
|
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
|
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
|
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ══════════════════════════════════════════════════════
|
|
CONTEÚDO
|
|
═══════════════════════════════════════════════════════ -->
|
|
<div class="flex flex-col lg:flex-row gap-3 px-3 md:px-4 pb-5">
|
|
<!-- ── ESQUERDA: link + mensagem ──────────────────── -->
|
|
<div class="flex-1 min-w-0 flex flex-col gap-3">
|
|
<!-- Card principal: link -->
|
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
|
<!-- Header da seção -->
|
|
<div class="flex items-center justify-between gap-3 flex-wrap px-4 pt-3.5 pb-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
|
<div>
|
|
<div class="font-semibold text-[var(--text-color)]">Seu link público</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
|
|
</div>
|
|
<span
|
|
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border flex-shrink-0"
|
|
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
|
|
{{ inviteToken ? 'Ativo' : 'Gerando…' }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="p-4 flex flex-col gap-4">
|
|
<!-- Skeleton -->
|
|
<div v-if="!inviteToken" class="flex flex-col gap-3">
|
|
<div class="h-10 rounded-md bg-[var(--surface-ground,#f8fafc)] animate-pulse" />
|
|
<div class="h-10 rounded-md bg-[var(--surface-ground,#f8fafc)] animate-pulse" />
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col gap-4">
|
|
<!-- InputGroup do link -->
|
|
<InputGroup>
|
|
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
|
<InputText readonly :value="publicUrl" class="font-mono text-[0.75rem]" />
|
|
<Button icon="pi pi-copy" severity="secondary" title="Copiar" @click="copyLink" />
|
|
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
|
|
</InputGroup>
|
|
|
|
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">
|
|
Token: <span class="font-mono select-all opacity-60">{{ inviteToken }}</span>
|
|
</div>
|
|
|
|
<!-- CTAs rápidas -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
|
<!-- Copiar link -->
|
|
<button
|
|
class="flex items-center gap-3 px-3.5 py-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] cursor-pointer text-left transition-[background,box-shadow,transform] duration-150 hover:bg-[var(--surface-hover,#f1f5f9)] hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)] hover:-translate-y-px active:translate-y-0"
|
|
@click="copyLink"
|
|
>
|
|
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
|
<i class="pi pi-copy" />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-semibold text-[1rem] text-[var(--text-color)]">Copiar link</div>
|
|
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Copiar mensagem pronta -->
|
|
<button
|
|
class="flex items-center gap-3 px-3.5 py-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] cursor-pointer text-left transition-[background,box-shadow,transform] duration-150 hover:bg-[var(--surface-hover,#f1f5f9)] hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)] hover:-translate-y-px active:translate-y-0"
|
|
@click="copyInviteMessage"
|
|
>
|
|
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
|
<i class="pi pi-comment" />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-semibold text-[1rem] text-[var(--text-color)]">Copiar mensagem pronta</div>
|
|
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Aviso -->
|
|
<Message severity="warn" :closable="false"> <b>Dica:</b> ao gerar um novo link, o anterior é revogado. Use isso quando quiser invalidar um link já compartilhado. </Message>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mensagem pronta -->
|
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4">
|
|
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-1">
|
|
<i class="pi pi-comment text-[1rem] text-[var(--text-color-secondary)]" />
|
|
Mensagem pronta para envio
|
|
</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
|
|
<div class="rounded-md bg-[var(--surface-ground,#f8fafc)] border border-[var(--surface-border,#e2e8f0)] p-4 text-[1rem] text-[var(--text-color)] leading-relaxed">
|
|
Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
|
<span class="block mt-2 font-mono text-[0.72rem] break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
|
|
</div>
|
|
<div class="mt-3">
|
|
<Button icon="pi pi-copy" label="Copiar mensagem" severity="secondary" outlined class="rounded-full" :disabled="!publicUrl" @click="copyInviteMessage" />
|
|
</div>
|
|
</div>
|
|
|
|
<LoadedPhraseBlock v-if="inviteToken" />
|
|
</div>
|
|
|
|
<!-- ── DIREITA: instruções ────────────────────────── -->
|
|
<div class="w-full lg:w-[272px] lg:flex-shrink-0 flex flex-col gap-3">
|
|
<!-- Como funciona -->
|
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
|
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
|
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
|
<i class="pi pi-list-check text-[0.9rem]" />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Como funciona</span>
|
|
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Simples e sem fricção para o paciente</span>
|
|
</div>
|
|
</div>
|
|
|
|
<ol class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
|
|
<li v-for="step in howItWorks" :key="step.n" class="flex items-start gap-3 px-3.5 py-3">
|
|
<div class="grid place-items-center w-7 h-7 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500 text-[0.75rem] font-bold mt-px">
|
|
{{ step.n }}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-semibold text-[1rem] text-[var(--text-color)]">{{ step.title }}</div>
|
|
<div class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 leading-relaxed">{{ step.desc }}</div>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<!-- Boas práticas -->
|
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
|
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
|
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
|
<i class="pi pi-shield text-[0.9rem]" />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Boas práticas</span>
|
|
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Segurança e privacidade</span>
|
|
</div>
|
|
</div>
|
|
|
|
<ul class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
|
|
<li v-for="tip in goodPractices" :key="tip" class="flex items-start gap-2.5 px-3.5 py-2.5">
|
|
<i class="pi pi-check text-emerald-500 mt-0.5 flex-shrink-0 text-[1rem]" />
|
|
<span class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">{{ tip }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|