@fayz-ai/plugin-marketing 0.2.0 → 0.8.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 (81) hide show
  1. package/README.md +2 -0
  2. package/dist/MarketingContext.d.ts +1 -0
  3. package/dist/MarketingContext.d.ts.map +1 -1
  4. package/dist/MarketingPage.d.ts +5 -1
  5. package/dist/MarketingPage.d.ts.map +1 -1
  6. package/dist/components/icons.d.ts +4 -0
  7. package/dist/components/icons.d.ts.map +1 -1
  8. package/dist/data/contentMedia.d.ts +3 -0
  9. package/dist/data/contentMedia.d.ts.map +1 -0
  10. package/dist/data/contentMock.d.ts +3 -0
  11. package/dist/data/contentMock.d.ts.map +1 -0
  12. package/dist/data/contentSupabase.d.ts +3 -0
  13. package/dist/data/contentSupabase.d.ts.map +1 -0
  14. package/dist/data/contentTypes.d.ts +111 -0
  15. package/dist/data/contentTypes.d.ts.map +1 -0
  16. package/dist/data/tables.d.ts +6 -0
  17. package/dist/data/tables.d.ts.map +1 -0
  18. package/dist/index.cjs +2813 -129
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2815 -133
  23. package/dist/index.js.map +1 -1
  24. package/dist/locales/en.d.ts.map +1 -1
  25. package/dist/locales/pt-BR.d.ts.map +1 -1
  26. package/dist/migrations/index.d.ts +9 -0
  27. package/dist/migrations/index.d.ts.map +1 -0
  28. package/dist/presets.d.ts +2 -0
  29. package/dist/presets.d.ts.map +1 -1
  30. package/dist/schema/index.d.ts +717 -0
  31. package/dist/schema/index.d.ts.map +1 -0
  32. package/dist/schema.cjs +65 -0
  33. package/dist/schema.cjs.map +1 -0
  34. package/dist/schema.js +61 -0
  35. package/dist/schema.js.map +1 -0
  36. package/dist/views/content/ContentPlannerContext.d.ts +13 -0
  37. package/dist/views/content/ContentPlannerContext.d.ts.map +1 -0
  38. package/dist/views/content/ContentView.d.ts +5 -0
  39. package/dist/views/content/ContentView.d.ts.map +1 -0
  40. package/dist/views/content/PlanBriefView.d.ts +12 -0
  41. package/dist/views/content/PlanBriefView.d.ts.map +1 -0
  42. package/dist/views/content/PostPage.d.ts +6 -0
  43. package/dist/views/content/PostPage.d.ts.map +1 -0
  44. package/dist/views/content/PostSocialPreview.d.ts +11 -0
  45. package/dist/views/content/PostSocialPreview.d.ts.map +1 -0
  46. package/dist/views/content/RecordingChecklist.d.ts +6 -0
  47. package/dist/views/content/RecordingChecklist.d.ts.map +1 -0
  48. package/dist/views/content/contentBits.d.ts +28 -0
  49. package/dist/views/content/contentBits.d.ts.map +1 -0
  50. package/dist/views/content/contentStore.d.ts +28 -0
  51. package/dist/views/content/contentStore.d.ts.map +1 -0
  52. package/dist/views/content/templates.d.ts +3 -0
  53. package/dist/views/content/templates.d.ts.map +1 -0
  54. package/package.json +17 -4
  55. package/src/MarketingContext.tsx +1 -0
  56. package/src/MarketingPage.tsx +23 -3
  57. package/src/components/icons.tsx +19 -0
  58. package/src/data/contentMedia.ts +25 -0
  59. package/src/data/contentMock.ts +253 -0
  60. package/src/data/contentSupabase.ts +263 -0
  61. package/src/data/contentTypes.ts +138 -0
  62. package/src/data/tables.ts +7 -0
  63. package/src/index.ts +64 -2
  64. package/src/locales/en.ts +95 -0
  65. package/src/locales/pt-BR.ts +95 -0
  66. package/src/migrations/000_plg_rename.sql +15 -0
  67. package/src/migrations/001_content_planner.sql +94 -0
  68. package/src/migrations/002_multi_platform.sql +25 -0
  69. package/src/migrations/003_recording_ops.sql +25 -0
  70. package/src/migrations/index.ts +177 -0
  71. package/src/presets.ts +5 -3
  72. package/src/schema/index.ts +69 -0
  73. package/src/views/content/ContentPlannerContext.tsx +13 -0
  74. package/src/views/content/ContentView.tsx +901 -0
  75. package/src/views/content/PlanBriefView.tsx +268 -0
  76. package/src/views/content/PostPage.tsx +370 -0
  77. package/src/views/content/PostSocialPreview.tsx +212 -0
  78. package/src/views/content/RecordingChecklist.tsx +145 -0
  79. package/src/views/content/contentBits.tsx +114 -0
  80. package/src/views/content/contentStore.ts +160 -0
  81. package/src/views/content/templates.ts +172 -0
