@fayz-ai/plugin-marketing 0.8.0 → 0.8.2

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 (74) hide show
  1. package/dist/MarketingContext.d.ts +2 -0
  2. package/dist/MarketingContext.d.ts.map +1 -1
  3. package/dist/MarketingPage.d.ts.map +1 -1
  4. package/dist/data/blogEntities.d.ts +28 -0
  5. package/dist/data/blogEntities.d.ts.map +1 -0
  6. package/dist/data/capability.test.d.ts +2 -0
  7. package/dist/data/capability.test.d.ts.map +1 -0
  8. package/dist/data/supabase.d.ts +10 -0
  9. package/dist/data/supabase.d.ts.map +1 -0
  10. package/dist/data/tables.d.ts +7 -0
  11. package/dist/data/tables.d.ts.map +1 -1
  12. package/dist/data/types.d.ts +2 -0
  13. package/dist/data/types.d.ts.map +1 -1
  14. package/dist/index.cjs +1239 -87
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1245 -94
  19. package/dist/index.js.map +1 -1
  20. package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
  21. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
  22. package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
  23. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
  24. package/dist/integrations/ranklayer/types.d.ts +16 -0
  25. package/dist/integrations/ranklayer/types.d.ts.map +1 -0
  26. package/dist/migrations/index.d.ts +4 -0
  27. package/dist/migrations/index.d.ts.map +1 -1
  28. package/dist/presets.d.ts +2 -0
  29. package/dist/presets.d.ts.map +1 -1
  30. package/dist/registries.d.ts +3 -3
  31. package/dist/registries.d.ts.map +1 -1
  32. package/dist/schema/index.d.ts +354 -0
  33. package/dist/schema/index.d.ts.map +1 -1
  34. package/dist/schema.cjs +30 -0
  35. package/dist/schema.cjs.map +1 -1
  36. package/dist/schema.js +30 -2
  37. package/dist/schema.js.map +1 -1
  38. package/dist/store.d.ts +2 -0
  39. package/dist/store.d.ts.map +1 -1
  40. package/dist/views/CampaignsView.d.ts.map +1 -1
  41. package/dist/views/blog/BlogAdminView.d.ts +5 -0
  42. package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
  43. package/dist/views/blog/BlogEntityForm.d.ts +11 -0
  44. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
  45. package/dist/views/blog/useEntityCrud.d.ts +29 -0
  46. package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
  47. package/dist/views/content/ContentView.d.ts.map +1 -1
  48. package/package.json +8 -6
  49. package/src/MarketingContext.tsx +2 -0
  50. package/src/MarketingPage.tsx +27 -2
  51. package/src/data/blogEntities.tsx +91 -0
  52. package/src/data/capability.test.ts +92 -0
  53. package/src/data/supabase.ts +401 -0
  54. package/src/data/tables.ts +9 -0
  55. package/src/data/types.ts +2 -0
  56. package/src/index.ts +153 -10
  57. package/src/integrations/ranklayer/RANKLAYER.md +40 -0
  58. package/src/integrations/ranklayer/connectorDef.tsx +98 -0
  59. package/src/integrations/ranklayer/data/supabase.ts +106 -0
  60. package/src/integrations/ranklayer/types.ts +16 -0
  61. package/src/migrations/004_ranklayer.sql +50 -0
  62. package/src/migrations/005_analytics_base.sql +60 -0
  63. package/src/migrations/006_analytics_views.sql +68 -0
  64. package/src/migrations/007_agent_rpcs.sql +119 -0
  65. package/src/migrations/index.ts +310 -1
  66. package/src/presets.ts +5 -3
  67. package/src/registries.ts +18 -6
  68. package/src/schema/index.ts +38 -1
  69. package/src/store.ts +21 -8
  70. package/src/views/CampaignsView.tsx +4 -1
  71. package/src/views/blog/BlogAdminView.tsx +99 -0
  72. package/src/views/blog/BlogEntityForm.tsx +101 -0
  73. package/src/views/blog/useEntityCrud.ts +50 -0
  74. package/src/views/content/ContentView.tsx +8 -1
