@fayz-ai/plugin-marketing 0.9.0-next.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/data/mock.d.ts.map +1 -1
  2. package/dist/data/supabase.d.ts +6 -0
  3. package/dist/data/supabase.d.ts.map +1 -1
  4. package/dist/data/tables.d.ts +15 -0
  5. package/dist/data/tables.d.ts.map +1 -1
  6. package/dist/data/types.d.ts +43 -2
  7. package/dist/data/types.d.ts.map +1 -1
  8. package/dist/index.d.ts +8 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +782 -104
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
  13. package/dist/locales/en.d.ts.map +1 -1
  14. package/dist/locales/pt-BR.d.ts.map +1 -1
  15. package/dist/migrations/index.d.ts +1 -0
  16. package/dist/migrations/index.d.ts.map +1 -1
  17. package/dist/registries.d.ts +3 -1
  18. package/dist/registries.d.ts.map +1 -1
  19. package/dist/store.d.ts +9 -2
  20. package/dist/store.d.ts.map +1 -1
  21. package/dist/types.d.ts +41 -5
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/views/LandingPageComposer.d.ts +8 -0
  24. package/dist/views/LandingPageComposer.d.ts.map +1 -0
  25. package/dist/views/LandingPagesView.d.ts.map +1 -1
  26. package/package.json +7 -7
  27. package/src/data/mock.ts +83 -8
  28. package/src/data/supabase.ts +215 -3
  29. package/src/data/tables.ts +17 -0
  30. package/src/data/types.ts +41 -1
  31. package/src/index.ts +12 -2
  32. package/src/integrations/ranklayer/connectorDef.tsx +4 -0
  33. package/src/locales/en.ts +24 -1
  34. package/src/locales/pt-BR.ts +24 -1
  35. package/src/migrations/008_landing_pages.sql +112 -0
  36. package/src/migrations/index.ts +116 -1
  37. package/src/registries.ts +3 -1
  38. package/src/store.ts +26 -3
  39. package/src/types.ts +44 -5
  40. package/src/views/LandingPageComposer.tsx +201 -0
  41. package/src/views/LandingPagesView.tsx +145 -22
@@ -6,6 +6,21 @@ export const T = {
6
6
  contentPosts: 'plg_marketing_content_posts',
7
7
  channels: 'plg_marketing_channels',
8
8
  campaigns: 'plg_marketing_campaigns',
9
+ landingPages: 'plg_marketing_landing_pages',
10
+ } as const
11
+
12
+ /** The tenant's sites are its fayz projects (@fayz-ai/db 020) — the same rows
13
+ * Settings → Sites manages. Marketing only READS them: a landing page points
14
+ * at one and adds a route. */
15
+ export const SITES_TABLE = 'fayz_projects'
16
+
17
+ /** Where the lead-tag catalog lives. Marketing only READS it (to offer the
18
+ * tags in the landing-page form); plugin-crm owns the table. Overridable via
19
+ * the plugin's `leadTags` option for apps that keep tags somewhere else. */
20
+ export const LEAD_TAGS_SOURCE = {
21
+ table: 'plg_crm_tags',
22
+ valueField: 'slug',
23
+ labelField: 'name',
9
24
  } as const
10
25
 
11
26
  // Read views (bridge views over spine + plugin tables; see 006_analytics_views.sql)
@@ -13,4 +28,6 @@ export const V = {
13
28
  channels: 'v_marketing_channels',
14
29
  campaigns: 'v_marketing_campaigns',
15
30
  attribution: 'v_marketing_attribution',
31
+ landingPages: 'v_marketing_landing_pages',
32
+ leadTagEvents: 'v_marketing_lead_tag_events',
16
33
  } as const
package/src/data/types.ts CHANGED
@@ -2,10 +2,14 @@ import type {
2
2
  Campaign,
3
3
  CampaignStatus,
4
4
  ChannelPerformance,
5
+ DateRangeKey,
5
6
  FunnelStage,
7
+ LandingPage,
6
8
  LandingPagePerf,
9
+ LandingPageStatus,
7
10
  MarketingOverview,
8
11
  MarketingQuery,
12
+ MarketingSite,
9
13
  } from '../types'
10
14
 
11
15
  // ---------------------------------------------------------------------------
@@ -25,6 +29,17 @@ export interface SaveCampaignInput {
25
29
  spend: number
26
30
  }
27
31
 
