@fayz-ai/plugin-marketing 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/MarketingContext.d.ts +1 -0
  2. package/dist/MarketingContext.d.ts.map +1 -1
  3. package/dist/MarketingPage.d.ts +5 -1
  4. package/dist/MarketingPage.d.ts.map +1 -1
  5. package/dist/components/icons.d.ts +4 -0
  6. package/dist/components/icons.d.ts.map +1 -1
  7. package/dist/data/contentMedia.d.ts +3 -0
  8. package/dist/data/contentMedia.d.ts.map +1 -0
  9. package/dist/data/contentMock.d.ts +3 -0
  10. package/dist/data/contentMock.d.ts.map +1 -0
  11. package/dist/data/contentSupabase.d.ts +3 -0
  12. package/dist/data/contentSupabase.d.ts.map +1 -0
  13. package/dist/data/contentTypes.d.ts +111 -0
  14. package/dist/data/contentTypes.d.ts.map +1 -0
  15. package/dist/index.cjs +2780 -130
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.ts +11 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2782 -134
  20. package/dist/index.js.map +1 -1
  21. package/dist/locales/en.d.ts.map +1 -1
  22. package/dist/locales/pt-BR.d.ts.map +1 -1
  23. package/dist/migrations/index.d.ts +4 -0
  24. package/dist/migrations/index.d.ts.map +1 -0
  25. package/dist/presets.d.ts +2 -0
  26. package/dist/presets.d.ts.map +1 -1
  27. package/dist/schema/index.d.ts +717 -0
  28. package/dist/schema/index.d.ts.map +1 -0
  29. package/dist/schema.cjs +65 -0
  30. package/dist/schema.cjs.map +1 -0
  31. package/dist/schema.js +61 -0
  32. package/dist/schema.js.map +1 -0
  33. package/dist/views/content/ContentPlannerContext.d.ts +13 -0
  34. package/dist/views/content/ContentPlannerContext.d.ts.map +1 -0
  35. package/dist/views/content/ContentView.d.ts +5 -0
  36. package/dist/views/content/ContentView.d.ts.map +1 -0
  37. package/dist/views/content/PlanBriefView.d.ts +12 -0
  38. package/dist/views/content/PlanBriefView.d.ts.map +1 -0
  39. package/dist/views/content/PostPage.d.ts +6 -0
  40. package/dist/views/content/PostPage.d.ts.map +1 -0
  41. package/dist/views/content/PostSocialPreview.d.ts +11 -0
  42. package/dist/views/content/PostSocialPreview.d.ts.map +1 -0
  43. package/dist/views/content/RecordingChecklist.d.ts +6 -0
  44. package/dist/views/content/RecordingChecklist.d.ts.map +1 -0
  45. package/dist/views/content/contentBits.d.ts +28 -0
  46. package/dist/views/content/contentBits.d.ts.map +1 -0
  47. package/dist/views/content/contentStore.d.ts +28 -0
  48. package/dist/views/content/contentStore.d.ts.map +1 -0
  49. package/dist/views/content/templates.d.ts +3 -0
  50. package/dist/views/content/templates.d.ts.map +1 -0
  51. package/package.json +11 -4
  52. package/src/MarketingContext.tsx +1 -0
  53. package/src/MarketingPage.tsx +23 -3
  54. package/src/components/icons.tsx +19 -0
  55. package/src/data/contentMedia.ts +25 -0
  56. package/src/data/contentMock.ts +253 -0
  57. package/src/data/contentSupabase.ts +262 -0
  58. package/src/data/contentTypes.ts +138 -0
  59. package/src/index.ts +62 -4
  60. package/src/locales/en.ts +95 -0
  61. package/src/locales/pt-BR.ts +95 -0
  62. package/src/migrations/001_content_planner.sql +94 -0
  63. package/src/migrations/002_multi_platform.sql +25 -0
  64. package/src/migrations/003_recording_ops.sql +25 -0
  65. package/src/migrations/index.ts +152 -0
  66. package/src/presets.ts +5 -3
  67. package/src/schema/index.ts +69 -0
  68. package/src/views/content/ContentPlannerContext.tsx +13 -0
  69. package/src/views/content/ContentView.tsx +901 -0
  70. package/src/views/content/PlanBriefView.tsx +268 -0
  71. package/src/views/content/PostPage.tsx +370 -0
  72. package/src/views/content/PostSocialPreview.tsx +212 -0
  73. package/src/views/content/RecordingChecklist.tsx +145 -0
  74. package/src/views/content/contentBits.tsx +114 -0
  75. package/src/views/content/contentStore.ts +160 -0
  76. 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,262 @@