@@ -0,0 +1,401 @@
1
+ import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
+ import type {
3
+ AttributionBridge,
4
+ MarketingDataProvider,
5
+ SaveCampaignInput,
6
+ SitesPerformanceBridge,
7
+ } from './types'
8
+ import type {
9
+ AcquisitionChannel,
10
+ Campaign,
11
+ ChannelPerformance,
12
+ ConversionModel,
13
+ DateRangeKey,
14
+ FunnelStage,
15
+ LandingPagePerf,
16
+ MarketingOverview,
17
+ MarketingQuery,
18
+ } from '../types'
19
+ import { T, V } from './tables'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Supabase marketing provider. Channels/campaigns are real CRUD rows
23
+ // (plg_marketing_channels/_campaigns, lazily seeded from the app's channel
24
+ // config); performance is derived from v_marketing_attribution — REAL
25
+ // conversions (bookings/orders/won deals) and leads, attributed by matching
26
+ // the event's origin string against the channel set. Reach has no generic
27
+ // source yet, so it reads 0 until a sites/ads integration feeds it.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface SupabaseMarketingProviderOptions {
31
+ channels: AcquisitionChannel[]
32
+ conversion: ConversionModel
33
+ attributionBridge?: AttributionBridge
34
+ sitesBridge?: SitesPerformanceBridge
35
+ }
36
+
37
+ const RANGE_DAYS: Record<DateRangeKey, number> = { '7d': 7, '30d': 30, '90d': 90 }
38
+
39
+ function getClient() {
40
+ const supabase = getSupabaseClientOptional() as any
41
+ if (!supabase) throw new Error('Supabase not initialized')
42
+ return supabase
43
+ }
44
+
45
+ function requireTenantId(): string {
46
+ const tenantId = getActiveTenantId()
47
+ if (!tenantId) throw new Error('No active tenant')
48
+ return tenantId
49
+ }
50
+
51
+ /** 'Walk-in' → 'walkin', 'paid-search' → 'paidsearch' — origin strings match
52
+ * channel ids OR labels regardless of case/punctuation. */
53
+ function normKey(value: string | null | undefined): string {
54
+ return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
55
+ }
56
+
57
+ interface AttributionRow {
58
+ kind: 'lead' | 'conversion'
59
+ source: string
60
+ channel_raw: string | null
61
+ occurred_at: string
62
+ value: number | null
63
+ }
64
+
65
+ export function createSupabaseMarketingProvider(
66
+ options: SupabaseMarketingProviderOptions,
67
+ ): MarketingDataProvider {
68
+ const { channels, conversion, attributionBridge, sitesBridge } = options
69
+
70
+ const channelByNorm = new Map<string, string>()
71
+ for (const ch of channels) {
72
+ channelByNorm.set(normKey(ch.id), ch.id)
73
+ channelByNorm.set(normKey(ch.label), ch.id)
74
+ }
75
+ const resolveChannel = (raw: string | null): string | null =>
76
+ channelByNorm.get(normKey(raw)) ?? null
77
+
78
+ // Lazy per-tenant seed: the channel set comes from app config (SQL can't know
79
+ // the domain), so the first read upserts the preset rows. Idempotent via the
80
+ // (tenant_id, channel_key) unique index.
81
+ const seeded = new Set<string>()
82
+ async function ensureChannels(tenantId: string): Promise<void> {
83
+ if (seeded.has(tenantId)) return
84
+ seeded.add(tenantId)
85
+ await getClient()
86
+ .from(T.channels)
87
+ .upsert(
88
+ channels.map((c) => ({
89
+ tenant_id: tenantId,
90
+ channel_key: c.id,
91
+ label: c.label,
92
+ icon: c.icon,
93
+ kind: c.kind,
94
+ })),
95
+ { onConflict: 'tenant_id,channel_key', ignoreDuplicates: true },
96
+ )
97
+ }
98
+
99
+ // One store.load() fans out to overview + channels + funnel + campaigns, and
100
+ // dashboard widgets pile on top — without dedup that's ~10 concurrent
101
+ // requests per mount (a stampede the instant mock never surfaced). Share
102
+ // in-flight promises and cache briefly; writes clear the cache.
103
+ const CACHE_TTL_MS = 30_000
104
+ const cache = new Map<string, { at: number; promise: Promise<unknown> }>()
105
+ function memo<T>(key: string, fn: () => Promise<T>): Promise<T> {
106
+ const hit = cache.get(key)
107
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.promise as Promise<T>
108
+ const promise = fn().catch((err) => {
109
+ cache.delete(key)
110
+ throw err
111
+ })
112
+ cache.set(key, { at: Date.now(), promise })
113
+ return promise
114
+ }
115
+
116
+ function windowFor(range: DateRangeKey): { since: Date; days: number } {
117
+ const days = RANGE_DAYS[range]
118
+ // Quantized to the minute so equal ranges hit the same cache key.
119
+ const now = Math.floor(Date.now() / 60_000) * 60_000
120
+ return { since: new Date(now - days * 86_400_000), days }
121
+ }
122
+
123
+ function fetchAttribution(
124
+ tenantId: string,
125
+ since: Date,
126
+ until?: Date,
127
+ ): Promise<AttributionRow[]> {
128
+ const key = `attr:${tenantId}:${since.getTime()}:${until?.getTime() ?? ''}`
129
+ return memo(key, async () => {
130
+ let q = getClient()
131
+ .from(V.attribution)
132
+ .select('kind, source, channel_raw, occurred_at, value')
133
+ .eq('tenant_id', tenantId)
134
+ .gte('occurred_at', since.toISOString())
135
+ // Leads always count; conversions only from the vertical's source.
136
+ .or(`kind.eq.lead,and(kind.eq.conversion,source.eq.${conversion.source})`)
137
+ if (until) q = q.lt('occurred_at', until.toISOString())
138
+ const { data, error } = await q
139
+ if (error) throw error
140
+ return (data ?? []) as AttributionRow[]
141
+ })
142
+ }
143
+
144
+ interface ChannelRollup {
145
+ leads: number
146
+ conversions: number
147
+ value: number
148
+ }
149
+
150
+ function rollup(rows: AttributionRow[]): Map<string, ChannelRollup> {
151
+ const map = new Map<string, ChannelRollup>()
152
+ for (const row of rows) {
153
+ const channelId = resolveChannel(row.channel_raw)
154
+ if (!channelId) continue
155
+ const agg = map.get(channelId) ?? { leads: 0, conversions: 0, value: 0 }
156
+ if (row.kind === 'lead') agg.leads += 1
157
+ else {
158
+ agg.conversions += 1
159
+ agg.value += Number(row.value ?? conversion.avgValue)
160
+ }
161
+ map.set(channelId, agg)
162
+ }
163
+ return map
164
+ }
165
+
166
+ function fetchSpend(
167
+ tenantId: string,
168
+ days: number,
169
+ since: Date,
170
+ ): Promise<Map<string, number>> {
171
+ return memo(`spend:${tenantId}:${days}:${since.getTime()}`, () => fetchSpendRaw(tenantId, days, since))
172
+ }
173
+
174
+ async function fetchSpendRaw(
175
+ tenantId: string,
176
+ days: number,
177
+ since: Date,
178
+ ): Promise<Map<string, number>> {
179
+ const client = getClient()
180
+ const [channelRows, campaignRows] = await Promise.all([
181
+ client.from(V.channels).select('channel_key, monthly_spend').eq('tenant_id', tenantId),
182
+ client
183
+ .from(V.campaigns)
184
+ .select('channel_key, spend, status, starts_at, ends_at')
185
+ .eq('tenant_id', tenantId)
186
+ .neq('status', 'draft'),
187
+ ])
188
+ const spend = new Map<string, number>()
189
+ for (const row of channelRows.data ?? []) {
190
+ const id = resolveChannel(row.channel_key)
191
+ if (!id) continue
192
+ spend.set(id, (spend.get(id) ?? 0) + (Number(row.monthly_spend) * days) / 30)
193
+ }
194
+ for (const row of campaignRows.data ?? []) {
195
+ const id = resolveChannel(row.channel_key)
196
+ if (!id) continue
197
+ // Count a campaign's spend when its window overlaps the query window.
198
+ const ended = row.ends_at ? new Date(row.ends_at) : null
199
+ if (ended && ended < since) continue
200
+ spend.set(id, (spend.get(id) ?? 0) + Number(row.spend))
201
+ }
202
+ return spend
203
+ }
204
+
205
+ async function channelPerformance(query: MarketingQuery): Promise<ChannelPerformance[]> {
206
+ const tenantId = requireTenantId()
207
+ await ensureChannels(tenantId)
208
+ const { since, days } = windowFor(query.range)
209
+ const [rows, spend] = await Promise.all([
210
+ fetchAttribution(tenantId, since),
211
+ fetchSpend(tenantId, days, since),
212
+ ])
213
+ const byChannel = rollup(rows)
214
+ if (attributionBridge) {
215
+ const bridged = await attributionBridge.conversionsByChannel(query)
216
+ for (const b of bridged) {
217
+ const agg = byChannel.get(b.channelId) ?? { leads: 0, conversions: 0, value: 0 }
218
+ agg.conversions = b.conversions
219
+ agg.value = b.value
220
+ byChannel.set(b.channelId, agg)
221
+ }
222
+ }
223
+ const list = channels.map((ch) => {
224
+ const agg = byChannel.get(ch.id) ?? { leads: 0, conversions: 0, value: 0 }
225
+ const chSpend = spend.get(ch.id) ?? 0
226
+ return {
227
+ channelId: ch.id,
228
+ reach: 0,
229
+ leads: agg.leads,
230
+ conversions: agg.conversions,
231
+ spend: Math.round(chSpend),
232
+ value: Math.round(agg.value),
233
+ cvr: 0,
234
+ cpa: agg.conversions > 0 && chSpend > 0 ? chSpend / agg.conversions : 0,
235
+ }
236
+ })
237
+ return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list
238
+ }
239
+
240
+ function campaignFromRow(row: Record<string, any>): Campaign {
241
+ return {
242
+ id: row.id,
243
+ name: row.name,
244
+ channelId: row.channel_key,
245
+ status: row.status,
246
+ start: row.starts_at ?? row.created_at,
247
+ end: row.ends_at ?? undefined,
248
+ spend: Number(row.spend ?? 0),
249
+ reach: 0,
250
+ leads: 0,
251
+ conversions: 0,
252
+ value: 0,
253
+ }
254
+ }
255
+
256
+ function withDerived(campaign: Campaign, rows: AttributionRow[]): Campaign {
257
+ const start = new Date(campaign.start).getTime()
258
+ const end = campaign.end ? new Date(campaign.end).getTime() : Number.POSITIVE_INFINITY
259
+ let leads = 0
260
+ let conversions = 0
261
+ let value = 0
262
+ for (const row of rows) {
263
+ if (resolveChannel(row.channel_raw) !== campaign.channelId) continue
264
+ const at = new Date(row.occurred_at).getTime()
265
+ if (at < start || at > end) continue
266
+ if (row.kind === 'lead') leads += 1
267
+ else {
268
+ conversions += 1
269
+ value += Number(row.value ?? conversion.avgValue)
270
+ }
271
+ }
272
+ return { ...campaign, leads, conversions, value: Math.round(value) }
273
+ }
274
+
275
+ return {
276
+ channelPerformance,
277
+
278
+ async overview(query: MarketingQuery): Promise<MarketingOverview> {
279
+ const tenantId = requireTenantId()
280
+ const perf = await channelPerformance(query)
281
+ const { since, days } = windowFor(query.range)
282
+ const prevRows = await fetchAttribution(
283
+ tenantId,
284
+ new Date(since.getTime() - days * 86_400_000),
285
+ since,
286
+ )
287
+ const conversions = perf.reduce((s, c) => s + c.conversions, 0)
288
+ const spend = perf.reduce((s, c) => s + c.spend, 0)
289
+ const value = perf.reduce((s, c) => s + c.value, 0)
290
+ const conversionsPrev = prevRows.filter(
291
+ (r) => r.kind === 'conversion' && resolveChannel(r.channel_raw),
292
+ ).length
293
+ const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0]
294
+ return {
295
+ conversions,
296
+ conversionsPrev,
297
+ cvr: 0,
298
+ cvrPrev: 0,
299
+ spend,
300
+ cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
301
+ value,
302
+ topChannelId: top && top.conversions > 0 ? top.channelId : null,
303
+ channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions })),
304
+ }
305
+ },
306
+
307
+ async funnel(query: MarketingQuery): Promise<FunnelStage[]> {
308
+ if (attributionBridge) return attributionBridge.funnel(query)
309
+ const tenantId = requireTenantId()
310
+ const { since } = windowFor(query.range)
311
+ const rows = await fetchAttribution(tenantId, since)
312
+ const leads = rows.filter((r) => r.kind === 'lead').length
313
+ const conversions = rows.filter((r) => r.kind === 'conversion').length
314
+ return [
315
+ { id: 'leads', label: 'Leads', count: leads, color: '#0ea5e9' },
316
+ { id: 'converted', label: conversion.labelPlural, count: conversions, color: '#22c55e' },
317
+ ]
318
+ },
319
+
320
+ async listCampaigns(query: MarketingQuery): Promise<Campaign[]> {
321
+ const tenantId = requireTenantId()
322
+ const fetchRows = () =>
323
+ memo(`campaigns:${tenantId}`, async () => {
324
+ const { data, error } = await getClient()
325
+ .from(V.campaigns)
326
+ .select('*')
327
+ .eq('tenant_id', tenantId)
328
+ .order('created_at', { ascending: false })
329
+ if (error) throw error
330
+ return (data ?? []) as Record<string, any>[]
331
+ })
332
+ const [data, rows] = await Promise.all([
333
+ fetchRows(),
334
+ fetchAttribution(tenantId, windowFor(query.range).since),
335
+ ])
336
+ const list = query.channelId ? data.filter((r) => r.channel_key === query.channelId) : data
337
+ return list.map((row) => withDerived(campaignFromRow(row), rows))
338
+ },
339
+
340
+ async getCampaign(id: string): Promise<Campaign | null> {
341
+ const tenantId = requireTenantId()
342
+ const { data, error } = await getClient()
343
+ .from(V.campaigns)
344
+ .select('*')
345
+ .eq('tenant_id', tenantId)
346
+ .eq('id', id)
347
+ .maybeSingle()
348
+ if (error) throw error
349
+ if (!data) return null
350
+ const campaign = campaignFromRow(data)
351
+ const rows = await fetchAttribution(tenantId, new Date(campaign.start))
352
+ return withDerived(campaign, rows)
353
+ },
354
+
355
+ async saveCampaign(input: SaveCampaignInput): Promise<Campaign> {
356
+ const tenantId = requireTenantId()
357
+ const payload = {
358
+ name: input.name,
359
+ channel_key: input.channelId,
360
+ status: input.status,
361
+ starts_at: input.start || null,
362
+ ends_at: input.end || null,
363
+ spend: input.spend ?? 0,
364
+ }
365
+ if (input.id) {
366
+ const { data, error } = await getClient()
367
+ .from(T.campaigns)
368
+ .update(payload)
369
+ .eq('id', input.id)
370
+ .select('*')
371
+ .single()
372
+ if (error) throw error
373
+ cache.clear()
374
+ return campaignFromRow(data)
375
+ }
376
+ const { data, error } = await getClient()
377
+ .from(T.campaigns)
378
+ .insert({ tenant_id: tenantId, ...payload })
379
+ .select('*')
380
+ .single()
381
+ if (error) throw error
382
+ cache.clear()
383
+ return campaignFromRow(data)
384
+ },
385
+
386
+ async deleteCampaign(id: string): Promise<void> {
387
+ const { error } = await getClient().from(T.campaigns).delete().eq('id', id)
388
+ if (error) throw error
389
+ cache.clear()
390
+ },
391
+
392
+ invalidateCache() {
393
+ cache.clear()
394
+ },
395
+
396
+ async landingPages(query: MarketingQuery): Promise<LandingPagePerf[]> {
397
+ if (sitesBridge) return sitesBridge.landingPages(query)
398
+ return []
399
+ },
400
+ }
401
+ }
@@ -4,4 +4,13 @@ export const T = {
4
4
  socialAccounts: 'plg_marketing_social_accounts',
5
5
  contentPlans: 'plg_marketing_content_plans',
6
6
  contentPosts: 'plg_marketing_content_posts',
7
+ channels: 'plg_marketing_channels',
8
+ campaigns: 'plg_marketing_campaigns',
9
+ } as const
10
+
11
+ // Read views (bridge views over spine + plugin tables; see 006_analytics_views.sql)
12
+ export const V = {
13
+ channels: 'v_marketing_channels',
14
+ campaigns: 'v_marketing_campaigns',
15
+ attribution: 'v_marketing_attribution',
7
16
  } as const
