@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.
Files changed (100) hide show
  1. package/README.md +3 -3
  2. package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/data/mock.d.ts +1 -1
  5. package/dist/data/mock.d.ts.map +1 -1
  6. package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
  7. package/dist/data/registries.d.ts.map +1 -0
  8. package/dist/index.d.ts +6 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +241 -188
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/ranklayer/connectorDef.d.ts +1 -1
  13. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
  14. package/dist/integrations/ranklayer/data/supabase.d.ts +24 -6
  15. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -1
  16. package/dist/integrations/ranklayer/outbound.d.ts +16 -0
  17. package/dist/integrations/ranklayer/outbound.d.ts.map +1 -0
  18. package/dist/integrations/ranklayer/settings.d.ts +12 -0
  19. package/dist/integrations/ranklayer/settings.d.ts.map +1 -0
  20. package/dist/integrations/ranklayer/types.d.ts +0 -1
  21. package/dist/integrations/ranklayer/types.d.ts.map +1 -1
  22. package/dist/lib/format.d.ts.map +1 -0
  23. package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
  24. package/dist/lib/presets.d.ts.map +1 -0
  25. package/dist/migrations/index.d.ts +1 -1
  26. package/dist/migrations/index.d.ts.map +1 -1
  27. package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
  28. package/dist/views/MarketingPage.d.ts.map +1 -0
  29. package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
  30. package/dist/views/content/ContentPlannerContext.d.ts +1 -1
  31. package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
  32. package/dist/views/dashboardWidgets.d.ts +1 -1
  33. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  34. package/package.json +6 -9
  35. package/dist/MarketingContext.d.ts.map +0 -1
  36. package/dist/MarketingPage.d.ts.map +0 -1
  37. package/dist/format.d.ts.map +0 -1
  38. package/dist/presets.d.ts.map +0 -1
  39. package/dist/registries.d.ts.map +0 -1
  40. package/src/MarketingContext.tsx +0 -42
  41. package/src/MarketingPage.tsx +0 -139
  42. package/src/components/MarketingBits.tsx +0 -131
  43. package/src/components/icons.tsx +0 -36
  44. package/src/data/blogEntities.tsx +0 -91
  45. package/src/data/capability.test.ts +0 -92
  46. package/src/data/contentMedia.ts +0 -25
  47. package/src/data/contentMock.ts +0 -253
  48. package/src/data/contentSupabase.ts +0 -263
  49. package/src/data/contentTypes.ts +0 -138
  50. package/src/data/mock.ts +0 -308
  51. package/src/data/supabase.ts +0 -613
  52. package/src/data/tables.ts +0 -33
  53. package/src/data/types.ts +0 -97
  54. package/src/format.ts +0 -40
  55. package/src/index.ts +0 -457
  56. package/src/integrations/ranklayer/RANKLAYER.md +0 -40
  57. package/src/integrations/ranklayer/connectorDef.tsx +0 -102
  58. package/src/integrations/ranklayer/data/supabase.ts +0 -106
  59. package/src/integrations/ranklayer/types.ts +0 -16
  60. package/src/locales/en.ts +0 -192
  61. package/src/locales/index.ts +0 -7
  62. package/src/locales/pt-BR.ts +0 -192
  63. package/src/migrations/000_plg_rename.sql +0 -15
  64. package/src/migrations/001_content_planner.sql +0 -94
  65. package/src/migrations/002_multi_platform.sql +0 -25
  66. package/src/migrations/003_recording_ops.sql +0 -25
  67. package/src/migrations/004_ranklayer.sql +0 -50
  68. package/src/migrations/005_analytics_base.sql +0 -60
  69. package/src/migrations/006_analytics_views.sql +0 -68
  70. package/src/migrations/007_agent_rpcs.sql +0 -119
  71. package/src/migrations/008_landing_pages.sql +0 -112
  72. package/src/migrations/index.ts +0 -601
  73. package/src/presets.ts +0 -119
  74. package/src/registries.ts +0 -62
  75. package/src/schema/index.ts +0 -106
  76. package/src/store.ts +0 -102
  77. package/src/types.ts +0 -153
  78. package/src/views/CampaignComposer.tsx +0 -89
  79. package/src/views/CampaignsView.tsx +0 -59
  80. package/src/views/ChannelDetailView.tsx +0 -59
  81. package/src/views/ChannelsView.tsx +0 -46
  82. package/src/views/FunnelView.tsx +0 -56
  83. package/src/views/LandingPageComposer.tsx +0 -201
  84. package/src/views/LandingPagesView.tsx +0 -172
  85. package/src/views/OverviewView.tsx +0 -24
  86. package/src/views/SettingsView.tsx +0 -72
  87. package/src/views/blog/BlogAdminView.tsx +0 -99
  88. package/src/views/blog/BlogEntityForm.tsx +0 -101
  89. package/src/views/blog/useEntityCrud.ts +0 -50
  90. package/src/views/content/ContentPlannerContext.tsx +0 -13
  91. package/src/views/content/ContentView.tsx +0 -908
  92. package/src/views/content/PlanBriefView.tsx +0 -268
  93. package/src/views/content/PostPage.tsx +0 -370
  94. package/src/views/content/PostSocialPreview.tsx +0 -212
  95. package/src/views/content/RecordingChecklist.tsx +0 -145
  96. package/src/views/content/contentBits.tsx +0 -114
  97. package/src/views/content/contentStore.ts +0 -160
  98. package/src/views/content/templates.ts +0 -172
  99. package/src/views/dashboardWidgets.tsx +0 -173
  100. /package/dist/{format.d.ts → lib/format.d.ts} +0 -0
