@fayz-ai/plugin-marketing 0.9.0-next.1 → 0.9.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 (88) hide show
  1. package/README.md +3 -3
  2. package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/data/mock.d.ts +1 -1
  5. package/dist/data/mock.d.ts.map +1 -1
  6. package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
  7. package/dist/data/registries.d.ts.map +1 -0
  8. package/dist/index.d.ts +4 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +17 -22
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/format.d.ts.map +1 -0
  13. package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
  14. package/dist/lib/presets.d.ts.map +1 -0
  15. package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
  16. package/dist/views/MarketingPage.d.ts.map +1 -0
  17. package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
  18. package/dist/views/content/ContentPlannerContext.d.ts +1 -1
  19. package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
  20. package/dist/views/dashboardWidgets.d.ts +1 -1
  21. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  22. package/package.json +6 -9
  23. package/dist/MarketingContext.d.ts.map +0 -1
  24. package/dist/MarketingPage.d.ts.map +0 -1
  25. package/dist/format.d.ts.map +0 -1
  26. package/dist/presets.d.ts.map +0 -1
  27. package/dist/registries.d.ts.map +0 -1
  28. package/src/MarketingContext.tsx +0 -42
  29. package/src/MarketingPage.tsx +0 -139
  30. package/src/components/MarketingBits.tsx +0 -131
  31. package/src/components/icons.tsx +0 -36
  32. package/src/data/blogEntities.tsx +0 -91
  33. package/src/data/capability.test.ts +0 -92
  34. package/src/data/contentMedia.ts +0 -25
  35. package/src/data/contentMock.ts +0 -253
  36. package/src/data/contentSupabase.ts +0 -263
  37. package/src/data/contentTypes.ts +0 -138
  38. package/src/data/mock.ts +0 -308
  39. package/src/data/supabase.ts +0 -613
  40. package/src/data/tables.ts +0 -33
  41. package/src/data/types.ts +0 -97
  42. package/src/format.ts +0 -40
  43. package/src/index.ts +0 -457
  44. package/src/integrations/ranklayer/RANKLAYER.md +0 -40
  45. package/src/integrations/ranklayer/connectorDef.tsx +0 -102
  46. package/src/integrations/ranklayer/data/supabase.ts +0 -106
  47. package/src/integrations/ranklayer/types.ts +0 -16
  48. package/src/locales/en.ts +0 -192
  49. package/src/locales/index.ts +0 -7
  50. package/src/locales/pt-BR.ts +0 -192
  51. package/src/migrations/000_plg_rename.sql +0 -15
  52. package/src/migrations/001_content_planner.sql +0 -94
  53. package/src/migrations/002_multi_platform.sql +0 -25
  54. package/src/migrations/003_recording_ops.sql +0 -25
  55. package/src/migrations/004_ranklayer.sql +0 -50
  56. package/src/migrations/005_analytics_base.sql +0 -60
  57. package/src/migrations/006_analytics_views.sql +0 -68
  58. package/src/migrations/007_agent_rpcs.sql +0 -119
  59. package/src/migrations/008_landing_pages.sql +0 -112
  60. package/src/migrations/index.ts +0 -601
  61. package/src/presets.ts +0 -119
  62. package/src/registries.ts +0 -62
  63. package/src/schema/index.ts +0 -106
  64. package/src/store.ts +0 -102
  65. package/src/types.ts +0 -153
  66. package/src/views/CampaignComposer.tsx +0 -89
  67. package/src/views/CampaignsView.tsx +0 -59
  68. package/src/views/ChannelDetailView.tsx +0 -59
  69. package/src/views/ChannelsView.tsx +0 -46
  70. package/src/views/FunnelView.tsx +0 -56
  71. package/src/views/LandingPageComposer.tsx +0 -201
  72. package/src/views/LandingPagesView.tsx +0 -172
  73. package/src/views/OverviewView.tsx +0 -24
  74. package/src/views/SettingsView.tsx +0 -72
  75. package/src/views/blog/BlogAdminView.tsx +0 -99
  76. package/src/views/blog/BlogEntityForm.tsx +0 -101
  77. package/src/views/blog/useEntityCrud.ts +0 -50
  78. package/src/views/content/ContentPlannerContext.tsx +0 -13
  79. package/src/views/content/ContentView.tsx +0 -908
  80. package/src/views/content/PlanBriefView.tsx +0 -268
  81. package/src/views/content/PostPage.tsx +0 -370
  82. package/src/views/content/PostSocialPreview.tsx +0 -212
  83. package/src/views/content/RecordingChecklist.tsx +0 -145
  84. package/src/views/content/contentBits.tsx +0 -114
  85. package/src/views/content/contentStore.ts +0 -160
  86. package/src/views/content/templates.ts +0 -172
  87. package/src/views/dashboardWidgets.tsx +0 -173
  88. /package/dist/{format.d.ts → lib/format.d.ts} +0 -0