package/src/data/types.ts CHANGED
@@ -34,6 +34,8 @@ export interface MarketingDataProvider {
34
34
  deleteCampaign(id: string): Promise<void>
35
35
  funnel(query: MarketingQuery): Promise<FunnelStage[]>
36
36
  landingPages(query: MarketingQuery): Promise<LandingPagePerf[]>
37
+ /** Drop any provider-side read cache (external writes, e.g. agent RPCs). */
38
+ invalidateCache?(): void
37
39
  }
38
40
 
39
41
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import type { PluginManifest, PluginRegistryDef, PluginScope, VerticalId } from '@fayz-ai/core'
3
- import { createSafeDataProvider, getSupabaseClientOptional, registerTranslations } from '@fayz-ai/core'
3
+ import { createSafeDataProvider, registerTranslations } from '@fayz-ai/core'
4
4
  import { PluginSettingsPanel } from '@fayz-ai/saas'
5
5
  import { MarketingPage } from './MarketingPage'
6
6
  import { SettingsView } from './views/SettingsView'
@@ -17,12 +17,15 @@ import type {
17
17
  SitesPerformanceBridge,
18
18
  } from './data/types'
19
19
  import { createMockMarketingProvider } from './data/mock'
20
+ import { createSupabaseMarketingProvider } from './data/supabase'
21
+ import { T } from './data/tables'
20
22
  import type { ContentPlannerProvider } from './data/contentTypes'
