@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.
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +6 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +15 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/types.d.ts +43 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +782 -104
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/registries.d.ts +3 -1
- package/dist/registries.d.ts.map +1 -1
- package/dist/store.d.ts +9 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +41 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/views/LandingPageComposer.d.ts +8 -0
- package/dist/views/LandingPageComposer.d.ts.map +1 -0
- package/dist/views/LandingPagesView.d.ts.map +1 -1
- package/package.json +14 -12
- package/src/data/mock.ts +83 -8
- package/src/data/supabase.ts +215 -3
- package/src/data/tables.ts +17 -0
- package/src/data/types.ts +41 -1
- package/src/index.ts +12 -2
- package/src/integrations/ranklayer/connectorDef.tsx +4 -0
- package/src/locales/en.ts +24 -1
- package/src/locales/pt-BR.ts +24 -1
- package/src/migrations/008_landing_pages.sql +112 -0
- package/src/migrations/index.ts +116 -1
- package/src/registries.ts +3 -1
- package/src/store.ts +26 -3
- package/src/types.ts +44 -5
- package/src/views/LandingPageComposer.tsx +201 -0
- package/src/views/LandingPagesView.tsx +145 -22
- package/dist/index.cjs +0 -5237
- package/dist/index.cjs.map +0 -1
package/src/data/supabase.ts
CHANGED
|
@@ -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
|
-
|
|
398
|
-
|
|
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
|
}
|
package/src/data/tables.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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.",
|
package/src/locales/pt-BR.ts
CHANGED
|
@@ -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.
|
|
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;
|