@@ -1,263 +0,0 @@
1
- import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
- import type {
3
- ContentPlan,
4
- ContentPlannerProvider,
5
- ContentPost,
6
- SocialAccount,
7
- } from './contentTypes'
8
- import { T } from './tables'
9
-
10
- // ---------------------------------------------------------------------------
11
- // Supabase content-planner provider. Tables: plg_marketing_social_accounts,
12
- // plg_marketing_content_plans, plg_marketing_content_posts (see src/migrations/001_content_planner.sql).
13
- // RLS scopes rows to the user's tenants; we still filter tenant_id explicitly
14
- // on reads so multi-tenant sessions see only the active tenant.
15
- // ---------------------------------------------------------------------------
16
-
17
- function getClient() {
18
- const supabase = getSupabaseClientOptional() as any
19
- if (!supabase) throw new Error('Supabase not initialized')
20
- return supabase
21
- }
22
-
23
- function requireTenantId(): string {
24
- const tenantId = getActiveTenantId()
25
- if (!tenantId) throw new Error('No active tenant')
26
- return tenantId
27
- }
28
-
29
- function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
30
- const result: Record<string, unknown> = {}
31
- for (const [key, value] of Object.entries(obj)) {
32
- result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value ?? undefined
33
- }
34
- return result
35
- }
36
-
37
- function toAccount(row: Record<string, unknown>): SocialAccount {
38
- const account = snakeToCamel(row) as unknown as SocialAccount
39
- if (!Array.isArray(account.platforms)) account.platforms = []
40
- return account
41
- }
42
-
43
- function toPlan(row: Record<string, unknown>): ContentPlan {
44
- const plan = snakeToCamel(row) as unknown as ContentPlan
45
- if (!Array.isArray(plan.pillars)) plan.pillars = []
46
- if (!Array.isArray(plan.formats)) plan.formats = []
47
- plan.briefMd = plan.briefMd ?? ''
48
- return plan
49
- }
50
-
51
- function toPost(row: Record<string, unknown>): ContentPost {
52
- const post = snakeToCamel(row) as unknown as ContentPost
53
- post.contentMd = post.contentMd ?? ''
54
- if (!Array.isArray(post.platforms)) post.platforms = []
55
- if (!Array.isArray(post.checklist)) post.checklist = []
56
- return post
57
- }
58
-
59
- export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
60
- return {
61
- async listAccounts() {
62
- const tenantId = getActiveTenantId()
63
- if (!tenantId) return []
64
- const { data } = await getClient()
65
- .from(T.socialAccounts)
66
- .select('*')
67
- .eq('tenant_id', tenantId)
68
- .eq('is_active', true)
69
- .order('created_at', { ascending: true })
70
- return (data ?? []).map(toAccount)
71
- },
72
-
73
- async saveAccount(input) {
74
- if (input.id) {
75
- const payload: Record<string, unknown> = {}
76
- if (input.name !== undefined) payload.name = input.name
77
- if (input.handle !== undefined) payload.handle = input.handle
78
- if (input.platforms !== undefined) payload.platforms = input.platforms
79
- if (input.isActive !== undefined) payload.is_active = input.isActive
80
- const { data, error } = await getClient()
81
- .from(T.socialAccounts)
82
- .update(payload)
83
- .eq('id', input.id)
84
- .select('*')
85
- .single()
86
- if (error) throw error
87
- return toAccount(data)
88
- }
89
- const { data, error } = await getClient()
90
- .from(T.socialAccounts)
91
- .insert({
92
- tenant_id: requireTenantId(),
93
- name: input.name,
94
- handle: input.handle ?? null,
95
- platforms: input.platforms ?? ['instagram'],
96
- is_active: input.isActive ?? true,
97
- })
98
- .select('*')
99
- .single()
100
- if (error) throw error
101
- return toAccount(data)
102
- },
103
-
104
- async deleteAccount(id) {
105
- const { error } = await getClient().from(T.socialAccounts).delete().eq('id', id)
106
- if (error) throw error
107
- },
108
-
109
- async listPlans(accountId) {
110
- const tenantId = getActiveTenantId()
111
- if (!tenantId) return []
112
- const { data } = await getClient()
113
- .from(T.contentPlans)
114
- .select('*')
115
- .eq('tenant_id', tenantId)
116
- .eq('account_id', accountId)
117
- .order('created_at', { ascending: false })
118
- return (data ?? []).map(toPlan)
119
- },
120
-
121
- async getPlan(id) {
122
- const { data } = await getClient()
123
- .from(T.contentPlans)
124
- .select('*')
125
- .eq('id', id)
126
- .maybeSingle()
127
- return data ? toPlan(data) : null
128
- },
129
-
130
- async savePlan(input) {
131
- if (input.id) {
132
- const payload: Record<string, unknown> = { updated_at: new Date().toISOString() }
133
- if (input.name !== undefined) payload.name = input.name
134
- if (input.status !== undefined) payload.status = input.status
135
- if (input.weeksCount !== undefined) payload.weeks_count = input.weeksCount
136
- if (input.startDate !== undefined) payload.start_date = input.startDate
137
- if (input.objective !== undefined) payload.objective = input.objective
138
- if (input.tone !== undefined) payload.tone = input.tone
139
- if (input.pillars !== undefined) payload.pillars = input.pillars
140
- if (input.formats !== undefined) payload.formats = input.formats
141
- if (input.weeklyFrequency !== undefined) payload.weekly_frequency = input.weeklyFrequency
142
- if (input.briefMd !== undefined) payload.brief_md = input.briefMd
143
- const { data, error } = await getClient()
144
- .from(T.contentPlans)
145
- .update(payload)
146
- .eq('id', input.id)
147
- .select('*')
148
- .single()
149
- if (error) throw error
150
- return toPlan(data)
151
- }
152
- if (!input.accountId) throw new Error('accountId is required')
153
- const { data, error } = await getClient()
154
- .from(T.contentPlans)
155
- .insert({
156
- tenant_id: requireTenantId(),
157
- account_id: input.accountId,
158
- name: input.name ?? 'Novo plano',
159
- status: input.status ?? 'draft',
160
- weeks_count: input.weeksCount ?? 4,
161
- start_date: input.startDate ?? null,
162
- objective: input.objective ?? null,
163
- tone: input.tone ?? null,
164
- pillars: input.pillars ?? [],
165
- formats: input.formats ?? ['reel', 'static'],
166
- weekly_frequency: input.weeklyFrequency ?? 3,
167
- brief_md: input.briefMd ?? '',
168
- })
169
- .select('*')
170
- .single()
171
- if (error) throw error
172
- return toPlan(data)
173
- },
174
-
175
- async deletePlan(id) {
176
- await getClient().from(T.contentPlans).delete().eq('id', id)
177
- },
178
-
179
- async listPosts(planId) {
180
- const { data } = await getClient()
181
- .from(T.contentPosts)
182
- .select('*')
183
- .eq('plan_id', planId)
184
- .order('week_number', { ascending: true })
185
- .order('position', { ascending: true })
186
- return (data ?? []).map(toPost)
187
- },
188
-
189
- async getPost(id) {
190
- const { data } = await getClient()
191
- .from(T.contentPosts)
192
- .select('*')
193
- .eq('id', id)
194
- .maybeSingle()
195
- return data ? toPost(data) : null
196
- },
197
-
198
- async savePost(input) {
199
- if (input.id) {
200
- const payload: Record<string, unknown> = { updated_at: new Date().toISOString() }
201
- if (input.weekNumber !== undefined) payload.week_number = input.weekNumber
202
- if (input.position !== undefined) payload.position = input.position
203
- if (input.title !== undefined) payload.title = input.title
204
- if (input.format !== undefined) payload.format = input.format
205
- if (input.status !== undefined) payload.status = input.status
206
- if (input.scheduledDate !== undefined) payload.scheduled_date = input.scheduledDate
207
- if (input.platforms !== undefined) payload.platforms = input.platforms
208
- if (input.checklist !== undefined) payload.checklist = input.checklist
209
- if (input.mediaUrl !== undefined) payload.media_url = input.mediaUrl
210
- if (input.hook !== undefined) payload.hook = input.hook
211
- if (input.cta !== undefined) payload.cta = input.cta
212
- if (input.contentMd !== undefined) payload.content_md = input.contentMd
213
- const { data, error } = await getClient()
214
- .from(T.contentPosts)
215
- .update(payload)
216
- .eq('id', input.id)
217
- .select('*')
218
- .single()
219
- if (error) throw error
220
- return toPost(data)
221
- }
222
- if (!input.planId) throw new Error('planId is required')
223
- const week = input.weekNumber ?? 1
224
-
225
- // Next position within the (plan, week) group.
226
- const { data: maxData } = await getClient()
227
- .from(T.contentPosts)
228
- .select('position')
229
- .eq('plan_id', input.planId)
230
- .eq('week_number', week)
231
- .order('position', { ascending: false })
232
- .limit(1)
233
- const maxPos = maxData?.[0]?.position ?? -1
234
-
235
- const { data, error } = await getClient()
236
- .from(T.contentPosts)
237
- .insert({
238
- tenant_id: requireTenantId(),
239
- plan_id: input.planId,
240
- week_number: week,
241
- position: input.position ?? maxPos + 1,
242
- title: input.title ?? '',
243
- format: input.format ?? 'reel',
244
- status: input.status ?? 'idea',
245
- scheduled_date: input.scheduledDate ?? null,
246
- platforms: input.platforms ?? [],
247
- checklist: input.checklist ?? [],
248
- media_url: input.mediaUrl ?? null,
249
- hook: input.hook ?? null,
250
- cta: input.cta ?? null,
251
- content_md: input.contentMd ?? '',
252
- })
253
- .select('*')
254
- .single()
255
- if (error) throw error
256
- return toPost(data)
257
- },
258
-
259
- async deletePost(id) {
260
- await getClient().from(T.contentPosts).delete().eq('id', id)
261
- },
262
- }
263
- }
@@ -1,138 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Content planner — domain types + provider seam. Deliberately separate from
3
- // MarketingDataProvider: analytics is mock-until-bridges, while the planner is
4
- // a real CRUD surface (Supabase-backed via createSafeDataProvider). The
5
- // interface must stay methods-only (the safe-provider Proxy turns every
6
- // property access into a call).
7
- // ---------------------------------------------------------------------------
8
-
9
- export type PostFormat = 'reel' | 'static' | 'carousel' | 'story' | 'video' | 'live' | 'article'
10
-
11
- /** Platforms an account can publish to. UI-driven list (the DB stores plain
12
- * text[]), so growing it is a one-line change here. */
13
- export const PLATFORMS = [
14
- 'instagram',
15
- 'tiktok',
16
- 'youtube',
17
- 'facebook',
18
- 'linkedin',
19
- 'x',
20
- 'pinterest',
21
- 'threads',
22
- ] as const
23
- export type Platform = (typeof PLATFORMS)[number]
24
-
25
- export type PostStatus = 'idea' | 'script' | 'recording' | 'editing' | 'scheduled' | 'published'
26
-
27
- export const POST_STATUS_ORDER: PostStatus[] = ['idea', 'script', 'recording', 'editing', 'scheduled', 'published']
28
-
29
- export interface SocialAccount {
30
- id: string
31
- name: string
32
- handle?: string
33
- /** Platforms this account/brand publishes to (one account, many connections). */
34
- platforms: string[]
35
- isActive: boolean
36
- }
37
-
38
- export interface ContentPlan {
39
- id: string
40
- accountId: string
41
- name: string
42
- status: 'draft' | 'active' | 'archived'
43
- weeksCount: number
44
- startDate?: string
45
- /** Structured "what we want" config — feeds chips + future AI drafting. */
46
- objective?: string
47
- tone?: string
48
- pillars: string[]
49
- formats: PostFormat[]
50
- weeklyFrequency: number
51
- /** Free-form brief/strategy document (markdown). */
52
- briefMd: string
53
- }
54
-
55
- /** Recording-day checklist item (on-set companion). */
56
- export interface ChecklistItem {
57
- id: string
58
- text: string
59
- done: boolean
60
- }
61
-
62
- export interface ContentPost {
63
- id: string
64
- planId: string
65
- weekNumber: number
66
- position: number
67
- title: string
68
- format: PostFormat
69
- status: PostStatus
70
- scheduledDate?: string
71
- /** Target platforms for this post; empty = inherit the account's platforms. */
72
- platforms: string[]
73
- /** Recording-day checklist — persisted as the crew checks items off. */
74
- checklist: ChecklistItem[]
75
- /** Uploaded asset URL (static posts: caption + final art instead of a script). */
76
- mediaUrl?: string
77
- hook?: string
78
- cta?: string
79
- /** The Notion-style page body — the script/plan in markdown. */
80
- contentMd: string
81
- updatedAt?: string
82
- }
83
-
84
- export interface SaveSocialAccountInput {
85
- id?: string
86
- name: string
87
- handle?: string
88
- platforms?: string[]
89
- isActive?: boolean
90
- }
91
-
92
- export interface SaveContentPlanInput {
93
- id?: string
94
- accountId?: string
95
- name?: string
96
- status?: ContentPlan['status']
97
- weeksCount?: number
98
- startDate?: string | null
99
- objective?: string
100
- tone?: string
101
- pillars?: string[]
102
- formats?: PostFormat[]
103
- weeklyFrequency?: number
104
- briefMd?: string
105
- }
106
-
107
- export interface SaveContentPostInput {
108
- id?: string
109
- planId?: string
110
- weekNumber?: number
111
- position?: number
112
- title?: string
113
- format?: PostFormat
114
- status?: PostStatus
115
- scheduledDate?: string | null
116
- platforms?: string[]
117
- checklist?: ChecklistItem[]
118
- mediaUrl?: string | null
119
- hook?: string
120
- cta?: string
121
- contentMd?: string
122
- }
123
-
124
- export interface ContentPlannerProvider {
125
- listAccounts(): Promise<SocialAccount[]>
126
- saveAccount(input: SaveSocialAccountInput): Promise<SocialAccount>
127
- /** Hard delete — plans and posts cascade with the account. */
128
- deleteAccount(id: string): Promise<void>
129
- listPlans(accountId: string): Promise<ContentPlan[]>
130
- getPlan(id: string): Promise<ContentPlan | null>
131
- savePlan(input: SaveContentPlanInput): Promise<ContentPlan>
132
- deletePlan(id: string): Promise<void>
133
- /** Ordered by week_number then position. */
134
- listPosts(planId: string): Promise<ContentPost[]>
135
- getPost(id: string): Promise<ContentPost | null>
136
- savePost(input: SaveContentPostInput): Promise<ContentPost>
137
- deletePost(id: string): Promise<void>
138
- }
package/src/data/mock.ts DELETED
@@ -1,308 +0,0 @@
1
- import type { MarketingDataProvider, SaveCampaignInput, SaveLandingPageInput } from './types'
2
- import type {
3
- AcquisitionChannel,
4
- AcquisitionChannelKind,
5
- Campaign,
6
- ChannelPerformance,
7
- ConversionModel,
8
- DateRangeKey,
9
- FunnelStage,
10
- LandingPage,
11
- LandingPagePerf,
12
- MarketingOverview,
13
- MarketingQuery,
14
- MarketingSite,
15
- } from '../types'
16
- import { CAMPAIGN_NAME_SEEDS, type MarketingDomain } from '../presets'
17
-
18
- // ---------------------------------------------------------------------------
19
- // Deterministic seed knobs (no Date.now at module init for stable analytics).
20
- // ---------------------------------------------------------------------------
21
-
22
- const RANGE_FACTOR: Record<DateRangeKey, number> = { '7d': 0.25, '30d': 1, '90d': 3.05 }
23
- const BASE_REACH = [4200, 3100, 2600, 1900, 1500, 1200, 1000]
24
-
25
- const KIND_LEAD_RATE: Record<AcquisitionChannelKind, number> = {
26
- paid: 0.18, social: 0.22, organic: 0.14, referral: 0.3, direct: 0.1, outbound: 0.16,
27
- }
28
- const KIND_CONV_RATE: Record<AcquisitionChannelKind, number> = {
29
- paid: 0.28, social: 0.25, organic: 0.32, referral: 0.45, direct: 0.22, outbound: 0.24,
30
- }
31
- const KIND_SPEND30: Record<AcquisitionChannelKind, number> = {
32
- paid: 2800, social: 900, outbound: 600, organic: 0, referral: 0, direct: 0,
33
- }
34
-
35
- interface ChannelBase {
36
- channelId: string
37
- kind: AcquisitionChannelKind
38
- reach: number
39
- leads: number
40
- conversions: number
41
- spend: number
42
- value: number
43
- }
44
-
45
- export interface MockMarketingConfig {
46
- channels: AcquisitionChannel[]
47
- conversion: ConversionModel
48
- domain: MarketingDomain
49
- }
50
-
51
- function channelBase30(channels: AcquisitionChannel[], conversion: ConversionModel): ChannelBase[] {
52
- return channels.map((ch, i) => {
53
- const reach = BASE_REACH[i % BASE_REACH.length]
54
- const leads = Math.round(reach * KIND_LEAD_RATE[ch.kind])
55
- const conversions = Math.round(leads * KIND_CONV_RATE[ch.kind])
56
- const spend = KIND_SPEND30[ch.kind]
57
- const value = Math.round(conversions * conversion.avgValue)
58
- return { channelId: ch.id, kind: ch.kind, reach, leads, conversions, spend, value }
59
- })
60
- }
61
-
62
- function scale(n: number, range: DateRangeKey): number {
63
- return Math.round(n * RANGE_FACTOR[range])
64
- }
65
-
66
- function toPerformance(base: ChannelBase, range: DateRangeKey): ChannelPerformance {
67
- const reach = scale(base.reach, range)
68
- const leads = scale(base.leads, range)
69
- const conversions = scale(base.conversions, range)
70
- const spend = scale(base.spend, range)
71
- const value = scale(base.value, range)
72
- return {
73
- channelId: base.channelId,
74
- reach, leads, conversions, spend, value,
75
- cvr: reach > 0 ? (conversions / reach) * 100 : 0,
76
- cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
77
- }
78
- }
79
-
80
- function seedCampaigns(cfg: MockMarketingConfig): Campaign[] {
81
- const names = CAMPAIGN_NAME_SEEDS[cfg.domain]
82
- const base = channelBase30(cfg.channels, cfg.conversion)
83
- const statuses: Campaign['status'][] = ['active', 'active', 'paused', 'ended', 'draft']
84
- const startDays = [12, 26, 40, 8, 3]
85
- const baseDate = new Date('2026-06-16T00:00:00Z').getTime()
86
-
87
- return names.map((name, i) => {
88
- const ch = cfg.channels[i % cfg.channels.length]
89
- const b = base[i % base.length]
90
- const status = statuses[i % statuses.length]
91
- const draft = status === 'draft'
92
- const start = new Date(baseDate - startDays[i % startDays.length] * 86_400_000).toISOString()
93
- // Campaigns carry a slice of their channel's volume so totals stay plausible.
94
- const share = 0.55 + (i % 3) * 0.12
95
- return {
96
- id: `cmp-${i + 1}`,
97
- name,
98
- channelId: ch.id,
99
- status,
100
- start,
101
- end: status === 'ended' ? new Date(baseDate - 2 * 86_400_000).toISOString() : undefined,
102
- spend: draft ? 0 : Math.round(b.spend * share),
103
- reach: draft ? 0 : Math.round(b.reach * share),
104
- leads: draft ? 0 : Math.round(b.leads * share),
105
- conversions: draft ? 0 : Math.round(b.conversions * share),
106
- value: draft ? 0 : Math.round(b.value * share),
107
- }
108
- })
109
- }
110
-
111
- function scaleCampaign(c: Campaign, range: DateRangeKey): Campaign {
112
- if (c.status === 'draft') return c
113
- return {
114
- ...c,
115
- spend: scale(c.spend, range),
116
- reach: scale(c.reach, range),
117
- leads: scale(c.leads, range),
118
- conversions: scale(c.conversions, range),
119
- value: scale(c.value, range),
120
- }
121
- }
122
-
123
- const MOCK_SITE: MarketingSite = {
124
- id: 'site-1',
125
- name: 'Site principal',
126
- url: 'https://exemplo.com.br',
127
- appId: 'app-demo',
128
- }
129
-
130
- const MOCK_LEAD_TAGS = [
131
- { slug: 'lp-consulta', name: 'LP Consulta' },
132
- { slug: 'lp-promo', name: 'LP Promo' },
133
- ]
134
-
135
- interface MockLandingPage extends LandingPage {
136
- /** 30d baseline; scaled per range at read time. */
137
- visits: number
138
- conversions: number
139
- }
140
-
141
- function seedLandingPages(): MockLandingPage[] {
142
- return [
143
- { id: 'lp-1', siteId: MOCK_SITE.id, name: 'Consulta gratuita', route: '/consulta', leadTags: ['lp-consulta'], status: 'live', isActive: true, visits: 3820, conversions: 474 },
144
- { id: 'lp-2', siteId: MOCK_SITE.id, name: 'Promo da estação', route: '/promo', leadTags: ['lp-promo'], status: 'live', isActive: true, visits: 1560, conversions: 126 },
145
- ]
146
- }
147
-
148
- function normalizeRoute(route: string): string {
149
- const trimmed = route.trim().replace(/\/+$/, '')
150
- if (!trimmed || trimmed === '/') return '/'
151
- return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
152
- }
153
-
154
- export function createMockMarketingProvider(cfg: MockMarketingConfig): MarketingDataProvider {
155
- const base = channelBase30(cfg.channels, cfg.conversion)
156
- const campaigns = seedCampaigns(cfg)
157
- const landing = seedLandingPages()
158
- let counter = 100
159
-
160
- async function channelPerformance(query: MarketingQuery): Promise<ChannelPerformance[]> {
161
- const list = base.map((b) => toPerformance(b, query.range))
162
- return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list
163
- }
164
-
165
- return {
166
- channelPerformance,
167
-
168
- async overview(query: MarketingQuery): Promise<MarketingOverview> {
169
- const perf = await channelPerformance(query)
170
- const reach = perf.reduce((s, c) => s + c.reach, 0)
171
- const conversions = perf.reduce((s, c) => s + c.conversions, 0)
172
- const spend = perf.reduce((s, c) => s + c.spend, 0)
173
- const value = perf.reduce((s, c) => s + c.value, 0)
174
- const cvr = reach > 0 ? (conversions / reach) * 100 : 0
175
- const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0]
176
- return {
177
- conversions,
178
- conversionsPrev: Math.round(conversions * 0.86),
179
- cvr,
180
- cvrPrev: cvr * 0.94,
181
- spend,
182
- cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
183
- value,
184
- topChannelId: top?.channelId ?? null,
185
- channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions })),
186
- }
187
- },
188
-
189
- async funnel(query: MarketingQuery): Promise<FunnelStage[]> {
190
- const perf = await channelPerformance(query)
191
- const reach = perf.reduce((s, c) => s + c.reach, 0)
192
- const leads = perf.reduce((s, c) => s + c.leads, 0)
193
- const conversions = perf.reduce((s, c) => s + c.conversions, 0)
194
- const qualified = Math.round(leads * 0.62)
195
- return [
196
- { id: 'reach', label: 'Reach / visits', count: reach, color: '#6366f1' },
197
- { id: 'leads', label: 'Leads', count: leads, color: '#0ea5e9' },
198
- { id: 'qualified', label: 'Qualified', count: qualified, color: '#14b8a6' },
199
- { id: 'converted', label: cfg.conversion.labelPlural, count: conversions, color: '#22c55e' },
200
- ]
201
- },
202
-
203
- async listCampaigns(query: MarketingQuery): Promise<Campaign[]> {
204
- const list = query.channelId ? campaigns.filter((c) => c.channelId === query.channelId) : campaigns
205
- return list.map((c) => scaleCampaign(c, query.range))
206
- },
207
-
208
- async getCampaign(id: string): Promise<Campaign | null> {
209
- return campaigns.find((c) => c.id === id) ?? null
210
- },
211
-
212
- async saveCampaign(input: SaveCampaignInput): Promise<Campaign> {
213
- if (input.id) {
214
- const existing = campaigns.find((c) => c.id === input.id)
215
- if (existing) {
216
- Object.assign(existing, {
217
- name: input.name, channelId: input.channelId, status: input.status,
218
- start: input.start, end: input.end, spend: input.spend,
219
- })
220
- return existing
221
- }
222
- }
223
- const b = base.find((x) => x.channelId === input.channelId) ?? base[0]
224
- const active = input.status === 'active'
225
- const created: Campaign = {
226
- id: `cmp-${++counter}`,
227
- name: input.name,
228
- channelId: input.channelId,
229
- status: input.status,
230
- start: input.start,
231
- end: input.end,
232
- spend: input.spend,
233
- // New campaigns ramp from a small slice of their channel's volume.
234
- reach: active ? Math.round(b.reach * 0.15) : 0,
235
- leads: active ? Math.round(b.leads * 0.15) : 0,
236
- conversions: active ? Math.round(b.conversions * 0.15) : 0,
237
- value: active ? Math.round(b.value * 0.15) : 0,
238
- }
239
- campaigns.unshift(created)
240
- return created
241
- },
242
-
243
- async deleteCampaign(id: string): Promise<void> {
244
- const idx = campaigns.findIndex((c) => c.id === id)
245
- if (idx >= 0) campaigns.splice(idx, 1)
246
- },
247
-
248
- async landingPages(query: MarketingQuery): Promise<LandingPagePerf[]> {
249
- return landing.map((p) => {
250
- const visits = scale(p.visits, query.range)
251
- const conversions = scale(p.conversions, query.range)
252
- return {
253
- ...p,
254
- siteName: MOCK_SITE.name,
255
- siteUrl: MOCK_SITE.url,
256
- appId: MOCK_SITE.appId,
257
- editorUrl: MOCK_SITE.editorUrl,
258
- url: `${MOCK_SITE.url}${p.route}`,
259
- visits,
260
- conversions,
261
- cvr: visits > 0 ? (conversions / visits) * 100 : null,
262
- }
263
- })
264
- },
265
-
266
- async saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage> {
267
- const route = normalizeRoute(input.route)
268
- const existing = input.id ? landing.find((p) => p.id === input.id) : undefined
269
- if (existing) {
270
- Object.assign(existing, {
271
- siteId: input.siteId, name: input.name, route,
272
- leadTags: input.leadTags ?? existing.leadTags,
273
- status: input.status ?? existing.status,
274
- prompt: input.prompt,
275
- isActive: input.isActive ?? existing.isActive,
276
- })
277
- return existing
278
- }
279
- const created: MockLandingPage = {
280
- id: `lp-${++counter}`,
281
- siteId: input.siteId,
282
- name: input.name,
283
- route,
284
- leadTags: input.leadTags ?? [],
285
- status: input.status ?? 'live',
286
- prompt: input.prompt,
287
- isActive: input.isActive ?? true,
288
- visits: 0,
289
- conversions: 0,
290
- }
291
- landing.push(created)
292
- return created
293
- },
294
-
295
- async deleteLandingPage(id: string): Promise<void> {
296
- const idx = landing.findIndex((p) => p.id === id)
297
- if (idx >= 0) landing.splice(idx, 1)
298
- },
299
-
300
- async listSites(): Promise<MarketingSite[]> {
301
- return [MOCK_SITE]
302
- },
303
-
304
- async listLeadTags(): Promise<Array<{ slug: string; name: string }>> {
305
- return MOCK_LEAD_TAGS
306
- },
307
- }
308
- }