21
23
  import { createMockContentPlannerProvider } from './data/contentMock'
22
24
  import { createSupabaseContentPlannerProvider } from './data/contentSupabase'
23
25
  import { createMarketingStore } from './store'
24
26
  import { createContentPlannerStore } from './views/content/contentStore'
25
- import { MIGRATION_000_PLG_RENAME, MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS } 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 } from './migrations'
28
+ import { ranklayerConnectorDef } from './integrations/ranklayer/connectorDef'
26
29
  import { DEFAULT_CURRENCY, type MarketingCurrency } from './format'
27
30
  import { MARKETING_PRESETS, type MarketingDomain, type MarketingDomainModules } from './presets'
28
31
  import type { AcquisitionChannel, ConversionModel } from './types'
@@ -74,6 +77,8 @@ const DEFAULT_LABELS: MarketingLabels = {
74
77
  funnel: 'Funnel',
75
78
  landingPages: 'Landing pages',
76
79
  content: 'Content',
80
+ blog: 'Blog',
81
+ blogCategories: 'Categorias',
77
82
  settings: 'Settings',
78
83
  }
79
84
 
@@ -90,16 +95,26 @@ function resolveConfig(options?: MarketingPluginOptions): { config: ResolvedMark
90
95
  return { config, domain }
91
96
  }