@@ -0,0 +1,253 @@
1
+ import type {
2
+ ContentPlan,
3
+ ContentPlannerProvider,
4
+ ContentPost,
5
+ SocialAccount,
6
+ } from './contentTypes'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Mock content-planner provider — in-memory CRUD seeded with a realistic
10
+ // 8-week clinic plan (2 reels + 1 static per week) so the surface demos well
11
+ // without Supabase. Same shape the beauty seed uses in production.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ let counter = 0
15
+ const nextId = (prefix: string) => `${prefix}-${++counter}`
16
+
17
+ const SAMPLE_SCRIPT = `# 5 sinais de que sua queda capilar não é normal
18
+
19
+ **TEMA:** 5 SINAIS DE QUE SUA QUEDA CAPILAR NÃO É NORMAL
20
+ **Formato:** Reel · **Duração:** 45 a 60 segundos
21
+
22
+ ---
23
+
24
+ ## TAKE 1 – GANCHO (0s a 5s)
25
+
26
+ *Dra. andando até a câmera.*
27
+
28
+ **Texto na tela:** ⚠️ 5 sinais de alerta
29
+
30
+ **Fala:** "Seu cabelo está caindo e você não sabe se isso é normal? Existem sinais que merecem atenção."
31
+
32
+ ---
33
+
34
+ ## TAKE 2 – SINAL 1 (5s a 12s)
35
+
36
+ *Mostrar a Dra. segurando alguns fios de cabelo ou imagem ilustrativa.*
37
+
38
+ **Texto na tela:** Excesso de fios caindo.
39
+
40
+ **Fala:** "Primeiro sinal: você percebe muitos fios no travesseiro, no banho ou ao pentear os cabelos."
41
+
42
+ ---
43
+
44
+ ## TAKE 3 – CTA (40s a 60s)
45
+
46
+ **Fala:** "Se você se identificou com algum desses sinais, agende uma avaliação capilar."
47
+
48
+ **CTA:** Agende sua avaliação
49
+ `
50
+
51
+ const WEEK_TITLES: Array<[string, string]> = [
52
+ ['5 sinais de que sua queda capilar não é normal', 'Antes e depois de tratamento capilar'],
53
+ ['O que é DHT?', 'Tour pela clínica'],
54
+ ['3 erros que aceleram a calvície', 'Depoimento de paciente'],
55
+ ['Como funciona uma avaliação capilar', 'Mitos sobre queda de cabelo'],
56
+ ['Por que seu tratamento anterior não funcionou?', 'Ozonioterapia para gordura localizada'],
57
+ ['Diferença entre dermatologista e tricologista', 'Caso real de evolução'],
58
+ ['Alopecia feminina explicada', 'Bastidores da clínica'],
59
+ ['Quando procurar ajuda para queda capilar', 'Convite para avaliação gratuita'],
60
+ ]
61
+
62
+ function buildSeed() {
63
+ const account: SocialAccount = {
64
+ id: nextId('acc'),
65
+ name: 'Conta demo',
66
+ handle: '@clinica.demo',
67
+ platforms: ['instagram'],
68
+ isActive: true,
69
+ }
70
+ const plan: ContentPlan = {
71
+ id: nextId('plan'),
72
+ accountId: account.id,
73
+ name: 'Plano de conteúdo — 8 semanas',
74
+ status: 'active',
75
+ weeksCount: 8,
76
+ objective: 'Atrair pacientes qualificados para avaliação capilar',
77
+ tone: 'Autoridade acessível — técnica, mas em linguagem simples',
78
+ pillars: ['Educação', 'Autoridade', 'Prova social', 'Bastidores', 'Conversão'],
79
+ formats: ['reel', 'static'],
80
+ weeklyFrequency: 3,
81
+ briefMd: '## Objetivo\n\nPosicionar a especialista como referência em saúde capilar e gerar agendamentos de avaliação.\n\n## Estratégia\n\n- 2 reels educativos/prova social por semana\n- 1 post estático por semana\n- CTA recorrente: agendar avaliação',
82
+ }
83
+ const posts: ContentPost[] = []
84
+ WEEK_TITLES.forEach(([reel1, reel2], i) => {
85
+ const week = i + 1
86
+ posts.push(
87
+ {
88
+ id: nextId('post'), planId: plan.id, weekNumber: week, position: 0,
89
+ title: reel1, format: 'reel', status: week === 1 ? 'script' : 'idea',
90
+ platforms: [], checklist: [], contentMd: week === 1 ? SAMPLE_SCRIPT : '',
91
+ },
92
+ {
93
+ id: nextId('post'), planId: plan.id, weekNumber: week, position: 1,
94
+ title: reel2, format: 'reel', status: 'idea', platforms: [], checklist: [], contentMd: '',
95
+ },
96
+ {
97
+ id: nextId('post'), planId: plan.id, weekNumber: week, position: 2,
98
+ title: 'Estático', format: 'static', status: 'idea', platforms: [], checklist: [], contentMd: '',
99
+ },
100
+ )
101
+ })
102
+ return { accounts: [account], plans: [plan], posts }
103
+ }
104
+
105
+ export function createMockContentPlannerProvider(): ContentPlannerProvider {
106
+ const db = buildSeed()
107
+
108
+ return {
109
+ async listAccounts() {
110
+ return [...db.accounts]
111
+ },
112
+
113
+ async saveAccount(input) {
114
+ if (input.id) {
115
+ const acc = db.accounts.find((a) => a.id === input.id)
116
+ if (!acc) throw new Error('Account not found')
117
+ Object.assign(acc, {
118
+ name: input.name ?? acc.name,
119
+ handle: input.handle ?? acc.handle,
120
+ platforms: input.platforms ?? acc.platforms,
121
+ isActive: input.isActive ?? acc.isActive,
122
+ })
123
+ return { ...acc }
124
+ }
125
+ const acc: SocialAccount = {
126
+ id: nextId('acc'),
127
+ name: input.name,
128
+ handle: input.handle,
129
+ platforms: input.platforms ?? ['instagram'],
130
+ isActive: input.isActive ?? true,
131
+ }
132
+ db.accounts.push(acc)
133
+ return { ...acc }
134
+ },
135
+
136
+ async deleteAccount(id) {
137
+ db.accounts = db.accounts.filter((a) => a.id !== id)
138
+ const planIds = new Set(db.plans.filter((p) => p.accountId === id).map((p) => p.id))
139
+ db.plans = db.plans.filter((p) => p.accountId !== id)
140
+ db.posts = db.posts.filter((p) => !planIds.has(p.planId))
141
+ },
142
+
143
+ async listPlans(accountId) {
144
+ return db.plans.filter((p) => p.accountId === accountId).map((p) => ({ ...p }))
145
+ },
146
+
147
+ async getPlan(id) {
148
+ const plan = db.plans.find((p) => p.id === id)
149
+ return plan ? { ...plan } : null
150
+ },
151
+
152
+ async savePlan(input) {
153
+ if (input.id) {
154
+ const plan = db.plans.find((p) => p.id === input.id)
155
+ if (!plan) throw new Error('Plan not found')
156
+ Object.assign(plan, {
157
+ ...(input.name !== undefined && { name: input.name }),
158
+ ...(input.status !== undefined && { status: input.status }),
159
+ ...(input.weeksCount !== undefined && { weeksCount: input.weeksCount }),
160
+ ...(input.startDate !== undefined && { startDate: input.startDate ?? undefined }),
161
+ ...(input.objective !== undefined && { objective: input.objective }),
162
+ ...(input.tone !== undefined && { tone: input.tone }),
163
+ ...(input.pillars !== undefined && { pillars: input.pillars }),
164
+ ...(input.formats !== undefined && { formats: input.formats }),
165
+ ...(input.weeklyFrequency !== undefined && { weeklyFrequency: input.weeklyFrequency }),
166
+ ...(input.briefMd !== undefined && { briefMd: input.briefMd }),
167
+ })
168
+ return { ...plan }
169
+ }
170
+ if (!input.accountId) throw new Error('accountId is required')
171
+ const plan: ContentPlan = {
172
+ id: nextId('plan'),
173
+ accountId: input.accountId,
174
+ name: input.name ?? 'Novo plano',
175
+ status: input.status ?? 'draft',
176
+ weeksCount: input.weeksCount ?? 4,
177
+ startDate: input.startDate ?? undefined,
178
+ objective: input.objective,
179
+ tone: input.tone,
180
+ pillars: input.pillars ?? [],
181
+ formats: input.formats ?? ['reel', 'static'],
182
+ weeklyFrequency: input.weeklyFrequency ?? 3,
183
+ briefMd: input.briefMd ?? '',
184
+ }
185
+ db.plans.push(plan)
186
+ return { ...plan }
187
+ },
188
+
189
+ async deletePlan(id) {
190
+ db.plans = db.plans.filter((p) => p.id !== id)
191
+ db.posts = db.posts.filter((p) => p.planId !== id)
192
+ },
193
+
194
+ async listPosts(planId) {
195
+ return db.posts
196
+ .filter((p) => p.planId === planId)
197
+ .sort((a, b) => a.weekNumber - b.weekNumber || a.position - b.position)
198
+ .map((p) => ({ ...p }))
199
+ },
200
+
201
+ async getPost(id) {
202
+ const post = db.posts.find((p) => p.id === id)
203
+ return post ? { ...post } : null
204
+ },
205
+
206
+ async savePost(input) {
207
+ if (input.id) {
208
+ const post = db.posts.find((p) => p.id === input.id)
209
+ if (!post) throw new Error('Post not found')
210
+ Object.assign(post, {
211
+ ...(input.weekNumber !== undefined && { weekNumber: input.weekNumber }),
212
+ ...(input.position !== undefined && { position: input.position }),
213
+ ...(input.title !== undefined && { title: input.title }),
214
+ ...(input.format !== undefined && { format: input.format }),
215
+ ...(input.status !== undefined && { status: input.status }),
216
+ ...(input.scheduledDate !== undefined && { scheduledDate: input.scheduledDate ?? undefined }),
217
+ ...(input.platforms !== undefined && { platforms: input.platforms }),
218
+ ...(input.checklist !== undefined && { checklist: input.checklist }),
219
+ ...(input.mediaUrl !== undefined && { mediaUrl: input.mediaUrl ?? undefined }),
220
+ ...(input.hook !== undefined && { hook: input.hook }),
221
+ ...(input.cta !== undefined && { cta: input.cta }),
222
+ ...(input.contentMd !== undefined && { contentMd: input.contentMd }),
223
+ })
224
+ return { ...post }
225
+ }
226
+ if (!input.planId) throw new Error('planId is required')
227
+ const week = input.weekNumber ?? 1
228
+ const siblings = db.posts.filter((p) => p.planId === input.planId && p.weekNumber === week)
229
+ const post: ContentPost = {
230
+ id: nextId('post'),
231
+ planId: input.planId,
232
+ weekNumber: week,
233
+ position: input.position ?? siblings.length,
234
+ title: input.title ?? '',
235
+ format: input.format ?? 'reel',
236
+ status: input.status ?? 'idea',
237
+ scheduledDate: input.scheduledDate ?? undefined,
238
+ platforms: input.platforms ?? [],
239
+ checklist: input.checklist ?? [],
240
+ mediaUrl: input.mediaUrl ?? undefined,
241
+ hook: input.hook,
242
+ cta: input.cta,
243
+ contentMd: input.contentMd ?? '',
244
+ }
245
+ db.posts.push(post)
246
+ return { ...post }
247
+ },
248
+
249
+ async deletePost(id) {
250
+ db.posts = db.posts.filter((p) => p.id !== id)
251
+ },
252
+ }
253
+ }
@@ -0,0 +1,263 @@
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
+ }
@@ -0,0 +1,138 @@
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
+ }
@@ -0,0 +1,7 @@
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
+ } as const