32
+ export interface SaveLandingPageInput {
33
+ id?: string
34
+ siteId: string
35
+ name: string
36
+ route: string
37
+ leadTags?: string[]
38
+ status?: LandingPageStatus
39
+ prompt?: string
40
+ isActive?: boolean
41
+ }
42
+
28
43
  export interface MarketingDataProvider {
29
44
  overview(query: MarketingQuery): Promise<MarketingOverview>
30
45
  channelPerformance(query: MarketingQuery): Promise<ChannelPerformance[]>
@@ -34,6 +49,12 @@ export interface MarketingDataProvider {
34
49
  deleteCampaign(id: string): Promise<void>
35
50
  funnel(query: MarketingQuery): Promise<FunnelStage[]>
36
51
  landingPages(query: MarketingQuery): Promise<LandingPagePerf[]>
52
+ saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage>
53
+ deleteLandingPage(id: string): Promise<void>
54
+ /** Sites the tenant owns — the "site" half of a landing page. */
55
+ listSites(): Promise<MarketingSite[]>
56
+ /** Lead-tag catalog offered in the landing-page form (read-only). */
57
+ listLeadTags(): Promise<Array<{ slug: string; name: string }>>
37
58
  /** Drop any provider-side read cache (external writes, e.g. agent RPCs). */
38
59
  invalidateCache?(): void
39
60
  }
@@ -52,6 +73,25 @@ export interface AttributionBridge {
52
73
  funnel(query: MarketingQuery): Promise<FunnelStage[]>
53
74
  }
54
75
 
76
+ /**
77
+ * Visit analytics for landing pages. The pages themselves are rows the tenant
78
+ * owns here; the VISITS live wherever the site is deployed, so this reads them
79
+ * back by (app id, route, period) — the contract the hosting platform's
80
+ * analytics endpoint is expected to take. Nothing implements it yet: without a
81
+ * bridge the surface shows the pages with visits pending ("—") rather than
82
+ * fake zeros.
83
+ */
55
84
  export interface SitesPerformanceBridge {
56
- landingPages(query: MarketingQuery): Promise<LandingPagePerf[]>
85
+ visitsByRoute(query: {
86
+ range: DateRangeKey
87
+ pages: Array<{
88
+ id: string
89
+ /** app manifest id of the deploy (fayz_projects.app_id) */
90
+ appId?: string
91
+ /** platform project id (fayz_projects.fayz_project_id) */
92
+ fayzProjectId?: string
93
+ siteUrl?: string
94
+ route: string
95
+ }>
96
+ }): Promise<Array<{ id: string; visits: number }>>
57
97
  }
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import { createMockContentPlannerProvider } from './data/contentMock'
24
24
  import { createSupabaseContentPlannerProvider } from './data/contentSupabase'
25
25
  import { createMarketingStore } from './store'
26
26
  import { createContentPlannerStore } from './views/content/contentStore'
27
- import { MIGRATION_000_PLG_RENAME, MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS, MIGRATION_004_RANKLAYER, MIGRATION_005_ANALYTICS_BASE, MIGRATION_006_ANALYTICS_VIEWS, MIGRATION_007_AGENT_RPCS } from './migrations'
27
+ import { MIGRATION_000_PLG_RENAME, MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS, MIGRATION_004_RANKLAYER, MIGRATION_005_ANALYTICS_BASE, MIGRATION_006_ANALYTICS_VIEWS, MIGRATION_007_AGENT_RPCS, MIGRATION_008_LANDING_PAGES } from './migrations'
28
28
  import { ranklayerConnectorDef } from './integrations/ranklayer/connectorDef'
29
29
  import { DEFAULT_CURRENCY, type MarketingCurrency } from './format'
30
30
  import { MARKETING_PRESETS, type MarketingDomain, type MarketingDomainModules } from './presets'
@@ -64,8 +64,11 @@ export interface MarketingPluginOptions {
64
64
  settingsRegistries?: PluginRegistryDef[]
65
65
  /** Optional DI to read real conversions (mounted later). */
66
66
  attributionBridge?: AttributionBridge
67
- /** Optional DI to read real landing-page performance (mounted later). */
67
+ /** Optional DI to read real landing-page visits (mounted later). */
68
68
  sitesBridge?: SitesPerformanceBridge
69
+ /** Where the lead-tag catalog is read from for the landing-page form.
70
+ * Defaults to plugin-crm's plg_crm_tags (slug/name). */
71
+ leadTags?: { table?: string; valueField?: string; labelField?: string }
69
72
  }
70
73
 
71
74
  const DEFAULT_LABELS: MarketingLabels = {
@@ -106,6 +109,7 @@ function createSafeProvider(
106
109
  conversion: config.conversion,
107
110
  attributionBridge: options?.attributionBridge,
108
111
  sitesBridge: options?.sitesBridge,
112
+ leadTags: options?.leadTags,
109
113
  }),
110
114
  () => createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain }),
111
115
  )
@@ -335,6 +339,12 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
335
339
  sql: MIGRATION_007_AGENT_RPCS,
336
340
  description: 'agent_marketing_create_campaign — guarded, audited server-plane campaign create',
337
341
  },