92
97
 
93
- function createSafeProvider(config: ResolvedMarketingConfig, domain: MarketingDomain): MarketingDataProvider {
94
- // Supabase-backed + bridge-fed providers land later; mock powers it for now.
95
- void getSupabaseClientOptional()
96
- return createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain })
98
+ function createSafeProvider(
99
+ config: ResolvedMarketingConfig,
100
+ domain: MarketingDomain,
101
+ options?: MarketingPluginOptions,
102
+ ): MarketingDataProvider {
103
+ return createSafeDataProvider(
104
+ () => createSupabaseMarketingProvider({
105
+ channels: config.channels,
106
+ conversion: config.conversion,
107
+ attributionBridge: options?.attributionBridge,
108
+ sitesBridge: options?.sitesBridge,
109
+ }),
110
+ () => createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain }),
111
+ )
97
112
  }
98
113
 
99
114
  export function createMarketingPlugin(options?: MarketingPluginOptions): PluginManifest {
100
115
  registerTranslations(marketingLocales)
101
116
  const { config, domain } = resolveConfig(options)
102
- const provider = options?.dataProvider ?? createSafeProvider(config, domain)
117
+ const provider = options?.dataProvider ?? createSafeProvider(config, domain, options)
103
118
  const store = createMarketingStore(provider)
104
119
  const dashboardWidgets = createMarketingDashboardWidgets({ config, provider, store })
105
120
 
@@ -130,6 +145,8 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
130
145
  subtitle: config.labels.pageSubtitle,
131
146
  generalSettings: React.createElement(SettingsView),
132
147
  registries: marketingRegistries,
148
+ // Enables the "Integrações" tab (renders manifest connectors for this host).
149
+ hostPluginId: 'marketing',
133
150
  routeBase: '/settings/marketing',
134
151
  }))
