@fayz-ai/plugin-marketing 0.9.0-next.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +7 -7
- 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/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;
|
package/src/migrations/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql, 005_analytics_base.sql, 006_analytics_views.sql, 007_agent_rpcs.sql — regenerate with scripts/embed-migrations.mjs
|
|
1
|
+
// AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql, 005_analytics_base.sql, 006_analytics_views.sql, 007_agent_rpcs.sql, 008_landing_pages.sql — regenerate with scripts/embed-migrations.mjs
|
|
2
2
|
// SQL files are the source of truth; this inline copy lets the manifest declare
|
|
3
3
|
// migrations as data. Do not edit by hand — run the embed script instead.
|
|
4
4
|
|
|
@@ -474,6 +474,120 @@ GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jso
|
|
|
474
474
|
TO authenticated, service_role;
|
|
475
475
|
`
|
|
476
476
|
|
|
477
|
+
export const MIGRATION_008_LANDING_PAGES = `-- Marketing Plugin: landing pages.
|
|
478
|
+
-- Prefix: plg_marketing_
|
|
479
|
+
-- A landing page IS a site plus a route (espacorenovario.com.br + /lp-capilar).
|
|
480
|
+
-- The SITE is not modelled here: it is the tenant's fayz project
|
|
481
|
+
-- (public.fayz_projects, @fayz-ai/db 020) — the same rows Settings → Sites
|
|
482
|
+
-- manages — so \`app_id\` / \`fayz_project_id\` / \`preview_url\` come from there.
|
|
483
|
+
-- Visit analytics are read from the platform that hosts the deploy (app id +
|
|
484
|
+
-- route + period); that source is not wired yet, so visits render as pending.
|
|
485
|
+
-- \`lead_tags\` is the cross with the CRM: leads that arrive through the page
|
|
486
|
+
-- carry those tag slugs in people.tags (v_marketing_lead_tag_events counts them).
|
|
487
|
+
-- \`status\`='planned' + \`prompt\` is a page that does NOT exist on the site yet:
|
|
488
|
+
-- the brief is written here and the page gets built with Fayz later.
|
|
489
|
+
-- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
|
|
490
|
+
|
|
491
|
+
CREATE TABLE IF NOT EXISTS public.plg_marketing_landing_pages (
|
|
492
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
493
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
494
|
+
site_id uuid NOT NULL REFERENCES public.fayz_projects(id) ON DELETE CASCADE,
|
|
495
|
+
name text NOT NULL,
|
|
496
|
+
route text NOT NULL,
|
|
497
|
+
lead_tags text[] NOT NULL DEFAULT '{}',
|
|
498
|
+
status text NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'planned')),
|
|
499
|
+
prompt text,
|
|
500
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
501
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
502
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
-- Self-healing for pools that ran an earlier cut of this migration (single
|
|
506
|
+
-- lead_tag column, no status/prompt). No-op on a fresh table. The views are
|
|
507
|
+
-- rebuilt at the bottom; dropping them first is what lets the column go.
|
|
508
|
+
DROP VIEW IF EXISTS public.v_marketing_landing_pages;
|
|
509
|
+
|
|
510
|
+
ALTER TABLE public.plg_marketing_landing_pages
|
|
511
|
+
ADD COLUMN IF NOT EXISTS lead_tags text[] NOT NULL DEFAULT '{}',
|
|
512
|
+
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'live',
|
|
513
|
+
ADD COLUMN IF NOT EXISTS prompt text;
|
|
514
|
+
|
|
515
|
+
DO $$
|
|
516
|
+
BEGIN
|
|
517
|
+
IF EXISTS (
|
|
518
|
+
SELECT 1 FROM information_schema.columns
|
|
519
|
+
WHERE table_schema = 'public' AND table_name = 'plg_marketing_landing_pages' AND column_name = 'lead_tag'
|
|
520
|
+
) THEN
|
|
521
|
+
UPDATE public.plg_marketing_landing_pages
|
|
522
|
+
SET lead_tags = ARRAY[lead_tag]
|
|
523
|
+
WHERE lead_tag IS NOT NULL AND lead_tags = '{}';
|
|
524
|
+
ALTER TABLE public.plg_marketing_landing_pages DROP COLUMN lead_tag;
|
|
525
|
+
END IF;
|
|
526
|
+
IF NOT EXISTS (
|
|
527
|
+
SELECT 1 FROM pg_constraint
|
|
528
|
+
WHERE conname = 'plg_marketing_landing_pages_status_check'
|
|
529
|
+
) THEN
|
|
530
|
+
ALTER TABLE public.plg_marketing_landing_pages
|
|
531
|
+
ADD CONSTRAINT plg_marketing_landing_pages_status_check CHECK (status IN ('live', 'planned'));
|
|
532
|
+
END IF;
|
|
533
|
+
END $$;
|
|
534
|
+
|
|
535
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_tenant ON public.plg_marketing_landing_pages(tenant_id);
|
|
536
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_site ON public.plg_marketing_landing_pages(site_id);
|
|
537
|
+
-- One row per route within a site: the route IS the page's identity.
|
|
538
|
+
CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_landing_pages_site_route
|
|
539
|
+
ON public.plg_marketing_landing_pages(site_id, route);
|
|
540
|
+
|
|
541
|
+
DO $$
|
|
542
|
+
DECLARE t text := 'plg_marketing_landing_pages';
|
|
543
|
+
BEGIN
|
|
544
|
+
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
|
|
545
|
+
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
|
|
546
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
|
|
547
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
|
|
548
|
+
END IF;
|
|
549
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
|
|
550
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
|
|
551
|
+
END IF;
|
|
552
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
|
|
553
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
|
|
554
|
+
END IF;
|
|
555
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
|
|
556
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
|
|
557
|
+
END IF;
|
|
558
|
+
END $$;
|
|
559
|
+
|
|
560
|
+
-- Read view: the page joined to its site, so the surface renders the full URL
|
|
561
|
+
-- (and the analytics keys) without a second round trip.
|
|
562
|
+
CREATE VIEW public.v_marketing_landing_pages WITH (security_invoker=true) AS
|
|
563
|
+
SELECT lp.id, lp.tenant_id, lp.site_id, lp.name, lp.route, lp.lead_tags, lp.status, lp.prompt, lp.is_active,
|
|
564
|
+
s.name AS site_name, s.preview_url AS site_url, s.app_id, s.fayz_project_id, s.editor_url,
|
|
565
|
+
lp.created_at, lp.updated_at
|
|
566
|
+
FROM public.plg_marketing_landing_pages lp
|
|
567
|
+
JOIN public.fayz_projects s ON s.id = lp.site_id;
|
|
568
|
+
GRANT SELECT ON public.v_marketing_landing_pages TO authenticated;
|
|
569
|
+
|
|
570
|
+
-- Lead events keyed by tag — the cross that turns "leads tagged lp-capilar"
|
|
571
|
+
-- into that page's conversions. One row per (lead, tag); \`person_id\` is what
|
|
572
|
+
-- keeps a lead carrying two of a page's tags from counting twice. Archived
|
|
573
|
+
-- leads are OUT: plugin-crm archives by flipping people.is_active to false, and
|
|
574
|
+
-- a page's number has to match what the funnel shows. Apps whose leads live
|
|
575
|
+
-- elsewhere may CREATE OR REPLACE this view keeping the contract:
|
|
576
|
+
-- (tenant_id uuid, tag text, person_id uuid, occurred_at timestamptz, value numeric)
|
|
577
|
+
DROP VIEW IF EXISTS public.v_marketing_lead_tag_events;
|
|
578
|
+
CREATE VIEW public.v_marketing_lead_tag_events WITH (security_invoker=true) AS
|
|
579
|
+
SELECT p.tenant_id,
|
|
580
|
+
tag,
|
|
581
|
+
p.id AS person_id,
|
|
582
|
+
p.created_at AS occurred_at,
|
|
583
|
+
NULLIF(p.metadata->>'value', '')::numeric AS value
|
|
584
|
+
FROM public.people p
|
|
585
|
+
CROSS JOIN LATERAL unnest(COALESCE(p.tags, '{}'::text[])) AS tag
|
|
586
|
+
WHERE p.kind = 'lead'
|
|
587
|
+
AND COALESCE(p.is_active, true);
|
|
588
|
+
GRANT SELECT ON public.v_marketing_lead_tag_events TO authenticated;
|
|
589
|
+
`
|
|
590
|
+
|
|
477
591
|
export const MIGRATIONS: Array<{ id: string; sql: string }> = [
|
|
478
592
|
{ id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
|
|
479
593
|
{ id: "001_content_planner", sql: MIGRATION_001_CONTENT_PLANNER },
|
|
@@ -483,4 +597,5 @@ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
|
|
|
483
597
|
{ id: "005_analytics_base", sql: MIGRATION_005_ANALYTICS_BASE },
|
|
484
598
|
{ id: "006_analytics_views", sql: MIGRATION_006_ANALYTICS_VIEWS },
|
|
485
599
|
{ id: "007_agent_rpcs", sql: MIGRATION_007_AGENT_RPCS },
|
|
600
|
+
{ id: "008_landing_pages", sql: MIGRATION_008_LANDING_PAGES },
|
|
486
601
|
]
|
package/src/registries.ts
CHANGED
|
@@ -41,7 +41,9 @@ const channelEntity: EntityDef = {
|
|
|
41
41
|
|
|
42
42
|
/** Build the marketing settings registries. Channels are tenant-editable rows
|
|
43
43
|
* in plg_marketing_channels; the Supabase provider lazily seeds them from the
|
|
44
|
-
* resolved domain preset (seedData doubles as walkable mock data).
|
|
44
|
+
* resolved domain preset (seedData doubles as walkable mock data). Sites are
|
|
45
|
+
* NOT here on purpose: they are the tenant's fayz projects, managed by the
|
|
46
|
+
* shell in Settings → Sites, and marketing only points a page at one. */
|
|
45
47
|
export function buildMarketingRegistries(channels: AcquisitionChannel[]): PluginRegistryDef[] {
|
|
46
48
|
return [
|
|
47
49
|
{
|
package/src/store.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createStore, type StoreApi } from 'zustand/vanilla'
|
|
2
|
-
import type { MarketingDataProvider, SaveCampaignInput } from './data/types'
|
|
2
|
+
import type { MarketingDataProvider, SaveCampaignInput, SaveLandingPageInput } from './data/types'
|
|
3
3
|
import type {
|
|
4
4
|
Campaign,
|
|
5
5
|
ChannelPerformance,
|
|
6
6
|
DateRangeKey,
|
|
7
7
|
FunnelStage,
|
|
8
|
+
LandingPage,
|
|
8
9
|
LandingPagePerf,
|
|
9
10
|
MarketingOverview,
|
|
11
|
+
MarketingSite,
|
|
10
12
|
} from './types'
|
|
11
13
|
|
|
12
14
|
export interface MarketingUIState {
|
|
@@ -16,6 +18,8 @@ export interface MarketingUIState {
|
|
|
16
18
|
campaigns: Campaign[]
|
|
17
19
|
funnel: FunnelStage[]
|
|
18
20
|
landingPages: LandingPagePerf[]
|
|
21
|
+
sites: MarketingSite[]
|
|
22
|
+
leadTags: Array<{ slug: string; name: string }>
|
|
19
23
|
loading: boolean
|
|
20
24
|
|
|
21
25
|
load(): Promise<void>
|
|
@@ -24,6 +28,8 @@ export interface MarketingUIState {
|
|
|
24
28
|
setRange(range: DateRangeKey): Promise<void>
|
|
25
29
|
saveCampaign(input: SaveCampaignInput): Promise<Campaign>
|
|
26
30
|
deleteCampaign(id: string): Promise<void>
|
|
31
|
+
saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage>
|
|
32
|
+
deleteLandingPage(id: string): Promise<void>
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export function createMarketingStore(provider: MarketingDataProvider): StoreApi<MarketingUIState> {
|
|
@@ -34,20 +40,24 @@ export function createMarketingStore(provider: MarketingDataProvider): StoreApi<
|
|
|
34
40
|
campaigns: [],
|
|
35
41
|
funnel: [],
|
|
36
42
|
landingPages: [],
|
|
43
|
+
sites: [],
|
|
44
|
+
leadTags: [],
|
|
37
45
|
loading: false,
|
|
38
46
|
|
|
39
47
|
async load() {
|
|
40
48
|
const range = get().range
|
|
41
49
|
set({ loading: true })
|
|
42
50
|
try {
|
|
43
|
-
const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
|
|
51
|
+
const [overview, channels, campaigns, funnel, landingPages, sites, leadTags] = await Promise.all([
|
|
44
52
|
provider.overview({ range }),
|
|
45
53
|
provider.channelPerformance({ range }),
|
|
46
54
|
provider.listCampaigns({ range }),
|
|
47
55
|
provider.funnel({ range }),
|
|
48
56
|
provider.landingPages({ range }),
|
|
57
|
+
provider.listSites(),
|
|
58
|
+
provider.listLeadTags(),
|
|
49
59
|
])
|
|
50
|
-
set({ overview, channels, campaigns, funnel, landingPages, loading: false })
|
|
60
|
+
set({ overview, channels, campaigns, funnel, landingPages, sites, leadTags, loading: false })
|
|
51
61
|
} catch (err) {
|
|
52
62
|
// A failed load must never wedge the surface in `loading` forever.
|
|
53
63
|
set({ loading: false })
|
|
@@ -75,5 +85,18 @@ export function createMarketingStore(provider: MarketingDataProvider): StoreApi<
|
|
|
75
85
|
await provider.deleteCampaign(id)
|
|
76
86
|
await get().load()
|
|
77
87
|
},
|
|
88
|
+
|
|
89
|
+
async saveLandingPage(input) {
|
|
90
|
+
const saved = await provider.saveLandingPage(input)
|
|
91
|
+
provider.invalidateCache?.()
|
|
92
|
+
await get().load()
|
|
93
|
+
return saved
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async deleteLandingPage(id) {
|
|
97
|
+
await provider.deleteLandingPage(id)
|
|
98
|
+
provider.invalidateCache?.()
|
|
99
|
+
await get().load()
|
|
100
|
+
},
|
|
78
101
|
}))
|
|
79
102
|
}
|
package/src/types.ts
CHANGED
|
@@ -76,14 +76,53 @@ export interface FunnelStage {
|
|
|
76
76
|
color: string
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
/** A site the tenant owns — a row of public.fayz_projects, managed in
|
|
80
|
+
* Settings → Sites. `appId`/`fayzProjectId` are the deploy's ids on the
|
|
81
|
+
* hosting platform: the keys the visit-analytics call needs (app + route +
|
|
82
|
+
* period). Marketing reads these; it does not own them. */
|
|
83
|
+
export interface MarketingSite {
|
|
80
84
|
id: string
|
|
81
85
|
name: string
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
/** live site URL, e.g. 'https://espacorenovario.com.br' */
|
|
87
|
+
url?: string
|
|
88
|
+
appId?: string
|
|
89
|
+
fayzProjectId?: string
|
|
90
|
+
/** Fayz editor for this project — where a planned page gets built. */
|
|
91
|
+
editorUrl?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 'live' = the route already exists on the site. 'planned' = it does not yet;
|
|
95
|
+
* `prompt` is the brief for building it with Fayz. */
|
|
96
|
+
export type LandingPageStatus = 'live' | 'planned'
|
|
97
|
+
|
|
98
|
+
/** A landing page IS a site + a route. `leadTags` is the CRM cross: leads that
|
|
99
|
+
* come in through the page carry those tag slugs. */
|
|
100
|
+
export interface LandingPage {
|
|
101
|
+
id: string
|
|
102
|
+
siteId: string
|
|
103
|
+
name: string
|
|
104
|
+
/** path within the site, always leading-slashed, e.g. '/lp-capilar' */
|
|
105
|
+
route: string
|
|
106
|
+
leadTags: string[]
|
|
107
|
+
status: LandingPageStatus
|
|
108
|
+
/** what the page should be — only meaningful while status is 'planned' */
|
|
109
|
+
prompt?: string
|
|
110
|
+
isActive: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface LandingPagePerf extends LandingPage {
|
|
114
|
+
siteName: string
|
|
115
|
+
siteUrl?: string
|
|
116
|
+
appId?: string
|
|
117
|
+
editorUrl?: string
|
|
118
|
+
/** full public URL (site + route); the bare route when the site has no URL */
|
|
119
|
+
url: string
|
|
120
|
+
/** null while no visit source is connected — renders as "—", not as zero. */
|
|
121
|
+
visits: number | null
|
|
122
|
+
/** leads carrying `leadTag` within the queried period */
|
|
84
123
|
conversions: number
|
|
85
|
-
/** conversions / visits * 100 */
|
|
86
|
-
cvr: number
|
|
124
|
+
/** conversions / visits * 100 — null when visits are unknown */
|
|
125
|
+
cvr: number | null
|
|
87
126
|
}
|
|
88
127
|
|
|
89
128
|
export interface MarketingOverview {
|