342
+ {
343
+ id: 'marketing-008-landing-pages',
344
+ version: '1.5.0',
345
+ sql: MIGRATION_008_LANDING_PAGES,
346
+ description: 'Sites + landing pages (site + route + lead tag) with tenant RLS, v_marketing_landing_pages and v_marketing_lead_tag_events',
347
+ },
338
348
  ],
339
349
  // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
340
350
  // (control plane only); the real sync lands via an external PR (RANKLAYER.md).
@@ -72,12 +72,16 @@ function RankLayerExtraPanel() {
72
72
  )
73
73
  }
74
74
 
75
+ // Inlined as a data URI so the mark can never fail to load from an app's domain.
76
+ const RANKLAYER_MARK = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIj48cmVjdCB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHJ4PSI3IiBmaWxsPSIjMUYyNDMxIi8+PHBvbHlnb24gcG9pbnRzPSIxNiw1LjYgMjYuNCwxMS42IDE2LDE3LjYgNS42LDExLjYiIGZpbGw9IiNGRkZGRkYiIHN0cm9rZT0iI0ZGRkZGRiIgc3Ryb2tlLXdpZHRoPSIxLjI1IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PGcgb3BhY2l0eT0iMC41NSI+PHBvbHlnb24gcG9pbnRzPSI1LjYsMTYuNCAxNiwyMi40IDI2LjQsMTYuNCAyNi40LDE4LjIgMTYsMjQuMiA1LjYsMTguMiIgZmlsbD0iI0ZGRkZGRiIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PGcgb3BhY2l0eT0iMC4yOCI+PHBvbHlnb24gcG9pbnRzPSI1LjYsMjEuMiAxNiwyNy4yIDI2LjQsMjEuMiAyNi40LDIzIDE2LDI5IDUuNiwyMyIgZmlsbD0iI0ZGRkZGRiIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PC9zdmc+'
77
+
75
78
  export const ranklayerConnectorDef: ConnectorDefinition = {
76
79
  id: 'ranklayer',
77
80
  hostPluginId: 'marketing',
78
81
  name: 'RankLayer',
79
82
  description: 'Publicação de conteúdo e páginas de SEO por serviço/bairro para ser encontrado no Google e em assistentes de IA.',
80
83
  icon: 'Search',
84
+ logoUrl: RANKLAYER_MARK,
81
85
  authKind: 'api-key',
82
86
  fields: [
83
87
  { key: 'apiKey', label: 'API Key', type: 'password', placeholder: '••••••••' },
package/src/locales/en.ts CHANGED
@@ -8,6 +8,7 @@ export const en: Record<string, string> = {
8
8
  'marketing.metric.cvr': 'CVR',
9
9
  'marketing.metric.totalVisits': 'Total visits',
10
10
  'marketing.metric.pages': 'Pages',
11
+ 'marketing.metric.leads': 'Leads',
11
12
  'marketing.metric.avgConversion': 'Avg. conversion',
12
13
  // columns
13
14
  'marketing.col.campaign': 'Campaign',
@@ -22,6 +23,8 @@ export const en: Record<string, string> = {
22
23
  'marketing.col.conversions': 'Conversions',
23
24
  'marketing.col.type': 'Type',
24
25
  'marketing.col.page': 'Page',
26
+ 'marketing.col.route': 'Route',
27
+ 'marketing.col.leadTag': 'Tag',
25
28
  // overview
26
29
  'marketing.overview.byChannelSuffix': 'by channel',
27
30
  'marketing.overview.recent': 'Recent campaigns',
@@ -53,7 +56,27 @@ export const en: Record<string, string> = {
53
56
  'marketing.funnel.overall': 'Overall conversion rate',
54
57
  'marketing.funnel.ofPrev': 'of prev',
55
58
  // landing pages
56
- 'marketing.landing.subtitle': 'Per-page visits → conversions performance',
59
+ 'marketing.landing.new': 'New page',
60
+ 'marketing.landing.edit': 'Edit page',
61
+ 'marketing.landing.delete': 'Delete page',
62
+ 'marketing.landing.save': 'Save',
63
+ 'marketing.landing.empty': 'No pages yet.',
64
+ 'marketing.landing.site': 'Site',
65
+ 'marketing.landing.sitePlaceholder': 'Pick a site',
66
+ 'marketing.landing.name': 'Name',
67
+ 'marketing.landing.namePlaceholder': 'Hair consult LP',
68
+ 'marketing.landing.route': 'Route',
69
+ 'marketing.landing.create': 'Create page',
70
+ 'marketing.landing.status': 'Does this page exist on the site yet?',
71
+ 'marketing.landing.statusLive': 'Already live',
72
+ 'marketing.landing.statusPlanned': 'Not yet',
73
+ 'marketing.landing.prompt': 'Page prompt',
74
+ 'marketing.landing.promptPlaceholder': 'Describe the page: offer, audience, sections, call to action…',
75
+ 'marketing.landing.buildWithFayz': 'Build page with Fayz',
76
+ 'marketing.landing.leadTag': 'Lead tags',
77
+ 'marketing.landing.leadTagPlaceholder': 'new tag…',
78
+ 'marketing.landing.leadTagHint': 'Leads from this page enter the CRM with these tags.',
79
+ 'marketing.landing.visitsPending': 'Visits come from the platform hosting the site; that source is not connected yet.',
57
80
  // settings
58
81
  'marketing.settings.conversionModel': 'Conversion model',
59
82
  'marketing.settings.conversionModelDesc': "What counts as a conversion in this workspace and where it's attributed from.",
@@ -8,6 +8,7 @@ export const ptBR: Record<string, string> = {
8
8
  'marketing.metric.cvr': 'CVR',
9
9
  'marketing.metric.totalVisits': 'Total de visitas',
10
10
  'marketing.metric.pages': 'Páginas',
11
+ 'marketing.metric.leads': 'Leads',
11
12
  'marketing.metric.avgConversion': 'Conversão média',
12
13
  // columns
13
14
  'marketing.col.campaign': 'Campanha',
@@ -22,6 +23,8 @@ export const ptBR: Record<string, string> = {
22
23
  'marketing.col.conversions': 'Conversões',
23
24
  'marketing.col.type': 'Tipo',
24
25
  'marketing.col.page': 'Página',
26
+ 'marketing.col.route': 'Rota',
27
+ 'marketing.col.leadTag': 'Tag',
25
28
  // overview
26
29
  'marketing.overview.byChannelSuffix': 'por canal',
27
30
  'marketing.overview.recent': 'Campanhas recentes',
@@ -53,7 +56,27 @@ export const ptBR: Record<string, string> = {
53
56
  'marketing.funnel.overall': 'Taxa de conversão geral',
54
57
  'marketing.funnel.ofPrev': 'do anterior',
55
58
  // landing pages
56
- 'marketing.landing.subtitle': 'Desempenho de visitas → conversões por página',
59
+ 'marketing.landing.new': 'Nova página',
60
+ 'marketing.landing.edit': 'Editar página',
61
+ 'marketing.landing.delete': 'Excluir página',
62
+ 'marketing.landing.save': 'Salvar',
63
+ 'marketing.landing.empty': 'Nenhuma página cadastrada ainda.',
64
+ 'marketing.landing.site': 'Site',
65
+ 'marketing.landing.sitePlaceholder': 'Selecione o site',
66
+ 'marketing.landing.name': 'Nome',
67
+ 'marketing.landing.namePlaceholder': 'LP Capilar',
68
+ 'marketing.landing.route': 'Rota',
69
+ 'marketing.landing.create': 'Criar página',
70
+ 'marketing.landing.status': 'Esta página já existe no site?',
71
+ 'marketing.landing.statusLive': 'Já existe',
72
+ 'marketing.landing.statusPlanned': 'Ainda não existe',
73
+ 'marketing.landing.prompt': 'Prompt da página',
74
+ 'marketing.landing.promptPlaceholder': 'Descreva a página: oferta, público, seções, chamada para ação…',
75
+ 'marketing.landing.buildWithFayz': 'Criar página com Fayz',
76
+ 'marketing.landing.leadTag': 'Tags dos leads',
77
+ 'marketing.landing.leadTagPlaceholder': 'nova tag…',
78
+ 'marketing.landing.leadTagHint': 'Os leads desta página entram no CRM com estas tags.',
79
+ 'marketing.landing.visitsPending': 'As visitas vêm da plataforma que hospeda o site; essa origem ainda não está conectada.',
57
80
  // settings
58
81
  'marketing.settings.conversionModel': 'Modelo de conversão',
59
82
  'marketing.settings.conversionModelDesc': 'O que conta como conversão neste workspace e de onde é atribuída.',
@@ -0,0 +1,112 @@
1
+ -- Marketing Plugin: landing pages.
2
+ -- Prefix: plg_marketing_
3
+ -- A landing page IS a site plus a route (espacorenovario.com.br + /lp-capilar).
4
+ -- The SITE is not modelled here: it is the tenant's fayz project
5
+ -- (public.fayz_projects, @fayz-ai/db 020) — the same rows Settings → Sites
6
+ -- manages — so `app_id` / `fayz_project_id` / `preview_url` come from there.
7
+ -- Visit analytics are read from the platform that hosts the deploy (app id +
8
+ -- route + period); that source is not wired yet, so visits render as pending.
9
+ -- `lead_tags` is the cross with the CRM: leads that arrive through the page
10
+ -- carry those tag slugs in people.tags (v_marketing_lead_tag_events counts them).
11
+ -- `status`='planned' + `prompt` is a page that does NOT exist on the site yet:
12
+ -- the brief is written here and the page gets built with Fayz later.
13
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
14
+
15
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_landing_pages (
16
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
17
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
18
+ site_id uuid NOT NULL REFERENCES public.fayz_projects(id) ON DELETE CASCADE,
19
+ name text NOT NULL,
20
+ route text NOT NULL,
21
+ lead_tags text[] NOT NULL DEFAULT '{}',
22
+ status text NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'planned')),
23
+ prompt text,
24
+ is_active boolean NOT NULL DEFAULT true,
25
+ created_at timestamptz NOT NULL DEFAULT now(),
26
+ updated_at timestamptz NOT NULL DEFAULT now()
27
+ );
28
+
29
+ -- Self-healing for pools that ran an earlier cut of this migration (single
30
+ -- lead_tag column, no status/prompt). No-op on a fresh table. The views are
31
+ -- rebuilt at the bottom; dropping them first is what lets the column go.
32
+ DROP VIEW IF EXISTS public.v_marketing_landing_pages;
33
+
34
+ ALTER TABLE public.plg_marketing_landing_pages
35
+ ADD COLUMN IF NOT EXISTS lead_tags text[] NOT NULL DEFAULT '{}',
36
+ ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'live',
37
+ ADD COLUMN IF NOT EXISTS prompt text;
38
+
39
+ DO $$
40
+ BEGIN
41
+ IF EXISTS (
42
+ SELECT 1 FROM information_schema.columns
43
+ WHERE table_schema = 'public' AND table_name = 'plg_marketing_landing_pages' AND column_name = 'lead_tag'
44
+ ) THEN
45
+ UPDATE public.plg_marketing_landing_pages
46
+ SET lead_tags = ARRAY[lead_tag]
47
+ WHERE lead_tag IS NOT NULL AND lead_tags = '{}';
48
+ ALTER TABLE public.plg_marketing_landing_pages DROP COLUMN lead_tag;
49
+ END IF;
50
+ IF NOT EXISTS (
51
+ SELECT 1 FROM pg_constraint
52
+ WHERE conname = 'plg_marketing_landing_pages_status_check'
53
+ ) THEN
54
+ ALTER TABLE public.plg_marketing_landing_pages
55
+ ADD CONSTRAINT plg_marketing_landing_pages_status_check CHECK (status IN ('live', 'planned'));
56
+ END IF;
57
+ END $$;
58
+
59
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_tenant ON public.plg_marketing_landing_pages(tenant_id);
60
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_site ON public.plg_marketing_landing_pages(site_id);
61
+ -- One row per route within a site: the route IS the page's identity.
62
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_landing_pages_site_route
63
+ ON public.plg_marketing_landing_pages(site_id, route);
64
+
65
+ DO $$
66
+ DECLARE t text := 'plg_marketing_landing_pages';
67
+ BEGIN
68
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
69
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
70
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
71
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
72
+ END IF;
73
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
74
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
75
+ END IF;
76
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
77
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
78
+ END IF;
79
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
80
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
81
+ END IF;
82
+ END $$;
83
+
84
+ -- Read view: the page joined to its site, so the surface renders the full URL
85
+ -- (and the analytics keys) without a second round trip.
86
+ CREATE VIEW public.v_marketing_landing_pages WITH (security_invoker=true) AS
87
+ SELECT lp.id, lp.tenant_id, lp.site_id, lp.name, lp.route, lp.lead_tags, lp.status, lp.prompt, lp.is_active,
88
+ s.name AS site_name, s.preview_url AS site_url, s.app_id, s.fayz_project_id, s.editor_url,
89
+ lp.created_at, lp.updated_at
90
+ FROM public.plg_marketing_landing_pages lp
91
+ JOIN public.fayz_projects s ON s.id = lp.site_id;
92
+ GRANT SELECT ON public.v_marketing_landing_pages TO authenticated;
93
+
94
+ -- Lead events keyed by tag — the cross that turns "leads tagged lp-capilar"
95
+ -- into that page's conversions. One row per (lead, tag); `person_id` is what
96
+ -- keeps a lead carrying two of a page's tags from counting twice. Archived
97
+ -- leads are OUT: plugin-crm archives by flipping people.is_active to false, and
98
+ -- a page's number has to match what the funnel shows. Apps whose leads live
99
+ -- elsewhere may CREATE OR REPLACE this view keeping the contract:
100
+ -- (tenant_id uuid, tag text, person_id uuid, occurred_at timestamptz, value numeric)
101
+ DROP VIEW IF EXISTS public.v_marketing_lead_tag_events;
102
+ CREATE VIEW public.v_marketing_lead_tag_events WITH (security_invoker=true) AS
103
+ SELECT p.tenant_id,
104
+ tag,
105
+ p.id AS person_id,
106
+ p.created_at AS occurred_at,
107
+ NULLIF(p.metadata->>'value', '')::numeric AS value
108
+ FROM public.people p
109
+ CROSS JOIN LATERAL unnest(COALESCE(p.tags, '{}'::text[])) AS tag
110
+ WHERE p.kind = 'lead'
111
+ AND COALESCE(p.is_active, true);
112
+ GRANT SELECT ON public.v_marketing_lead_tag_events TO authenticated;
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql, 005_analytics_base.sql, 006_analytics_views.sql, 007_agent_rpcs.sql — regenerate with scripts/embed-migrations.mjs
1
+ // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql, 005_analytics_base.sql, 006_analytics_views.sql, 007_agent_rpcs.sql, 008_landing_pages.sql — regenerate with scripts/embed-migrations.mjs
2
2
  // SQL files are the source of truth; this inline copy lets the manifest declare
3
3
  // migrations as data. Do not edit by hand — run the embed script instead.
4
4
 
@@ -474,6 +474,120 @@ GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jso
474
474
  TO authenticated, service_role;
475
475
  `
476
476
 
477
+ export const MIGRATION_008_LANDING_PAGES = `-- Marketing Plugin: landing pages.
478
+ -- Prefix: plg_marketing_
479
+ -- A landing page IS a site plus a route (espacorenovario.com.br + /lp-capilar).
480
+ -- The SITE is not modelled here: it is the tenant's fayz project
481
+ -- (public.fayz_projects, @fayz-ai/db 020) — the same rows Settings → Sites
482
+ -- manages — so \`app_id\` / \`fayz_project_id\` / \`preview_url\` come from there.
483
+ -- Visit analytics are read from the platform that hosts the deploy (app id +
484
+ -- route + period); that source is not wired yet, so visits render as pending.
485
+ -- \`lead_tags\` is the cross with the CRM: leads that arrive through the page
486
+ -- carry those tag slugs in people.tags (v_marketing_lead_tag_events counts them).
487
+ -- \`status\`='planned' + \`prompt\` is a page that does NOT exist on the site yet:
488
+ -- the brief is written here and the page gets built with Fayz later.
489
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
490
+
491
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_landing_pages (
492
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
493
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
494
+ site_id uuid NOT NULL REFERENCES public.fayz_projects(id) ON DELETE CASCADE,
495
+ name text NOT NULL,
496
+ route text NOT NULL,
497
+ lead_tags text[] NOT NULL DEFAULT '{}',
498
+ status text NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'planned')),
499
+ prompt text,
500
+ is_active boolean NOT NULL DEFAULT true,
501
+ created_at timestamptz NOT NULL DEFAULT now(),
502
+ updated_at timestamptz NOT NULL DEFAULT now()
503
+ );
504
+
505
+ -- Self-healing for pools that ran an earlier cut of this migration (single
506
+ -- lead_tag column, no status/prompt). No-op on a fresh table. The views are
507
+ -- rebuilt at the bottom; dropping them first is what lets the column go.
508
+ DROP VIEW IF EXISTS public.v_marketing_landing_pages;
509
+
510
+ ALTER TABLE public.plg_marketing_landing_pages
511
+ ADD COLUMN IF NOT EXISTS lead_tags text[] NOT NULL DEFAULT '{}',
512
+ ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'live',
513
+ ADD COLUMN IF NOT EXISTS prompt text;
514
+
515
+ DO $$
516
+ BEGIN
517
+ IF EXISTS (
518
+ SELECT 1 FROM information_schema.columns
519
+ WHERE table_schema = 'public' AND table_name = 'plg_marketing_landing_pages' AND column_name = 'lead_tag'
520
+ ) THEN
521
+ UPDATE public.plg_marketing_landing_pages
522
+ SET lead_tags = ARRAY[lead_tag]
523
+ WHERE lead_tag IS NOT NULL AND lead_tags = '{}';
524
+ ALTER TABLE public.plg_marketing_landing_pages DROP COLUMN lead_tag;
525
+ END IF;
526
+ IF NOT EXISTS (
527
+ SELECT 1 FROM pg_constraint
528
+ WHERE conname = 'plg_marketing_landing_pages_status_check'
529
+ ) THEN
530
+ ALTER TABLE public.plg_marketing_landing_pages
531
+ ADD CONSTRAINT plg_marketing_landing_pages_status_check CHECK (status IN ('live', 'planned'));
532
+ END IF;
533
+ END $$;
534
+
535
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_tenant ON public.plg_marketing_landing_pages(tenant_id);
536
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_site ON public.plg_marketing_landing_pages(site_id);
537
+ -- One row per route within a site: the route IS the page's identity.
538
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_landing_pages_site_route
539
+ ON public.plg_marketing_landing_pages(site_id, route);
540
+
541
+ DO $$
542
+ DECLARE t text := 'plg_marketing_landing_pages';
543
+ BEGIN
544
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
545
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
546
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
547
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
548
+ END IF;
549
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
550
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
551
+ END IF;
552
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
553
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
554
+ END IF;
555
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
556
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
557
+ END IF;
558
+ END $$;
559
+
560
+ -- Read view: the page joined to its site, so the surface renders the full URL
561
+ -- (and the analytics keys) without a second round trip.
562
+ CREATE VIEW public.v_marketing_landing_pages WITH (security_invoker=true) AS
563
+ SELECT lp.id, lp.tenant_id, lp.site_id, lp.name, lp.route, lp.lead_tags, lp.status, lp.prompt, lp.is_active,
564
+ s.name AS site_name, s.preview_url AS site_url, s.app_id, s.fayz_project_id, s.editor_url,
565
+ lp.created_at, lp.updated_at
566
+ FROM public.plg_marketing_landing_pages lp
567
+ JOIN public.fayz_projects s ON s.id = lp.site_id;
568
+ GRANT SELECT ON public.v_marketing_landing_pages TO authenticated;
569
+
570
+ -- Lead events keyed by tag — the cross that turns "leads tagged lp-capilar"
571
+ -- into that page's conversions. One row per (lead, tag); \`person_id\` is what
572
+ -- keeps a lead carrying two of a page's tags from counting twice. Archived
573
+ -- leads are OUT: plugin-crm archives by flipping people.is_active to false, and
574
+ -- a page's number has to match what the funnel shows. Apps whose leads live
575
+ -- elsewhere may CREATE OR REPLACE this view keeping the contract:
576
+ -- (tenant_id uuid, tag text, person_id uuid, occurred_at timestamptz, value numeric)
577
+ DROP VIEW IF EXISTS public.v_marketing_lead_tag_events;
578
+ CREATE VIEW public.v_marketing_lead_tag_events WITH (security_invoker=true) AS
579
+ SELECT p.tenant_id,
580
+ tag,
581
+ p.id AS person_id,
582
+ p.created_at AS occurred_at,
583
+ NULLIF(p.metadata->>'value', '')::numeric AS value
584
+ FROM public.people p
585
+ CROSS JOIN LATERAL unnest(COALESCE(p.tags, '{}'::text[])) AS tag
586
+ WHERE p.kind = 'lead'
587
+ AND COALESCE(p.is_active, true);
588
+ GRANT SELECT ON public.v_marketing_lead_tag_events TO authenticated;
589
+ `
590
+
477
591
  export const MIGRATIONS: Array<{ id: string; sql: string }> = [
478
592
  { id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
479
593
  { id: "001_content_planner", sql: MIGRATION_001_CONTENT_PLANNER },
@@ -483,4 +597,5 @@ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
483
597
  { id: "005_analytics_base", sql: MIGRATION_005_ANALYTICS_BASE },
484
598
  { id: "006_analytics_views", sql: MIGRATION_006_ANALYTICS_VIEWS },
485
599
  { id: "007_agent_rpcs", sql: MIGRATION_007_AGENT_RPCS },
600
+ { id: "008_landing_pages", sql: MIGRATION_008_LANDING_PAGES },
486
601
  ]
package/src/registries.ts CHANGED
@@ -41,7 +41,9 @@ const channelEntity: EntityDef = {
41
41
 
42
42
  /** Build the marketing settings registries. Channels are tenant-editable rows
43
43
  * in plg_marketing_channels; the Supabase provider lazily seeds them from the
44
- * resolved domain preset (seedData doubles as walkable mock data). */
44
+ * resolved domain preset (seedData doubles as walkable mock data). Sites are
45
+ * NOT here on purpose: they are the tenant's fayz projects, managed by the
46
+ * shell in Settings → Sites, and marketing only points a page at one. */
45
47
  export function buildMarketingRegistries(channels: AcquisitionChannel[]): PluginRegistryDef[] {
46
48
  return [
47
49
  {
package/src/store.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { createStore, type StoreApi } from 'zustand/vanilla'
2
- import type { MarketingDataProvider, SaveCampaignInput } from './data/types'
2
+ import type { MarketingDataProvider, SaveCampaignInput, SaveLandingPageInput } from './data/types'
3
3
  import type {
4
4
  Campaign,
5
5
  ChannelPerformance,
6
6
  DateRangeKey,
7
7
  FunnelStage,
8
+ LandingPage,
8
9
  LandingPagePerf,
9
10
  MarketingOverview,
11
+ MarketingSite,
10
12
  } from './types'
11
13
 
12
14
  export interface MarketingUIState {
@@ -16,6 +18,8 @@ export interface MarketingUIState {
16
18
  campaigns: Campaign[]
17
19
  funnel: FunnelStage[]
18
20
  landingPages: LandingPagePerf[]
21
+ sites: MarketingSite[]
22
+ leadTags: Array<{ slug: string; name: string }>
19
23
  loading: boolean
20
24
 
21
25
  load(): Promise<void>
@@ -24,6 +28,8 @@ export interface MarketingUIState {
24
28
  setRange(range: DateRangeKey): Promise<void>
25
29
  saveCampaign(input: SaveCampaignInput): Promise<Campaign>
26
30
  deleteCampaign(id: string): Promise<void>
31
+ saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage>
32
+ deleteLandingPage(id: string): Promise<void>
27
33
  }
28
34
 
29
35
  export function createMarketingStore(provider: MarketingDataProvider): StoreApi<MarketingUIState> {
@@ -34,20 +40,24 @@ export function createMarketingStore(provider: MarketingDataProvider): StoreApi<
34
40
  campaigns: [],
35
41
  funnel: [],
36
42
  landingPages: [],
43
+ sites: [],
44
+ leadTags: [],
37
45
  loading: false,
38
46
 
39
47
  async load() {
40
48
  const range = get().range
41
49
  set({ loading: true })
42
50
  try {
43
- const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
51
+ const [overview, channels, campaigns, funnel, landingPages, sites, leadTags] = await Promise.all([
44
52
  provider.overview({ range }),
45
53
  provider.channelPerformance({ range }),
46
54
  provider.listCampaigns({ range }),
47
55
  provider.funnel({ range }),
48
56
  provider.landingPages({ range }),
57
+ provider.listSites(),
58
+ provider.listLeadTags(),
49
59
  ])
50
- set({ overview, channels, campaigns, funnel, landingPages, loading: false })
60
+ set({ overview, channels, campaigns, funnel, landingPages, sites, leadTags, loading: false })
51
61
  } catch (err) {
52
62
  // A failed load must never wedge the surface in `loading` forever.
53
63
  set({ loading: false })
@@ -75,5 +85,18 @@ export function createMarketingStore(provider: MarketingDataProvider): StoreApi<
75
85
  await provider.deleteCampaign(id)
76
86
  await get().load()
77
87
  },
88
+
89
+ async saveLandingPage(input) {
90
+ const saved = await provider.saveLandingPage(input)
91
+ provider.invalidateCache?.()
92
+ await get().load()
93
+ return saved
94
+ },
95
+
96
+ async deleteLandingPage(id) {
97
+ await provider.deleteLandingPage(id)
98
+ provider.invalidateCache?.()
99
+ await get().load()
100
+ },
78
101
  }))
79
102
  }
package/src/types.ts CHANGED
@@ -76,14 +76,53 @@ export interface FunnelStage {
76
76
  color: string
77
77
  }
78
78
 
79
- export interface LandingPagePerf {
79
+ /** A site the tenant owns — a row of public.fayz_projects, managed in
80
+ * Settings → Sites. `appId`/`fayzProjectId` are the deploy's ids on the
81
+ * hosting platform: the keys the visit-analytics call needs (app + route +
82
+ * period). Marketing reads these; it does not own them. */
83
+ export interface MarketingSite {
80
84
  id: string
81
85
  name: string
82
- type: 'Funnel' | 'Landing page' | 'Website'
83
- visits: number
86
+ /** live site URL, e.g. 'https://espacorenovario.com.br' */
87
+ url?: string
88
+ appId?: string
89
+ fayzProjectId?: string
90
+ /** Fayz editor for this project — where a planned page gets built. */
91
+ editorUrl?: string
92
+ }
93
+
94
+ /** 'live' = the route already exists on the site. 'planned' = it does not yet;
95
+ * `prompt` is the brief for building it with Fayz. */
96
+ export type LandingPageStatus = 'live' | 'planned'
97
+
98
+ /** A landing page IS a site + a route. `leadTags` is the CRM cross: leads that
99
+ * come in through the page carry those tag slugs. */
100
+ export interface LandingPage {
101
+ id: string
102
+ siteId: string
103
+ name: string
104
+ /** path within the site, always leading-slashed, e.g. '/lp-capilar' */
105
+ route: string
106
+ leadTags: string[]
107
+ status: LandingPageStatus
108
+ /** what the page should be — only meaningful while status is 'planned' */
109
+ prompt?: string
110
+ isActive: boolean
111
+ }
112
+
113
+ export interface LandingPagePerf extends LandingPage {
114
+ siteName: string
115
+ siteUrl?: string
116
+ appId?: string
117
+ editorUrl?: string
118
+ /** full public URL (site + route); the bare route when the site has no URL */
119
+ url: string
120
+ /** null while no visit source is connected — renders as "—", not as zero. */
121
+ visits: number | null
122
+ /** leads carrying `leadTag` within the queried period */
84
123
  conversions: number
85
- /** conversions / visits * 100 */
86
- cvr: number
124
+ /** conversions / visits * 100 — null when visits are unknown */
125
+ cvr: number | null
87
126
  }
88
127
 
89
128
  export interface MarketingOverview {