1
+ import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
+ import type {
3
+ ContentPlan,
4
+ ContentPlannerProvider,
5
+ ContentPost,
6
+ SocialAccount,
7
+ } from './contentTypes'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Supabase content-planner provider. Tables: mkt_social_accounts,
11
+ // mkt_content_plans, mkt_content_posts (see src/migrations/001_content_planner.sql).
12
+ // RLS scopes rows to the user's tenants; we still filter tenant_id explicitly
13
+ // on reads so multi-tenant sessions see only the active tenant.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function getClient() {
17
+ const supabase = getSupabaseClientOptional() as any
18
+ if (!supabase) throw new Error('Supabase not initialized')
19
+ return supabase
20
+ }
21
+
22
+ function requireTenantId(): string {
23
+ const tenantId = getActiveTenantId()
24
+ if (!tenantId) throw new Error('No active tenant')
25
+ return tenantId
26
+ }
27
+
28
+ function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
29
+ const result: Record<string, unknown> = {}
30
+ for (const [key, value] of Object.entries(obj)) {
31
+ result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value ?? undefined
32
+ }
33
+ return result
34
+ }
35
+
36
+ function toAccount(row: Record<string, unknown>): SocialAccount {
37
+ const account = snakeToCamel(row) as unknown as SocialAccount
38
+ if (!Array.isArray(account.platforms)) account.platforms = []
39
+ return account
40
+ }
41
+
42
+ function toPlan(row: Record<string, unknown>): ContentPlan {
43
+ const plan = snakeToCamel(row) as unknown as ContentPlan
44
+ if (!Array.isArray(plan.pillars)) plan.pillars = []
45
+ if (!Array.isArray(plan.formats)) plan.formats = []
46
+ plan.briefMd = plan.briefMd ?? ''
47
+ return plan
48
+ }
49
+
50
+ function toPost(row: Record<string, unknown>): ContentPost {
51
+ const post = snakeToCamel(row) as unknown as ContentPost
52
+ post.contentMd = post.contentMd ?? ''
53
+ if (!Array.isArray(post.platforms)) post.platforms = []
54
+ if (!Array.isArray(post.checklist)) post.checklist = []
55
+ return post
56
+ }
57
+
58
+ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
59
+ return {
60
+ async listAccounts() {
61
+ const tenantId = getActiveTenantId()
62
+ if (!tenantId) return []
63
+ const { data } = await getClient()
64
+ .from('mkt_social_accounts')
65
+ .select('*')
66
+ .eq('tenant_id', tenantId)
67
+ .eq('is_active', true)
68
+ .order('created_at', { ascending: true })
69
+ return (data ?? []).map(toAccount)
70
+ },
71
+
72
+ async saveAccount(input) {
73
+ if (input.id) {
74
+ const payload: Record<string, unknown> = {}
75
+ if (input.name !== undefined) payload.name = input.name
76
+ if (input.handle !== undefined) payload.handle = input.handle
77
+ if (input.platforms !== undefined) payload.platforms = input.platforms
78
+ if (input.isActive !== undefined) payload.is_active = input.isActive
79
+ const { data, error } = await getClient()
80
+ .from('mkt_social_accounts')
81
+ .update(payload)
82
+ .eq('id', input.id)
83
+ .select('*')
84
+ .single()
85
+ if (error) throw error
86
+ return toAccount(data)
87
+ }
88
+ const { data, error } = await getClient()
89
+ .from('mkt_social_accounts')
90
+ .insert({
91
+ tenant_id: requireTenantId(),
92
+ name: input.name,
93
+ handle: input.handle ?? null,
94
+ platforms: input.platforms ?? ['instagram'],
95
+ is_active: input.isActive ?? true,
96
+ })
97
+ .select('*')
98
+ .single()
99
+ if (error) throw error
100
+ return toAccount(data)
101
+ },
102
+
103
+ async deleteAccount(id) {
104
+ const { error } = await getClient().from('mkt_social_accounts').delete().eq('id', id)
105
+ if (error) throw error
106
+ },
107
+
108
+ async listPlans(accountId) {
109
+ const tenantId = getActiveTenantId()
110
+ if (!tenantId) return []
111
+ const { data } = await getClient()
112
+ .from('mkt_content_plans')
113
+ .select('*')
114
+ .eq('tenant_id', tenantId)
115
+ .eq('account_id', accountId)
116
+ .order('created_at', { ascending: false })
117
+ return (data ?? []).map(toPlan)
118
+ },
119
+
120
+ async getPlan(id) {
121
+ const { data } = await getClient()
122
+ .from('mkt_content_plans')
123
+ .select('*')
124
+ .eq('id', id)
125
+ .maybeSingle()
126
+ return data ? toPlan(data) : null
127
+ },
128
+
129
+ async savePlan(input) {
130
+ if (input.id) {
131
+ const payload: Record<string, unknown> = { updated_at: new Date().toISOString() }
132
+ if (input.name !== undefined) payload.name = input.name
133
+ if (input.status !== undefined) payload.status = input.status
134
+ if (input.weeksCount !== undefined) payload.weeks_count = input.weeksCount
135
+ if (input.startDate !== undefined) payload.start_date = input.startDate
136
+ if (input.objective !== undefined) payload.objective = input.objective
137
+ if (input.tone !== undefined) payload.tone = input.tone
138
+ if (input.pillars !== undefined) payload.pillars = input.pillars
139
+ if (input.formats !== undefined) payload.formats = input.formats
140
+ if (input.weeklyFrequency !== undefined) payload.weekly_frequency = input.weeklyFrequency
141
+ if (input.briefMd !== undefined) payload.brief_md = input.briefMd
142
+ const { data, error } = await getClient()
143
+ .from('mkt_content_plans')
144
+ .update(payload)
145
+ .eq('id', input.id)
146
+ .select('*')
147
+ .single()
148
+ if (error) throw error
149
+ return toPlan(data)
150
+ }
151
+ if (!input.accountId) throw new Error('accountId is required')
152
+ const { data, error } = await getClient()
153
+ .from('mkt_content_plans')
154
+ .insert({
155
+ tenant_id: requireTenantId(),
156
+ account_id: input.accountId,
157
+ name: input.name ?? 'Novo plano',
158
+ status: input.status ?? 'draft',
159
+ weeks_count: input.weeksCount ?? 4,
160
+ start_date: input.startDate ?? null,
161
+ objective: input.objective ?? null,
162
+ tone: input.tone ?? null,
163
+ pillars: input.pillars ?? [],
164
+ formats: input.formats ?? ['reel', 'static'],
165
+ weekly_frequency: input.weeklyFrequency ?? 3,
166
+ brief_md: input.briefMd ?? '',
167
+ })
168
+ .select('*')
169
+ .single()
170
+ if (error) throw error
171
+ return toPlan(data)
172
+ },
173
+
174
+ async deletePlan(id) {
175
+ await getClient().from('mkt_content_plans').delete().eq('id', id)
176
+ },
177
+
178
+ async listPosts(planId) {
179
+ const { data } = await getClient()
180
+ .from('mkt_content_posts')
181
+ .select('*')
182
+ .eq('plan_id', planId)
183
+ .order('week_number', { ascending: true })
184
+ .order('position', { ascending: true })
185
+ return (data ?? []).map(toPost)
186
+ },
187
+
188
+ async getPost(id) {
189
+ const { data } = await getClient()
190
+ .from('mkt_content_posts')
191
+ .select('*')
192
+ .eq('id', id)
193
+ .maybeSingle()
194
+ return data ? toPost(data) : null
195
+ },
196
+
197
+ async savePost(input) {
198
+ if (input.id) {
199
+ const payload: Record<string, unknown> = { updated_at: new Date().toISOString() }
200
+ if (input.weekNumber !== undefined) payload.week_number = input.weekNumber
201
+ if (input.position !== undefined) payload.position = input.position
202
+ if (input.title !== undefined) payload.title = input.title
203
+ if (input.format !== undefined) payload.format = input.format
204
+ if (input.status !== undefined) payload.status = input.status
205
+ if (input.scheduledDate !== undefined) payload.scheduled_date = input.scheduledDate
206
+ if (input.platforms !== undefined) payload.platforms = input.platforms
207
+ if (input.checklist !== undefined) payload.checklist = input.checklist
208
+ if (input.mediaUrl !== undefined) payload.media_url = input.mediaUrl
209
+ if (input.hook !== undefined) payload.hook = input.hook
210
+ if (input.cta !== undefined) payload.cta = input.cta
211
+ if (input.contentMd !== undefined) payload.content_md = input.contentMd
212
+ const { data, error } = await getClient()
213
+ .from('mkt_content_posts')
214
+ .update(payload)
215
+ .eq('id', input.id)
216
+ .select('*')
217
+ .single()
218
+ if (error) throw error
219
+ return toPost(data)
220
+ }
221
+ if (!input.planId) throw new Error('planId is required')
222
+ const week = input.weekNumber ?? 1
223
+
224
+ // Next position within the (plan, week) group.
225
+ const { data: maxData } = await getClient()
226
+ .from('mkt_content_posts')
227
+ .select('position')
228
+ .eq('plan_id', input.planId)
229
+ .eq('week_number', week)
230
+ .order('position', { ascending: false })
231
+ .limit(1)
232
+ const maxPos = maxData?.[0]?.position ?? -1
233
+
234
+ const { data, error } = await getClient()
235
+ .from('mkt_content_posts')
236
+ .insert({
237
+ tenant_id: requireTenantId(),
238
+ plan_id: input.planId,
239
+ week_number: week,
240
+ position: input.position ?? maxPos + 1,
241
+ title: input.title ?? '',
242
+ format: input.format ?? 'reel',
243
+ status: input.status ?? 'idea',
244
+ scheduled_date: input.scheduledDate ?? null,
245
+ platforms: input.platforms ?? [],
246
+ checklist: input.checklist ?? [],
247
+ media_url: input.mediaUrl ?? null,
248
+ hook: input.hook ?? null,
249
+ cta: input.cta ?? null,
250
+ content_md: input.contentMd ?? '',
251
+ })
252
+ .select('*')
253
+ .single()
254
+ if (error) throw error
255
+ return toPost(data)
256
+ },
257
+
258
+ async deletePost(id) {
259
+ await getClient().from('mkt_content_posts').delete().eq('id', id)
260
+ },
261
+ }
262
+ }
@@ -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
+ }