@fayz-ai/plugin-marketing 0.9.0 → 0.10.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/README.md +3 -3
- package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
- package/dist/context.d.ts.map +1 -0
- package/dist/data/mock.d.ts +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +241 -188
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
- package/dist/integrations/ranklayer/data/supabase.d.ts +24 -6
- package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -1
- package/dist/integrations/ranklayer/outbound.d.ts +16 -0
- package/dist/integrations/ranklayer/outbound.d.ts.map +1 -0
- package/dist/integrations/ranklayer/settings.d.ts +12 -0
- package/dist/integrations/ranklayer/settings.d.ts.map +1 -0
- package/dist/integrations/ranklayer/types.d.ts +0 -1
- package/dist/integrations/ranklayer/types.d.ts.map +1 -1
- package/dist/lib/format.d.ts.map +1 -0
- package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
- package/dist/lib/presets.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
- package/dist/views/MarketingPage.d.ts.map +1 -0
- package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
- package/dist/views/content/ContentPlannerContext.d.ts +1 -1
- package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
- package/dist/views/dashboardWidgets.d.ts +1 -1
- package/dist/views/dashboardWidgets.d.ts.map +1 -1
- package/package.json +6 -9
- package/dist/MarketingContext.d.ts.map +0 -1
- package/dist/MarketingPage.d.ts.map +0 -1
- package/dist/format.d.ts.map +0 -1
- package/dist/presets.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/MarketingContext.tsx +0 -42
- package/src/MarketingPage.tsx +0 -139
- package/src/components/MarketingBits.tsx +0 -131
- package/src/components/icons.tsx +0 -36
- package/src/data/blogEntities.tsx +0 -91
- package/src/data/capability.test.ts +0 -92
- package/src/data/contentMedia.ts +0 -25
- package/src/data/contentMock.ts +0 -253
- package/src/data/contentSupabase.ts +0 -263
- package/src/data/contentTypes.ts +0 -138
- package/src/data/mock.ts +0 -308
- package/src/data/supabase.ts +0 -613
- package/src/data/tables.ts +0 -33
- package/src/data/types.ts +0 -97
- package/src/format.ts +0 -40
- package/src/index.ts +0 -457
- package/src/integrations/ranklayer/RANKLAYER.md +0 -40
- package/src/integrations/ranklayer/connectorDef.tsx +0 -102
- package/src/integrations/ranklayer/data/supabase.ts +0 -106
- package/src/integrations/ranklayer/types.ts +0 -16
- package/src/locales/en.ts +0 -192
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -192
- package/src/migrations/000_plg_rename.sql +0 -15
- package/src/migrations/001_content_planner.sql +0 -94
- package/src/migrations/002_multi_platform.sql +0 -25
- package/src/migrations/003_recording_ops.sql +0 -25
- package/src/migrations/004_ranklayer.sql +0 -50
- package/src/migrations/005_analytics_base.sql +0 -60
- package/src/migrations/006_analytics_views.sql +0 -68
- package/src/migrations/007_agent_rpcs.sql +0 -119
- package/src/migrations/008_landing_pages.sql +0 -112
- package/src/migrations/index.ts +0 -601
- package/src/presets.ts +0 -119
- package/src/registries.ts +0 -62
- package/src/schema/index.ts +0 -106
- package/src/store.ts +0 -102
- package/src/types.ts +0 -153
- package/src/views/CampaignComposer.tsx +0 -89
- package/src/views/CampaignsView.tsx +0 -59
- package/src/views/ChannelDetailView.tsx +0 -59
- package/src/views/ChannelsView.tsx +0 -46
- package/src/views/FunnelView.tsx +0 -56
- package/src/views/LandingPageComposer.tsx +0 -201
- package/src/views/LandingPagesView.tsx +0 -172
- package/src/views/OverviewView.tsx +0 -24
- package/src/views/SettingsView.tsx +0 -72
- package/src/views/blog/BlogAdminView.tsx +0 -99
- package/src/views/blog/BlogEntityForm.tsx +0 -101
- package/src/views/blog/useEntityCrud.ts +0 -50
- package/src/views/content/ContentPlannerContext.tsx +0 -13
- package/src/views/content/ContentView.tsx +0 -908
- package/src/views/content/PlanBriefView.tsx +0 -268
- package/src/views/content/PostPage.tsx +0 -370
- package/src/views/content/PostSocialPreview.tsx +0 -212
- package/src/views/content/RecordingChecklist.tsx +0 -145
- package/src/views/content/contentBits.tsx +0 -114
- package/src/views/content/contentStore.ts +0 -160
- package/src/views/content/templates.ts +0 -172
- package/src/views/dashboardWidgets.tsx +0 -173
- /package/dist/{format.d.ts → lib/format.d.ts} +0 -0
package/src/data/supabase.ts
DELETED
|
@@ -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
|
-
}
|
package/src/data/tables.ts
DELETED
|
@@ -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
|