135
152
  SettingsComponent.displayName = 'MarketingSettings'
@@ -150,6 +167,96 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
150
167
  { id: 'marketing', label: config.labels.pageTitle, group: 'Engage' },
151
168
  ...(config.modules.landingPages ? [{ id: 'marketing.landing-pages', label: config.labels.landingPages, group: 'Engage' }] : []),
152
169
  ...(config.modules.contentPlanner ? [{ id: 'marketing.content', label: config.labels.content, group: 'Engage' }] : []),
170
+ ...(config.modules.blog ? [{ id: 'marketing.blog', label: config.labels.blog, group: 'Engage' }] : []),
171
+ ],
172
+ declaredLimits: [
173
+ ...(config.modules.campaigns ? [{ key: 'campaigns_active', label: 'Active campaigns', table: T.campaigns }] : []),
174
+ ...(config.modules.contentPlanner ? [{ key: 'content_posts_month', label: 'Content posts / month', table: 'plg_marketing_content_posts', period: 'month' as const }] : []),
175
+ ...(config.modules.blog ? [{ key: 'blog_posts', label: 'Blog posts', table: 'plg_blog_posts' }] : []),
176
+ ],
177
+ // Read-models for the agent's data primitives (searchRecords/queryData) —
178
+ // mirrors agenda's `agenda:appointments`. Keys are stable ASCII; fields are
179
+ // the provider's camelCase output shape.
180
+ queryEntities: [
181
+ ...(config.modules.campaigns ? [{
182
+ key: 'marketing:campaigns',
183
+ writable: false,
184
+ entity: {
185
+ name: 'Campaign',
186
+ namePlural: 'Campaigns',
187
+ icon: 'Megaphone',
188
+ permission: { feature: 'marketing', action: 'read' as const },
189
+ fields: [
190
+ { key: 'name', label: 'Name', type: 'text' as const, searchable: true },
191
+ { key: 'channelKey', label: 'Channel', type: 'text' as const, searchable: true },
192
+ { key: 'channelLabel', label: 'Channel label', type: 'text' as const },
193
+ { key: 'status', label: 'Status', type: 'text' as const },
194
+ { key: 'startsAt', label: 'Starts at', type: 'text' as const },
195
+ { key: 'endsAt', label: 'Ends at', type: 'text' as const },
196
+ { key: 'spend', label: 'Spend', type: 'number' as const },
197
+ { key: 'createdAt', label: 'Created at', type: 'text' as const },
198
+ ],
199
+ data: {
200
+ table: 'v_marketing_campaigns',
201
+ tenantScoped: true,
202
+ searchColumns: ['name', 'channel_key'],
203
+ },
204
+ },
205
+ }] : []),
206
+ ...(config.modules.channels ? [{
207
+ key: 'marketing:channels',
208
+ writable: false,
209
+ entity: {
210
+ name: 'Marketing channel',
211
+ namePlural: 'Marketing channels',
212
+ icon: 'Radio',
213
+ permission: { feature: 'marketing', action: 'read' as const },
214
+ fields: [
215
+ { key: 'channelKey', label: 'Key', type: 'text' as const, searchable: true },
216
+ { key: 'label', label: 'Channel', type: 'text' as const, searchable: true },
217
+ { key: 'kind', label: 'Type', type: 'text' as const },
218
+ { key: 'isActive', label: 'Active', type: 'boolean' as const },
219
+ { key: 'monthlySpend', label: 'Monthly spend', type: 'number' as const },
220
+ ],
221
+ data: {
222
+ table: 'v_marketing_channels',
223
+ tenantScoped: true,
224
+ searchColumns: ['label', 'channel_key'],
225
+ },
226
+ },
227
+ }] : []),
228
+ ...(config.modules.contentPlanner ? [{
229
+ key: 'marketing:contentPosts',
230
+ writable: false,
231
+ entity: {
232
+ name: 'Content post',
233
+ namePlural: 'Content posts',
234
+ icon: 'FileText',
235
+ permission: { feature: 'marketing.content', action: 'read' as const },
236
+ fields: [
237
+ { key: 'title', label: 'Title', type: 'text' as const, searchable: true },
238
+ { key: 'format', label: 'Format', type: 'text' as const },
239
+ { key: 'status', label: 'Status', type: 'text' as const },
240
+ { key: 'weekNumber', label: 'Week', type: 'number' as const },
241
+ { key: 'scheduledDate', label: 'Scheduled date', type: 'text' as const },
242
+ { key: 'hook', label: 'Hook', type: 'text' as const, searchable: true },
243
+ ],
244
+ data: {
245
+ table: T.contentPosts,
246
+ tenantScoped: true,
247
+ searchColumns: ['title', 'hook'],
248
+ },
249
+ },
250
+ }] : []),
251
+ ],
252
+ declaredRpcs: [
253
+ ...(config.modules.campaigns ? [{
254
+ name: 'agent_marketing_create_campaign',
255
+ kind: 'write' as const,
256
+ description:
257
+ 'Guarded campaign create: agent_guard (role→plan→campaigns_active cap), channel validated against the tenant channel set, audited.',
258
+ audits: true,
259
+ }] : []),
153
260
  ],
