From 52c34cf63accb95a75da86e8c42d94a286cf5c4c Mon Sep 17 00:00:00 2001 From: Leonardo Date: Sat, 13 Jun 2026 20:36:33 -0300 Subject: [PATCH] freemium F2 polish: welcome email + plano gratuito na vitrine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ...0260613000007_freemium_f2_vitrine_free.sql | 66 +++++++++ src/views/pages/auth/OnboardingPage.vue | 5 + src/views/pages/public/Landingpage-v1.vue | 19 ++- .../functions/send-welcome-email/index.ts | 125 ++++++++++++++++++ 4 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 database-novo/migrations/20260613000007_freemium_f2_vitrine_free.sql create mode 100644 supabase/functions/send-welcome-email/index.ts diff --git a/database-novo/migrations/20260613000007_freemium_f2_vitrine_free.sql b/database-novo/migrations/20260613000007_freemium_f2_vitrine_free.sql new file mode 100644 index 0000000..2581b09 --- /dev/null +++ b/database-novo/migrations/20260613000007_freemium_f2_vitrine_free.sql @@ -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; diff --git a/src/views/pages/auth/OnboardingPage.vue b/src/views/pages/auth/OnboardingPage.vue index c2ddc08..76bfa60 100644 --- a/src/views/pages/auth/OnboardingPage.vue +++ b/src/views/pages/auth/OnboardingPage.vue @@ -92,6 +92,11 @@ async function provision(slugOverride = null) { // 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); } + // 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'); } catch (err) { const msg = String(err?.message || ''); diff --git a/src/views/pages/public/Landingpage-v1.vue b/src/views/pages/public/Landingpage-v1.vue index 2436ceb..5ceb8b0 100644 --- a/src/views/pages/public/Landingpage-v1.vue +++ b/src/views/pages/public/Landingpage-v1.vue @@ -80,6 +80,14 @@ function priceFor(p) { 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() { loadingPricing.value = true; try { @@ -481,11 +489,16 @@ onMounted(fetchPricing);
- {{ formatBRLFromCents(priceFor(p)) }} - /{{ billingInterval === 'month' ? 'mês' : 'ano' }} + +
-
Melhor custo-benefício
+
Melhor custo-benefício
{{ p.public_description || '—' }} diff --git a/supabase/functions/send-welcome-email/index.ts b/supabase/functions/send-welcome-email/index.ts new file mode 100644 index 0000000..8d3a42f --- /dev/null +++ b/supabase/functions/send-welcome-email/index.ts @@ -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á, ${name}!` : 'Olá!' + return ` +
+

${ola}

+

Seu ambiente ${tenantName} está pronto. Sua conta gratuita já foi ativada — é só entrar e começar.

+

+ Acessar meu ambiente → +

+

No plano gratuito você já tem o essencial. Quando precisar de mais, é só clicar em Upgrade PRO dentro do sistema.

+

Agência PSI — gestão clínica sem ruído.

+
` +} + +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 + + // 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) + } +})