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' }}
+
+ Grátis para sempre
+
+
+ {{ 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)
+ }
+})