154
261
  navigation: [
155
262
  {
@@ -204,7 +311,34 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
204
311
  sql: MIGRATION_003_RECORDING_OPS,
205
312
  description: 'Recording-day checklist + media asset URL on posts; content-media storage bucket',
206
313
  },
314
+ {
315
+ id: 'marketing-004-ranklayer',
316
+ version: '1.3.0',
317
+ sql: MIGRATION_004_RANKLAYER,
318
+ description: 'RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)',
319
+ },
320
+ {
321
+ id: 'marketing-005-analytics-base',
322
+ version: '1.4.0',
323
+ sql: MIGRATION_005_ANALYTICS_BASE,
324
+ description: 'Analytics base: plg_marketing_channels + plg_marketing_campaigns with canonical tenant RLS',
325
+ },
326
+ {
327
+ id: 'marketing-006-analytics-views',
328
+ version: '1.4.0',
329
+ sql: MIGRATION_006_ANALYTICS_VIEWS,
330
+ description: 'Read views v_marketing_channels/_campaigns + generic spine-only v_marketing_attribution',
331
+ },
332
+ {
333
+ id: 'marketing-007-agent-rpcs',
334
+ version: '1.4.0',
335
+ sql: MIGRATION_007_AGENT_RPCS,
336
+ description: 'agent_marketing_create_campaign — guarded, audited server-plane campaign create',
337
+ },
207
338
  ],
