freemium F2 polish: welcome email + plano gratuito na vitrine
- edge function send-welcome-email: e-mail de boas-vindas ao DONO do tenant recem-provisionado (destinatario do JWT, SMTP global/sistema, defaults Mailpit). Best-effort, disparada fire-and-forget no OnboardingPage so no provisionamento novo. - vitrine: seed plan_public + bullets dos planos free (cartao "Gratis"); Landingpage passa a mostrar "Gratis para sempre" (isFreePlan) em vez de "—". - build OK Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Freemium F2 (polish) — apresentação do plano gratuito na vitrine pública
|
||||||
|
--
|
||||||
|
-- Os planos free já eram is_visible em v_public_pricing, mas sem plan_public
|
||||||
|
-- (nome/descrição/bullets) e sem preço — renderizavam sem nome/valor. Este seed
|
||||||
|
-- dá um cartão "Grátis" decente. Referência por KEY (subquery), idempotente.
|
||||||
|
-- O preço "Grátis" é tratado no front (Landingpage isFreePlan).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||||
|
SELECT id, 'Grátis',
|
||||||
|
'Comece sem custo: o essencial pra organizar sua agenda, pacientes e prontuário.',
|
||||||
|
'Grátis', false, true, 0
|
||||||
|
FROM public.plans WHERE key = 'clinic_free'
|
||||||
|
ON CONFLICT (plan_id) DO UPDATE
|
||||||
|
SET public_name = EXCLUDED.public_name,
|
||||||
|
public_description = EXCLUDED.public_description,
|
||||||
|
badge = EXCLUDED.badge,
|
||||||
|
is_visible = true,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||||
|
SELECT id, 'Grátis',
|
||||||
|
'Pra terapeutas individuais: agenda, pacientes e prontuário sem custo.',
|
||||||
|
'Grátis', false, true, 0
|
||||||
|
FROM public.plans WHERE key = 'therapist_free'
|
||||||
|
ON CONFLICT (plan_id) DO UPDATE
|
||||||
|
SET public_name = EXCLUDED.public_name,
|
||||||
|
public_description = EXCLUDED.public_description,
|
||||||
|
badge = EXCLUDED.badge,
|
||||||
|
is_visible = true,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- bullets (idempotente: limpa os dos free e re-insere)
|
||||||
|
DELETE FROM public.plan_public_bullets
|
||||||
|
WHERE plan_id IN (SELECT id FROM public.plans WHERE key IN ('clinic_free','therapist_free'));
|
||||||
|
|
||||||
|
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||||
|
SELECT p.id, b.text, b.highlight, b.sort_order
|
||||||
|
FROM public.plans p
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
VALUES
|
||||||
|
('Agenda completa e prontuário', true, 1),
|
||||||
|
('Até 30 pacientes ativos', false, 2),
|
||||||
|
('Documentos e lembretes básicos', false, 3),
|
||||||
|
('Agendamento online', false, 4)
|
||||||
|
) AS b(text, highlight, sort_order)
|
||||||
|
WHERE p.key = 'clinic_free';
|
||||||
|
|
||||||
|
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||||
|
SELECT p.id, b.text, b.highlight, b.sort_order
|
||||||
|
FROM public.plans p
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
VALUES
|
||||||
|
('Agenda completa e prontuário', true, 1),
|
||||||
|
('Até 20 pacientes ativos', false, 2),
|
||||||
|
('Documentos e lembretes básicos', false, 3),
|
||||||
|
('Agendamento online', false, 4)
|
||||||
|
) AS b(text, highlight, sort_order)
|
||||||
|
WHERE p.key = 'therapist_free';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -92,6 +92,11 @@ async function provision(slugOverride = null) {
|
|||||||
// caminho pago (intent) — best-effort, não bloqueia
|
// caminho pago (intent) — best-effort, não bloqueia
|
||||||
try { await supabase.rpc('processar_pos_signup'); } catch (e) { console.warn('[onboarding] processar_pos_signup:', e?.message || e); }
|
try { await supabase.rpc('processar_pos_signup'); } catch (e) { console.warn('[onboarding] processar_pos_signup:', e?.message || e); }
|
||||||
|
|
||||||
|
// welcome email — só no provisionamento NOVO, fire-and-forget (não bloqueia)
|
||||||
|
if (data?.status === 'provisioned') {
|
||||||
|
supabase.functions.invoke('send-welcome-email').catch(() => { /* best-effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
await finishAndRedirect(data?.kind || 'therapist');
|
await finishAndRedirect(data?.kind || 'therapist');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = String(err?.message || '');
|
const msg = String(err?.message || '');
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ function priceFor(p) {
|
|||||||
return cents;
|
return cents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// plano gratuito: por chave (_free) ou preço zero/ausente
|
||||||
|
function isFreePlan(p) {
|
||||||
|
const k = String(p?.plan_key || '').toLowerCase();
|
||||||
|
if (k.endsWith('_free') || k === 'free') return true;
|
||||||
|
const cents = priceFor(p);
|
||||||
|
return cents == null || Number(cents) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPricing() {
|
async function fetchPricing() {
|
||||||
loadingPricing.value = true;
|
loadingPricing.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -481,11 +489,16 @@ onMounted(fetchPricing);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-3xl font-semibold leading-none">
|
<div class="mt-4 text-3xl font-semibold leading-none">
|
||||||
|
<template v-if="isFreePlan(p)">
|
||||||
|
Grátis<span class="text-sm font-normal text-[var(--text-color-secondary)]"> para sempre </span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
{{ formatBRLFromCents(priceFor(p)) }}
|
{{ formatBRLFromCents(priceFor(p)) }}
|
||||||
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ billingInterval === 'month' ? 'mês' : 'ano' }} </span>
|
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ billingInterval === 'month' ? 'mês' : 'ano' }} </span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
|
<div v-if="!isFreePlan(p) && billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
|
||||||
|
|
||||||
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px] leading-relaxed">
|
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px] leading-relaxed">
|
||||||
{{ p.public_description || '—' }}
|
{{ p.public_description || '—' }}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — Edge Function: send-welcome-email (Freemium F2)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| E-mail de boas-vindas ao DONO de um tenant recém-provisionado. Best-effort:
|
||||||
|
| nunca quebra o login/onboarding — se o SMTP falhar, só loga.
|
||||||
|
|
|
||||||
|
| • Destinatário derivado do JWT (não do body) — segurança.
|
||||||
|
| • Usa um SMTP GLOBAL/de sistema (env), não o canal do tenant (um tenant
|
||||||
|
| novo ainda não configurou notification_channels). Defaults = Mailpit local.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
|
import { SmtpClient } from 'https://deno.land/x/smtp@v0.7.0/mod.ts'
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function welcomeHtml(name: string, tenantName: string, loginUrl: string): string {
|
||||||
|
const ola = name ? `Olá, <strong>${name}</strong>!` : 'Olá!'
|
||||||
|
return `
|
||||||
|
<div style="font-family: system-ui, sans-serif; max-width: 520px; margin: 0 auto; color: #1f2937;">
|
||||||
|
<p>${ola}</p>
|
||||||
|
<p>Seu ambiente <strong>${tenantName}</strong> está pronto. Sua conta gratuita já foi ativada — é só entrar e começar.</p>
|
||||||
|
<p style="margin: 24px 0;">
|
||||||
|
<a href="${loginUrl}" style="background:#10b981;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600;">Acessar meu ambiente →</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#6b7280;font-size:13px;">No plano gratuito você já tem o essencial. Quando precisar de mais, é só clicar em <strong>Upgrade PRO</strong> dentro do sistema.</p>
|
||||||
|
<p style="color:#9ca3af;font-size:12px;margin-top:24px;">Agência PSI — gestão clínica sem ruído.</p>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||||
|
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('Authorization') || ''
|
||||||
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||||
|
|
||||||
|
// cliente no contexto do usuário (resolve o JWT → destinatário)
|
||||||
|
const userClient = createClient(supabaseUrl, Deno.env.get('SUPABASE_ANON_KEY')!, {
|
||||||
|
global: { headers: { Authorization: authHeader } },
|
||||||
|
})
|
||||||
|
const { data: userData } = await userClient.auth.getUser()
|
||||||
|
const user = userData?.user
|
||||||
|
if (!user?.id || !user?.email) {
|
||||||
|
return json({ ok: false, error: 'no_session' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = (user.user_metadata || {}) as Record<string, string>
|
||||||
|
|
||||||
|
// nome do tenant: metadata OU consulta via admin
|
||||||
|
const admin = createClient(supabaseUrl, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
||||||
|
let tenantName = String(meta.tenant_name || '').trim()
|
||||||
|
let ownerName = String(meta.display_name || '').trim()
|
||||||
|
if (!tenantName || !ownerName) {
|
||||||
|
const { data: member } = await admin
|
||||||
|
.from('tenant_members')
|
||||||
|
.select('tenant_id')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('status', 'active')
|
||||||
|
.order('created_at', { ascending: true })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
if (member?.tenant_id) {
|
||||||
|
const { data: t } = await admin.from('tenants').select('name').eq('id', member.tenant_id).maybeSingle()
|
||||||
|
if (!tenantName) tenantName = t?.name || 'seu ambiente'
|
||||||
|
}
|
||||||
|
if (!ownerName) {
|
||||||
|
const { data: pr } = await admin.from('profiles').select('full_name').eq('id', user.id).maybeSingle()
|
||||||
|
ownerName = pr?.full_name || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tenantName = tenantName || 'seu ambiente'
|
||||||
|
|
||||||
|
// SMTP global de sistema (env) — defaults = Mailpit local
|
||||||
|
const host = Deno.env.get('SMTP_HOST') || 'mailpit'
|
||||||
|
const port = parseInt(Deno.env.get('SMTP_PORT') || '1025', 10)
|
||||||
|
const username = Deno.env.get('SMTP_USER') || 'test'
|
||||||
|
const password = Deno.env.get('SMTP_PASS') || 'test'
|
||||||
|
const fromEmail = Deno.env.get('SMTP_FROM') || 'no-reply@agenciapsi.local'
|
||||||
|
const fromName = Deno.env.get('SMTP_FROM_NAME') || 'Agência PSI'
|
||||||
|
const appUrl = (Deno.env.get('APP_URL') || 'http://localhost:5173').replace(/\/+$/, '')
|
||||||
|
const loginUrl = `${appUrl}/auth/login`
|
||||||
|
|
||||||
|
const subject = `Bem-vindo(a) — ${tenantName} está pronto`
|
||||||
|
const html = welcomeHtml(ownerName, tenantName, loginUrl)
|
||||||
|
const text = `${ownerName ? 'Olá, ' + ownerName + '!' : 'Olá!'}\n\nSeu ambiente ${tenantName} está pronto. Acesse: ${loginUrl}\n\nAgência PSI`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new SmtpClient()
|
||||||
|
const connectConfig = { hostname: host, port, username, password }
|
||||||
|
if (port === 465) await client.connectTLS(connectConfig)
|
||||||
|
else await client.connect(connectConfig)
|
||||||
|
await client.send({
|
||||||
|
from: `${fromName} <${fromEmail}>`,
|
||||||
|
to: user.email,
|
||||||
|
subject,
|
||||||
|
content: text,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
await client.close()
|
||||||
|
} catch (smtpErr) {
|
||||||
|
// best-effort: não quebra o onboarding
|
||||||
|
console.error('[send-welcome-email] SMTP falhou (ignorado):', smtpErr)
|
||||||
|
return json({ ok: false, error: 'smtp_failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[send-welcome-email] fatal:', err)
|
||||||
|
return json({ ok: false, error: 'internal' }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user