@fayz-ai/plugin-marketing 0.8.2 → 0.9.0-next.1

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 (43) 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 +14 -12
  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
  42. package/dist/index.cjs +0 -5237
  43. package/dist/index.cjs.map +0 -1
@@ -3,6 +3,7 @@ import type {
3
3
  AttributionBridge,
4
4
  MarketingDataProvider,
5
5
  SaveCampaignInput,
6
+ SaveLandingPageInput,
6
7
  SitesPerformanceBridge,
7
8
  } from './types'
8
9
  import type {
@@ -12,11 +13,13 @@ import type {
12
13
  ConversionModel,
13
14
  DateRangeKey,
14
15
  FunnelStage,
16
+ LandingPage,
15
17
  LandingPagePerf,
16
18
  MarketingOverview,
17
19
  MarketingQuery,
20
+ MarketingSite,
18
21
  } from '../types'
19
- import { T, V } from './tables'
22
+ import { LEAD_TAGS_SOURCE, SITES_TABLE, T, V } from './tables'
20
23
 
21
24
  // ---------------------------------------------------------------------------
22
25
  // Supabase marketing provider. Channels/campaigns are real CRUD rows
@@ -32,6 +35,8 @@ export interface SupabaseMarketingProviderOptions {
32
35
  conversion: ConversionModel
33
36
  attributionBridge?: AttributionBridge
34
37
  sitesBridge?: SitesPerformanceBridge
38
+ /** Where the lead-tag catalog is read from (defaults to plugin-crm's). */
39
+ leadTags?: { table?: string; valueField?: string; labelField?: string }
35
40
  }
36
41
 
37
42
  const RANGE_DAYS: Record<DateRangeKey, number> = { '7d': 7, '30d': 30, '90d': 90 }
@@ -54,6 +59,56 @@ function normKey(value: string | null | undefined): string {
54
59
  return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
55
60
  }
56
61
 
62
+ /** Fallback editor link when the project row has no explicit editor_url —
63
+ * same convention the shell's "Seu site" widget uses. */
64
+ const FAYZ_EDITOR_BASE = 'https://fayz.ai/editor'
65
+
66
+ /** '/lp-capilar' ← 'lp-capilar' | 'lp-capilar/' | '' */
67
+ function normalizeRoute(route: string): string {
68
+ const trimmed = route.trim().replace(/\/+$/, '')
69
+ if (!trimmed || trimmed === '/') return '/'
70
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
71
+ }
72
+
73
+ /** The site's URL is whatever the fayz project recorded (may be missing while
74
+ * the deploy has no public URL yet) — then the page shows just its route. */
75
+ function pageUrl(siteUrl: string | null | undefined, route: string): string {
76
+ const path = normalizeRoute(route)
77
+ if (!siteUrl) return path
78
+ const base = siteUrl.trim().replace(/\/+$/, '')
79
+ const withScheme = /^https?:\/\//.test(base) ? base : `https://${base}`
80
+ return `${withScheme}${path === '/' ? '' : path}`
81
+ }
82
+
83
+ function landingPageFromRow(row: Record<string, any>): LandingPage {
84
+ return {
85
+ id: row.id,
86
+ siteId: row.site_id,
87
+ name: row.name,
88
+ route: row.route,
89
+ leadTags: row.lead_tags ?? [],
90
+ status: row.status === 'planned' ? 'planned' : 'live',
91
+ prompt: row.prompt ?? undefined,
92
+ isActive: row.is_active !== false,
93
+ }
94
+ }
95
+
96
+ interface LandingPageRow {
97
+ id: string
98
+ site_id: string
99
+ name: string
100
+ route: string
101
+ lead_tags: string[] | null
102
+ status: string
103
+ prompt: string | null
104
+ is_active: boolean
105
+ site_name: string
106
+ site_url: string | null
107
+ app_id: string | null
108
+ fayz_project_id: string | null
109
+ editor_url: string | null
110
+ }
111
+
57
112
  interface AttributionRow {
58
113
  kind: 'lead' | 'conversion'
59
114
  source: string
@@ -66,6 +121,7 @@ export function createSupabaseMarketingProvider(
66
121
  options: SupabaseMarketingProviderOptions,
67
122
  ): MarketingDataProvider {
68
123
  const { channels, conversion, attributionBridge, sitesBridge } = options
124
+ const leadTagsSource = { ...LEAD_TAGS_SOURCE, ...options.leadTags }
69
125
 
70
126
  const channelByNorm = new Map<string, string>()
71
127
  for (const ch of channels) {
@@ -237,6 +293,66 @@ export function createSupabaseMarketingProvider(
237
293
  return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list
238
294
  }
239
295
 
296
+ function fetchLandingPages(tenantId: string): Promise<LandingPageRow[]> {
297
+ return memo(`lp:${tenantId}`, async () => {
298
+ const { data, error } = await getClient()
299
+ .from(V.landingPages)
300
+ .select('id, site_id, name, route, lead_tags, status, prompt, is_active, site_name, site_url, app_id, fayz_project_id, editor_url')
301
+ .eq('tenant_id', tenantId)
302
+ .order('name')
303
+ // A pool that hasn't run migration 008 has no view — that must cost the
304
+ // page its rows, not take the whole Marketing surface down with it.
305
+ if (error) return []
306
+ return (data ?? []) as LandingPageRow[]
307
+ })
308
+ }
309
+
310
+ /** Leads carrying each page's tags within the window — the CRM cross. Keyed
311
+ * by tag → lead ids so a page with several tags counts each lead once. */
312
+ function fetchLeadsByTag(
313
+ tenantId: string,
314
+ tags: string[],
315
+ since: Date,
316
+ ): Promise<Map<string, Set<string>>> {
317
+ const key = `lptags:${tenantId}:${since.getTime()}:${tags.join(',')}`
318
+ return memo(key, async () => {
319
+ const { data, error } = await getClient()
320
+ .from(V.leadTagEvents)
321
+ .select('tag, person_id')
322
+ .eq('tenant_id', tenantId)
323
+ .in('tag', tags)
324
+ .gte('occurred_at', since.toISOString())
325
+ if (error) return new Map<string, Set<string>>()
326
+ const byTag = new Map<string, Set<string>>()
327
+ for (const row of data ?? []) {
328
+ const set = byTag.get(row.tag) ?? new Set<string>()
329
+ set.add(row.person_id)
330
+ byTag.set(row.tag, set)
331
+ }
332
+ return byTag
333
+ })
334
+ }
335
+
336
+ /** Visits come from wherever the site is deployed. No bridge ⇒ unknown, and
337
+ * the surface says so instead of showing zeros. */
338
+ async function fetchVisits(
339
+ range: DateRangeKey,
340
+ rows: LandingPageRow[],
341
+ ): Promise<Map<string, number>> {
342
+ if (!sitesBridge) return new Map()
343
+ const result = await sitesBridge.visitsByRoute({
344
+ range,
345
+ pages: rows.map((r) => ({
346
+ id: r.id,
347
+ appId: r.app_id ?? undefined,
348
+ fayzProjectId: r.fayz_project_id ?? undefined,
349
+ siteUrl: r.site_url ?? undefined,
350
+ route: r.route,
351
+ })),
352
+ })
353
+ return new Map(result.map((r) => [r.id, r.visits]))
354
+ }
355
+
240
356
  function campaignFromRow(row: Record<string, any>): Campaign {
241
357
  return {
242
358
  id: row.id,
@@ -394,8 +510,104 @@ export function createSupabaseMarketingProvider(
394
510
  },
395
511
 
396
512
  async landingPages(query: MarketingQuery): Promise<LandingPagePerf[]> {
397
- if (sitesBridge) return sitesBridge.landingPages(query)
398
- return []
513
+ const tenantId = requireTenantId()
514
+ const { since } = windowFor(query.range)
515
+ const rows = await fetchLandingPages(tenantId)
516
+ if (!rows.length) return []
517
+
518
+ const tags = [...new Set(rows.flatMap((r) => r.lead_tags ?? []))]
519
+ const [leadsByTag, visitsById] = await Promise.all([
520
+ tags.length ? fetchLeadsByTag(tenantId, tags, since) : Promise.resolve(new Map<string, Set<string>>()),
521
+ fetchVisits(query.range, rows),
522
+ ])
523
+
524
+ return rows.map((r) => {
525
+ const visits = visitsById.get(r.id) ?? null
526
+ const leads = new Set<string>()
527
+ for (const tag of r.lead_tags ?? []) {
528
+ for (const personId of leadsByTag.get(tag) ?? []) leads.add(personId)
529
+ }
530
+ const conversions = leads.size
531
+ return {
532
+ ...landingPageFromRow(r),
533
+ siteName: r.site_name,
534
+ siteUrl: r.site_url ?? undefined,
535
+ appId: r.app_id ?? undefined,
536
+ editorUrl: r.editor_url ?? (r.fayz_project_id ? `${FAYZ_EDITOR_BASE}/${r.fayz_project_id}` : undefined),
537
+ url: pageUrl(r.site_url, r.route),
538
+ visits,
539
+ conversions,
540
+ cvr: visits && visits > 0 ? (conversions / visits) * 100 : null,
541
+ }
542
+ })
543
+ },
544
+
545
+ async saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage> {
546
+ const tenantId = requireTenantId()
547
+ const payload = {
548
+ tenant_id: tenantId,
549
+ site_id: input.siteId,
550
+ name: input.name,
551
+ route: normalizeRoute(input.route),
552
+ lead_tags: input.leadTags ?? [],
553
+ status: input.status ?? 'live',
554
+ prompt: input.prompt?.trim() || null,
555
+ is_active: input.isActive ?? true,
556
+ updated_at: new Date().toISOString(),
557
+ }
558
+ const query = input.id
559
+ ? getClient().from(T.landingPages).update(payload).eq('id', input.id)
560
+ : getClient().from(T.landingPages).insert(payload)
561
+ const { data, error } = await query.select('*').single()
562
+ if (error) throw error
563
+ cache.clear()
564
+ return landingPageFromRow(data)
565
+ },
566
+
567
+ async deleteLandingPage(id: string): Promise<void> {
568
+ const { error } = await getClient().from(T.landingPages).delete().eq('id', id)
569
+ if (error) throw error
570
+ cache.clear()
571
+ },
572
+
573
+ async listSites(): Promise<MarketingSite[]> {
574
+ const tenantId = requireTenantId()
575
+ return memo(`sites:${tenantId}`, async () => {
576
+ // Owned by the shell (Settings → Sites); marketing just picks one.
577
+ const { data, error } = await getClient()
578
+ .from(SITES_TABLE)
579
+ .select('id, name, preview_url, app_id, fayz_project_id, editor_url')
580
+ .eq('tenant_id', tenantId)
581
+ .order('is_default', { ascending: false })
582
+ .order('name')
583
+ if (error) return []
584
+ return (data ?? []).map((r: any) => ({
585
+ id: r.id,
586
+ name: r.name,
587
+ url: r.preview_url ?? undefined,
588
+ appId: r.app_id ?? undefined,
589
+ fayzProjectId: r.fayz_project_id ?? undefined,
590
+ editorUrl: r.editor_url ?? (r.fayz_project_id ? `${FAYZ_EDITOR_BASE}/${r.fayz_project_id}` : undefined),
591
+ }))
592
+ })
593
+ },
594
+
595
+ async listLeadTags(): Promise<Array<{ slug: string; name: string }>> {
596
+ const tenantId = requireTenantId()
597
+ const { table, valueField, labelField } = leadTagsSource
598
+ return memo(`leadtags:${tenantId}`, async () => {
599
+ // The catalog belongs to another plugin — an app without it (or with a
600
+ // different tag store) must not break the landing-page form.
601
+ const { data, error } = await getClient()
602
+ .from(table)
603
+ .select(`${valueField}, ${labelField}`)
604
+ .eq('tenant_id', tenantId)
605
+ .order(labelField)
606
+ if (error) return []
607
+ return (data ?? [])
608
+ .map((r: any) => ({ slug: r[valueField] as string, name: (r[labelField] as string) ?? r[valueField] }))
609
+ .filter((tag: { slug: string }) => Boolean(tag.slug))
610
+ })
399
611
  },
400
612
  }
401
613
  }
@@ -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;