339
+ // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
340
+ // (control plane only); the real sync lands via an external PR (RANKLAYER.md).
341
+ connectors: [ranklayerConnectorDef],
208
342
  aiTools: [
209
343
  {
210
344
  id: 'marketing.channel-performance',
@@ -245,18 +379,26 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
245
379
  {
246
380
  id: 'marketing.create-campaign',
247
381
  name: 'createCampaign',
248
- description: 'Creates an acquisition campaign on a channel.',
382
+ description:
383
+ 'Creates an acquisition campaign on a channel. Resolve the channel first via queryData/searchRecords on marketing channels (use its channel key). The user confirms before creating.',
249
384
  icon: 'Plus',
250
385
  mode: 'persist' as const,
386
+ limitKey: 'campaigns_active',
387
+ execution: { plane: 'server' as const, kind: 'rpc' as const, rpc: 'agent_marketing_create_campaign' },
251
388
  category: 'Marketing',
252
389
  parameters: {
253
390
  type: 'object' as const,
254
391
  properties: {
255
392
  name: { type: 'string' as const, description: 'Campaign name' },
256
- channelId: { type: 'string' as const, description: 'Acquisition channel id' },
393
+ channel_key: { type: 'string' as const, description: "Acquisition channel key (e.g. 'instagram') or its label" },
394
+ status: { type: 'string' as const, enum: ['draft', 'active', 'paused'], description: 'Defaults to draft' },
395
+ starts_at: { type: 'string' as const, description: 'ISO start date-time (defaults to now)' },
396
+ ends_at: { type: 'string' as const, description: 'ISO end date-time (optional)' },
397
+ spend: { type: 'number' as const, description: 'Planned/committed spend (optional)' },
257
398
  },
258
- required: ['name', 'channelId'],
399
+ required: ['name', 'channel_key'],
259
400
  },
401
+ suggestions: [{ label: 'Create an Instagram campaign for this month' }],
260
402
  permission: { feature: 'marketing', action: 'create' as const },
261
403
  },
262
404
  ],
@@ -289,6 +431,7 @@ export type {
289
431
  } from './types'
290
432
  export { MARKETING_PRESETS, type MarketingDomain } from './presets'
291
433
  export { createMockMarketingProvider } from './data/mock'
434
+ export { createSupabaseMarketingProvider } from './data/supabase'
292
435
  export type {
293
436
  ContentPlannerProvider,
294
437
  ContentPlan,