@@ -1,613 +0,0 @@
1
- import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
- import type {
3
- AttributionBridge,
4
- MarketingDataProvider,
5
- SaveCampaignInput,
6
- SaveLandingPageInput,
7
- SitesPerformanceBridge,
8
- } from './types'
9
- import type {
10
- AcquisitionChannel,
11
- Campaign,
12
- ChannelPerformance,
13
- ConversionModel,
14
- DateRangeKey,
15
- FunnelStage,
16
- LandingPage,
17
- LandingPagePerf,
18
- MarketingOverview,
19
- MarketingQuery,
20
- MarketingSite,
21
- } from '../types'
22
- import { LEAD_TAGS_SOURCE, SITES_TABLE, T, V } from './tables'
23
-
24
- // ---------------------------------------------------------------------------
25
- // Supabase marketing provider. Channels/campaigns are real CRUD rows
26
- // (plg_marketing_channels/_campaigns, lazily seeded from the app's channel
27
- // config); performance is derived from v_marketing_attribution — REAL
28
- // conversions (bookings/orders/won deals) and leads, attributed by matching
29
- // the event's origin string against the channel set. Reach has no generic
30
- // source yet, so it reads 0 until a sites/ads integration feeds it.
31
- // ---------------------------------------------------------------------------
32
-
33
- export interface SupabaseMarketingProviderOptions {
34
- channels: AcquisitionChannel[]
35
- conversion: ConversionModel
36
- attributionBridge?: AttributionBridge
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 }
40
- }
41
-
42
- const RANGE_DAYS: Record<DateRangeKey, number> = { '7d': 7, '30d': 30, '90d': 90 }
43
-
44
- function getClient() {
45
- const supabase = getSupabaseClientOptional() as any
46
- if (!supabase) throw new Error('Supabase not initialized')
47
- return supabase
48
- }
49
-
50
- function requireTenantId(): string {
51
- const tenantId = getActiveTenantId()
52
- if (!tenantId) throw new Error('No active tenant')
53
- return tenantId
54
- }
55
-
56
- /** 'Walk-in' → 'walkin', 'paid-search' → 'paidsearch' — origin strings match
57
- * channel ids OR labels regardless of case/punctuation. */
58
- function normKey(value: string | null | undefined): string {
59
- return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
60
- }
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
-
112
- interface AttributionRow {
113
- kind: 'lead' | 'conversion'
114
- source: string
115
- channel_raw: string | null
116
- occurred_at: string
117
- value: number | null
118
- }
119
-
120
- export function createSupabaseMarketingProvider(
121
- options: SupabaseMarketingProviderOptions,
122
- ): MarketingDataProvider {
123
- const { channels, conversion, attributionBridge, sitesBridge } = options
124
- const leadTagsSource = { ...LEAD_TAGS_SOURCE, ...options.leadTags }
125
-
126
- const channelByNorm = new Map<string, string>()
127
- for (const ch of channels) {
128
- channelByNorm.set(normKey(ch.id), ch.id)
129
- channelByNorm.set(normKey(ch.label), ch.id)
130
- }
131
- const resolveChannel = (raw: string | null): string | null =>
132
- channelByNorm.get(normKey(raw)) ?? null
133
-
134
- // Lazy per-tenant seed: the channel set comes from app config (SQL can't know
135
- // the domain), so the first read upserts the preset rows. Idempotent via the
136
- // (tenant_id, channel_key) unique index.
137
- const seeded = new Set<string>()
138
- async function ensureChannels(tenantId: string): Promise<void> {
139
- if (seeded.has(tenantId)) return
140
- seeded.add(tenantId)
141
- await getClient()
142
- .from(T.channels)
143
- .upsert(
144
- channels.map((c) => ({
145
- tenant_id: tenantId,
146
- channel_key: c.id,
147
- label: c.label,
148
- icon: c.icon,
149
- kind: c.kind,
150
- })),
151
- { onConflict: 'tenant_id,channel_key', ignoreDuplicates: true },
152
- )
153
- }
154
-
155
- // One store.load() fans out to overview + channels + funnel + campaigns, and
156
- // dashboard widgets pile on top — without dedup that's ~10 concurrent
157
- // requests per mount (a stampede the instant mock never surfaced). Share
158
- // in-flight promises and cache briefly; writes clear the cache.
159
- const CACHE_TTL_MS = 30_000
160
- const cache = new Map<string, { at: number; promise: Promise<unknown> }>()
161
- function memo<T>(key: string, fn: () => Promise<T>): Promise<T> {
162
- const hit = cache.get(key)
163
- if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.promise as Promise<T>
164
- const promise = fn().catch((err) => {
165
- cache.delete(key)
166
- throw err
167
- })
168
- cache.set(key, { at: Date.now(), promise })
169
- return promise
170
- }
171
-
172
- function windowFor(range: DateRangeKey): { since: Date; days: number } {
173
- const days = RANGE_DAYS[range]
174
- // Quantized to the minute so equal ranges hit the same cache key.
175
- const now = Math.floor(Date.now() / 60_000) * 60_000
176
- return { since: new Date(now - days * 86_400_000), days }
177
- }
178
-
179
- function fetchAttribution(
180
- tenantId: string,
181
- since: Date,
182
- until?: Date,
183
- ): Promise<AttributionRow[]> {
184
- const key = `attr:${tenantId}:${since.getTime()}:${until?.getTime() ?? ''}`
185
- return memo(key, async () => {
186
- let q = getClient()
187
- .from(V.attribution)
188
- .select('kind, source, channel_raw, occurred_at, value')
189
- .eq('tenant_id', tenantId)
190
- .gte('occurred_at', since.toISOString())
191
- // Leads always count; conversions only from the vertical's source.
192
- .or(`kind.eq.lead,and(kind.eq.conversion,source.eq.${conversion.source})`)
193
- if (until) q = q.lt('occurred_at', until.toISOString())
194
- const { data, error } = await q
195
- if (error) throw error
196
- return (data ?? []) as AttributionRow[]
197
- })
198
- }
199
-
200
- interface ChannelRollup {
201
- leads: number
202
- conversions: number
203
- value: number
204
- }
205
-
206
- function rollup(rows: AttributionRow[]): Map<string, ChannelRollup> {
207
- const map = new Map<string, ChannelRollup>()
208
- for (const row of rows) {
209
- const channelId = resolveChannel(row.channel_raw)
210
- if (!channelId) continue
211
- const agg = map.get(channelId) ?? { leads: 0, conversions: 0, value: 0 }
212
- if (row.kind === 'lead') agg.leads += 1
213
- else {
214
- agg.conversions += 1
215
- agg.value += Number(row.value ?? conversion.avgValue)
216
- }
217
- map.set(channelId, agg)
218
- }
219
- return map
220
- }
221
-
222
- function fetchSpend(
223
- tenantId: string,
224
- days: number,
225
- since: Date,
226
- ): Promise<Map<string, number>> {
227
- return memo(`spend:${tenantId}:${days}:${since.getTime()}`, () => fetchSpendRaw(tenantId, days, since))
228
- }
229
-
230
- async function fetchSpendRaw(
231
- tenantId: string,
232
- days: number,
233
- since: Date,
234
- ): Promise<Map<string, number>> {
235
- const client = getClient()
236
- const [channelRows, campaignRows] = await Promise.all([
237
- client.from(V.channels).select('channel_key, monthly_spend').eq('tenant_id', tenantId),
238
- client
239
- .from(V.campaigns)
240
- .select('channel_key, spend, status, starts_at, ends_at')
241
- .eq('tenant_id', tenantId)
242
- .neq('status', 'draft'),
243
- ])
244
- const spend = new Map<string, number>()
245
- for (const row of channelRows.data ?? []) {
246
- const id = resolveChannel(row.channel_key)
247
- if (!id) continue
248
- spend.set(id, (spend.get(id) ?? 0) + (Number(row.monthly_spend) * days) / 30)
249
- }
250
- for (const row of campaignRows.data ?? []) {
251
- const id = resolveChannel(row.channel_key)
252
- if (!id) continue
253
- // Count a campaign's spend when its window overlaps the query window.
254
- const ended = row.ends_at ? new Date(row.ends_at) : null
255
- if (ended && ended < since) continue
256
- spend.set(id, (spend.get(id) ?? 0) + Number(row.spend))
257
- }
258
- return spend
259
- }
260
-
261
- async function channelPerformance(query: MarketingQuery): Promise<ChannelPerformance[]> {
262
- const tenantId = requireTenantId()
263
- await ensureChannels(tenantId)
264
- const { since, days } = windowFor(query.range)
265
- const [rows, spend] = await Promise.all([
266
- fetchAttribution(tenantId, since),
267
- fetchSpend(tenantId, days, since),
268
- ])
269
- const byChannel = rollup(rows)
270
- if (attributionBridge) {
271
- const bridged = await attributionBridge.conversionsByChannel(query)
272
- for (const b of bridged) {
273
- const agg = byChannel.get(b.channelId) ?? { leads: 0, conversions: 0, value: 0 }
274
- agg.conversions = b.conversions
275
- agg.value = b.value
276
- byChannel.set(b.channelId, agg)
277
- }
278
- }
279
- const list = channels.map((ch) => {
280
- const agg = byChannel.get(ch.id) ?? { leads: 0, conversions: 0, value: 0 }
281
- const chSpend = spend.get(ch.id) ?? 0
282
- return {
283
- channelId: ch.id,
284
- reach: 0,
285
- leads: agg.leads,
286
- conversions: agg.conversions,
287
- spend: Math.round(chSpend),
288
- value: Math.round(agg.value),
289
- cvr: 0,
290
- cpa: agg.conversions > 0 && chSpend > 0 ? chSpend / agg.conversions : 0,
291
- }
292
- })
293
- return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list
294
- }
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
-
356
- function campaignFromRow(row: Record<string, any>): Campaign {
357
- return {
358
- id: row.id,
359
- name: row.name,
360
- channelId: row.channel_key,
361
- status: row.status,
362
- start: row.starts_at ?? row.created_at,
363
- end: row.ends_at ?? undefined,
364
- spend: Number(row.spend ?? 0),
365
- reach: 0,
366
- leads: 0,
367
- conversions: 0,
368
- value: 0,
369
- }
370
- }
371
-
372
- function withDerived(campaign: Campaign, rows: AttributionRow[]): Campaign {
373
- const start = new Date(campaign.start).getTime()
374
- const end = campaign.end ? new Date(campaign.end).getTime() : Number.POSITIVE_INFINITY
375
- let leads = 0
376
- let conversions = 0
377
- let value = 0
378
- for (const row of rows) {
379
- if (resolveChannel(row.channel_raw) !== campaign.channelId) continue
380
- const at = new Date(row.occurred_at).getTime()
381
- if (at < start || at > end) continue
382
- if (row.kind === 'lead') leads += 1
383
- else {
384
- conversions += 1
385
- value += Number(row.value ?? conversion.avgValue)
386
- }
387
- }
388
- return { ...campaign, leads, conversions, value: Math.round(value) }
389
- }
390
-
391
- return {
392
- channelPerformance,
393
-
394
- async overview(query: MarketingQuery): Promise<MarketingOverview> {
395
- const tenantId = requireTenantId()
396
- const perf = await channelPerformance(query)
397
- const { since, days } = windowFor(query.range)
398
- const prevRows = await fetchAttribution(
399
- tenantId,
400
- new Date(since.getTime() - days * 86_400_000),
401
- since,
402
- )
403
- const conversions = perf.reduce((s, c) => s + c.conversions, 0)
404
- const spend = perf.reduce((s, c) => s + c.spend, 0)
405
- const value = perf.reduce((s, c) => s + c.value, 0)
406
- const conversionsPrev = prevRows.filter(
407
- (r) => r.kind === 'conversion' && resolveChannel(r.channel_raw),
408
- ).length
409
- const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0]
410
- return {
411
- conversions,
412
- conversionsPrev,
413
- cvr: 0,
414
- cvrPrev: 0,
415
- spend,
416
- cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
417
- value,
418
- topChannelId: top && top.conversions > 0 ? top.channelId : null,
419
- channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions })),
420
- }
421
- },
422
-
423
- async funnel(query: MarketingQuery): Promise<FunnelStage[]> {
424
- if (attributionBridge) return attributionBridge.funnel(query)
425
- const tenantId = requireTenantId()
426
- const { since } = windowFor(query.range)
427
- const rows = await fetchAttribution(tenantId, since)
428
- const leads = rows.filter((r) => r.kind === 'lead').length
429
- const conversions = rows.filter((r) => r.kind === 'conversion').length
430
- return [
431
- { id: 'leads', label: 'Leads', count: leads, color: '#0ea5e9' },
432
- { id: 'converted', label: conversion.labelPlural, count: conversions, color: '#22c55e' },
433
- ]
434
- },
435
-
436
- async listCampaigns(query: MarketingQuery): Promise<Campaign[]> {
437
- const tenantId = requireTenantId()
438
- const fetchRows = () =>
439
- memo(`campaigns:${tenantId}`, async () => {
440
- const { data, error } = await getClient()
441
- .from(V.campaigns)
442
- .select('*')
443
- .eq('tenant_id', tenantId)
444
- .order('created_at', { ascending: false })
445
- if (error) throw error
446
- return (data ?? []) as Record<string, any>[]
447
- })
448
- const [data, rows] = await Promise.all([
449
- fetchRows(),
450
- fetchAttribution(tenantId, windowFor(query.range).since),
451
- ])
452
- const list = query.channelId ? data.filter((r) => r.channel_key === query.channelId) : data
453
- return list.map((row) => withDerived(campaignFromRow(row), rows))
454
- },
455
-
456
- async getCampaign(id: string): Promise<Campaign | null> {
457
- const tenantId = requireTenantId()
458
- const { data, error } = await getClient()
459
- .from(V.campaigns)
460
- .select('*')
461
- .eq('tenant_id', tenantId)
462
- .eq('id', id)
463
- .maybeSingle()
464
- if (error) throw error
465
- if (!data) return null
466
- const campaign = campaignFromRow(data)
467
- const rows = await fetchAttribution(tenantId, new Date(campaign.start))
468
- return withDerived(campaign, rows)
469
- },
470
-
471
- async saveCampaign(input: SaveCampaignInput): Promise<Campaign> {
472
- const tenantId = requireTenantId()
473
- const payload = {
474
- name: input.name,
475
- channel_key: input.channelId,
476
- status: input.status,
477
- starts_at: input.start || null,
478
- ends_at: input.end || null,
479
- spend: input.spend ?? 0,
480
- }
481
- if (input.id) {
482
- const { data, error } = await getClient()
483
- .from(T.campaigns)
484
- .update(payload)
485
- .eq('id', input.id)
486
- .select('*')
487
- .single()
488
- if (error) throw error
489
- cache.clear()
490
- return campaignFromRow(data)
491
- }
492
- const { data, error } = await getClient()
493
- .from(T.campaigns)
494
- .insert({ tenant_id: tenantId, ...payload })
495
- .select('*')
496
- .single()
497
- if (error) throw error
498
- cache.clear()
499
- return campaignFromRow(data)
500
- },
501
-
502
- async deleteCampaign(id: string): Promise<void> {
503
- const { error } = await getClient().from(T.campaigns).delete().eq('id', id)
504
- if (error) throw error
505
- cache.clear()
506
- },
507
-
508
- invalidateCache() {
509
- cache.clear()
510
- },
511
-
512
- async landingPages(query: MarketingQuery): Promise<LandingPagePerf[]> {
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
- })
611
- },
612
- }
613
- }
@@ -1,33 +0,0 @@
1
- // Physical table names for the marketing plugin (plg_marketing_* prefix). Import T
2
- // and reference T.<key> in the data providers so a rename lands in one place.
3
- export const T = {
4
- socialAccounts: 'plg_marketing_social_accounts',
5
- contentPlans: 'plg_marketing_content_plans',
6
- contentPosts: 'plg_marketing_content_posts',
7
- channels: 'plg_marketing_channels',
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',
24
- } as const
25
-
26
- // Read views (bridge views over spine + plugin tables; see 006_analytics_views.sql)
27
- export const V = {
28
- channels: 'v_marketing_channels',
29
- campaigns: 'v_marketing_campaigns',
30
- attribution: 'v_marketing_attribution',
31
- landingPages: 'v_marketing_landing_pages',
32
- leadTagEvents: 'v_marketing_lead_tag_events',
33
- } as const