@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,131 +0,0 @@
1
- import React from 'react'
2
- import { ArrowDownRight, ArrowUpRight } from 'lucide-react'
3
- import { cn, SegmentedControl } from '@fayz-ai/ui'
4
- import { useTranslation } from '@fayz-ai/core'
5
- import type { CampaignStatus, DateRangeKey } from '../types'
6
- import { trendDelta } from '../format'
7
- import { useMarketingStore } from '../MarketingContext'
8
- import { ChannelIcon } from './icons'
9
- import { useChannelLookup } from '../MarketingContext'
10
-
11
- // ---------------------------------------------------------------------------
12
- // KPI card with optional trend chip.
13
- // ---------------------------------------------------------------------------
14
-
15
- export function KpiCard({
16
- icon: Icon, label, value, sub, current, previous, invertTrend,
17
- }: {
18
- icon: React.ComponentType<{ className?: string }>
19
- label: string
20
- value: string
21
- sub?: string
22
- current?: number
23
- previous?: number
24
- /** for cost-style metrics where down is good (e.g. CPA) */
25
- invertTrend?: boolean
26
- }) {
27
- const trend = current != null && previous != null ? trendDelta(current, previous) : null
28
- const good = trend ? (invertTrend ? trend.dir === 'down' : trend.dir === 'up') : false
29
- const bad = trend ? (invertTrend ? trend.dir === 'up' : trend.dir === 'down') : false
30
- return (
31
- <div className="rounded-card border border-border bg-card p-4">
32
- <div className="flex items-center gap-2 text-muted-foreground">
33
- <Icon className="h-4 w-4" />
34
- <span className="text-xs font-medium">{label}</span>
35
- </div>
36
- <div className="mt-2 flex items-end justify-between gap-2">
37
- <p className="text-2xl font-semibold text-foreground">{value}</p>
38
- {trend && trend.dir !== 'neutral' && (
39
- <span className={cn(
40
- 'mb-0.5 inline-flex items-center gap-0.5 text-xs font-medium',
41
- good && 'text-emerald-600', bad && 'text-rose-600',
42
- !good && !bad && 'text-muted-foreground',
43
- )}>
44
- {trend.dir === 'up' ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
45
- {trend.pct.toFixed(0)}%
46
- </span>
47
- )}
48
- </div>
49
- {sub && <p className="mt-0.5 text-xs text-muted-foreground">{sub}</p>}
50
- </div>
51
- )
52
- }
53
-
54
- // ---------------------------------------------------------------------------
55
- // Date-range selector (segmented). Reads/writes the shared store range.
56
- // ---------------------------------------------------------------------------
57
-
58
- const RANGES: DateRangeKey[] = ['7d', '30d', '90d']
59
-
60
- export function RangeTabs() {
61
- const range = useMarketingStore((s) => s.range)
62
- const setRange = useMarketingStore((s) => s.setRange)
63
- return (
64
- <SegmentedControl
65
- options={RANGES}
66
- value={range}
67
- onChange={(r) => void setRange(r)}
68
- aria-label="Time range"
69
- />
70
- )
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // Status badge for campaigns.
75
- // ---------------------------------------------------------------------------
76
-
77
- const STATUS_STYLE: Record<CampaignStatus, string> = {
78
- active: 'bg-emerald-100 text-emerald-700',
79
- paused: 'bg-amber-100 text-amber-700',
80
- ended: 'bg-muted text-muted-foreground',
81
- draft: 'bg-slate-100 text-slate-600',
82
- }
83
-
84
- export function StatusBadge({ status }: { status: CampaignStatus }) {
85
- const t = useTranslation()
86
- return (
87
- <span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', STATUS_STYLE[status])}>
88
- {t(`marketing.status.${status}`)}
89
- </span>
90
- )
91
- }
92
-
93
- // ---------------------------------------------------------------------------
94
- // Channel cell (icon + label) used in tables.
95
- // ---------------------------------------------------------------------------
96
-
97
- export function ChannelCell({ channelId }: { channelId: string }) {
98
- const lookup = useChannelLookup()
99
- const ch = lookup.get(channelId)
100
- return (
101
- <span className="inline-flex items-center gap-1.5 text-foreground">
102
- <ChannelIcon name={ch?.icon ?? 'Link2'} className="h-3.5 w-3.5 text-muted-foreground" />
103
- {ch?.label ?? channelId}
104
- </span>
105
- )
106
- }
107
-
108
- // ---------------------------------------------------------------------------
109
- // Horizontal proportion bar (funnel / channel-mix), no charting dep.
110
- // ---------------------------------------------------------------------------
111
-
112
- export function ProportionBar({ value, max, color, label, right }: {
113
- value: number
114
- max: number
115
- color: string
116
- label: React.ReactNode
117
- right?: React.ReactNode
118
- }) {
119
- const pct = max > 0 ? Math.max((value / max) * 100, 2) : 0
120
- return (
121
- <div className="flex items-center gap-3">
122
- <div className="w-32 shrink-0 text-sm text-muted-foreground">{label}</div>
123
- <div className="h-7 flex-1 overflow-hidden rounded-md bg-muted/40">
124
- <div className="flex h-full items-center rounded-md px-2 text-xs font-semibold"
125
- style={{ width: `${pct}%`, backgroundColor: color + '26', color }}>
126
- {right}
127
- </div>
128
- </div>
129
- </div>
130
- )
131
- }
@@ -1,36 +0,0 @@
1
- import React from 'react'
2
- import {
3
- Search, Megaphone, Globe, Mail, Users, MousePointerClick,
4
- Instagram, MessageCircle, DoorOpen, Utensils, Bike, Link2,
5
- Youtube, Facebook, Linkedin, Twitter, Music2, Pin, AtSign,
6
- } from 'lucide-react'
7
-
8
- // Channel icon names referenced by presets → lucide components. Falls back to
9
- // a generic link icon for anything unmapped.
10
- const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
11
- Search, Megaphone, Globe, Mail, Users, MousePointerClick,
12
- Instagram, MessageCircle, DoorOpen, Utensils, Bike,
13
- }
14
-
15
- export function ChannelIcon({ name, className }: { name: string; className?: string }) {
16
- const Icon = ICONS[name] ?? Link2
17
- return <Icon className={className} />
18
- }
19
-
20
- // Platform icons for the content planner. Lucide has no TikTok / Pinterest /
21
- // Threads glyphs — Music2 / Pin / AtSign are the conventional stand-ins.
22
- const PLATFORM_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
23
- instagram: Instagram,
24
- tiktok: Music2,
25
- youtube: Youtube,
26
- facebook: Facebook,
27
- linkedin: Linkedin,
28
- x: Twitter,
29
- pinterest: Pin,
30
- threads: AtSign,
31
- }
32
-
33
- export function PlatformIcon({ platform, className }: { platform: string; className?: string }) {
34
- const Icon = PLATFORM_ICONS[platform] ?? Link2
35
- return <Icon className={className} />
36
- }
@@ -1,91 +0,0 @@
1
- import type { EntityDef, FieldDef } from '@fayz-ai/core'
2
-
3
- // ---------------------------------------------------------------------------
4
- // Blog backoffice entities — the single source of truth that drives BOTH the
5
- // generic list (CrudListView columns + facets) and the create/edit form
6
- // (CrudFormPage fields, incl. the markdown body + category relation).
7
- //
8
- // Persistence is the generic Supabase provider resolved from `data.table`
9
- // (plg_blog_posts / plg_blog_categories, owned by @fayz-ai/plugin-blog's
10
- // migrations). camelCase field keys map to snake_case columns automatically.
11
- // The website reads the same rows through the anon v_public_blog_posts view.
12
- // ---------------------------------------------------------------------------
13
-
14
- export const BLOG_POSTS_TABLE = 'plg_blog_posts'
15
- export const BLOG_CATEGORIES_TABLE = 'plg_blog_categories'
16
-
17
- export interface BlogPostRow {
18
- id: string
19
- title: string
20
- slug: string
21
- excerpt: string
22
- body: string
23
- categoryId?: string
24
- coverImage?: string
25
- authorName?: string
26
- authorRole?: string
27
- authorAvatarUrl?: string
28
- authorBio?: string
29
- status: 'draft' | 'published'
30
- readTime?: string
31
- publishedAt?: string
32
- }
33
-
34
- export interface BlogCategoryRow {
35
- id: string
36
- name: string
37
- slug: string
38
- description?: string
39
- }
40
-
41
- export function buildBlogPostEntity(): EntityDef<BlogPostRow> {
42
- const fields: FieldDef[] = [
43
- { key: 'title', label: 'Title', type: 'text', required: true, searchable: true, showInTable: true, sortable: true },
44
- { key: 'slug', label: 'Slug', type: 'text', required: true, searchable: true, showInTable: false, hint: 'URL identifier, e.g. "cannabis-and-oral-health"' },
45
- { key: 'status', label: 'Status', type: 'select', options: ['draft', 'published'], showInTable: true, defaultValue: 'draft' },
46
- { key: 'categoryId', label: 'Category', type: 'relation', relation: { table: BLOG_CATEGORIES_TABLE, valueField: 'id', labelField: 'name', tenantScoped: true }, showInTable: false },
47
- { key: 'excerpt', label: 'Excerpt', type: 'textarea', span: 2, showInTable: false, placeholder: 'Short summary shown on cards' },
48
- { key: 'body', label: 'Body', type: 'markdown', span: 2, showInTable: false },
49
- { key: 'coverImage', label: 'Cover image', type: 'image', showInTable: false, placeholder: 'https://…' },
50
- { key: 'readTime', label: 'Read time', type: 'text', showInTable: false, placeholder: '5 min' },
51
- { key: 'publishedAt', label: 'Published at', type: 'date', showInTable: true, sortable: true },
52
- { key: 'authorName', label: 'Author name', type: 'text', showInTable: false },
53
- { key: 'authorRole', label: 'Author role', type: 'text', showInTable: false, placeholder: 'e.g. Cirurgião-Dentista' },
54
- { key: 'authorAvatarUrl', label: 'Author avatar', type: 'image', showInTable: false, placeholder: 'https://…' },
55
- { key: 'authorBio', label: 'Author bio', type: 'textarea', span: 2, showInTable: false },
56
- ]
57
- return {
58
- name: 'Post',
59
- namePlural: 'Posts',
60
- icon: 'Newspaper',
61
- fields,
62
- displayField: 'title',
63
- subtitleField: 'slug',
64
- facets: [{ field: 'status', allLabel: 'All' }],
65
- data: {
66
- table: BLOG_POSTS_TABLE,
67
- tenantScoped: true,
68
- searchColumns: ['title', 'slug', 'excerpt'],
69
- },
70
- }
71
- }
72
-
73
- export function buildBlogCategoryEntity(): EntityDef<BlogCategoryRow> {
74
- const fields: FieldDef[] = [
75
- { key: 'name', label: 'Name', type: 'text', required: true, searchable: true, showInTable: true, sortable: true },
76
- { key: 'slug', label: 'Slug', type: 'text', required: true, searchable: true, showInTable: true, hint: 'URL identifier' },
77
- { key: 'description', label: 'Description', type: 'textarea', span: 2, showInTable: false },
78
- ]
79
- return {
80
- name: 'Category',
81
- namePlural: 'Categories',
82
- icon: 'Tags',
83
- fields,
84
- displayField: 'name',
85
- data: {
86
- table: BLOG_CATEGORIES_TABLE,
87
- tenantScoped: true,
88
- searchColumns: ['name', 'slug'],
89
- },
90
- }
91
- }
@@ -1,92 +0,0 @@
1
- // Capability test for the marketing analytics data slice (mock provider) —
2
- // template: plugin-tasks/src/data/capability.test.ts. The Supabase provider
3
- // implements the same MarketingDataProvider interface against
4
- // plg_marketing_channels/_campaigns + v_marketing_attribution.
5
- import { describe, it, expect } from 'vitest'
6
- import { createMockMarketingProvider } from './mock'
7
- import { MARKETING_PRESETS } from '../presets'
8
-
9
- function makeProvider() {
10
- const preset = MARKETING_PRESETS.beauty
11
- return createMockMarketingProvider({
12
- channels: preset.channels,
13
- conversion: preset.conversion,
14
- domain: 'beauty',
15
- })
16
- }
17
-
18
- describe('plugin-marketing capability · analytics slice (mock provider)', () => {
19
- it('reports per-channel performance for every configured channel', async () => {
20
- const provider = makeProvider()
21
- const perf = await provider.channelPerformance({ range: '30d' })
22
- expect(perf.map((c) => c.channelId).sort()).toEqual(
23
- MARKETING_PRESETS.beauty.channels.map((c) => c.id).sort(),
24
- )
25
- for (const c of perf) {
26
- expect(c.conversions).toBeGreaterThanOrEqual(0)
27
- expect(c.cvr).toBeGreaterThanOrEqual(0)
28
- }
29
- })
30
-
31
- it('overview aggregates the same channels the mix reports', async () => {
32
- const provider = makeProvider()
33
- const overview = await provider.overview({ range: '30d' })
34
- expect(overview.channelMix.length).toBe(MARKETING_PRESETS.beauty.channels.length)
35
- expect(overview.conversions).toBe(
36
- overview.channelMix.reduce((s, c) => s + c.conversions, 0),
37
- )
38
- expect(overview.topChannelId).toBeTruthy()
39
- })
40
-
41
- it('persists a created campaign: it surfaces in list and get', async () => {
42
- const provider = makeProvider()
43
- const created = await provider.saveCampaign({
44
- name: 'Campanha Teste',
45
- channelId: 'instagram',
46
- status: 'active',
47
- start: new Date('2026-07-01').toISOString(),
48
- spend: 150,
49
- })
50
- expect(created.id).toBeTruthy()
51
-
52
- const listed = await provider.listCampaigns({ range: '30d' })
53
- expect(listed.some((c) => c.id === created.id)).toBe(true)
54
-
55
- const fetched = await provider.getCampaign(created.id)
56
- expect(fetched?.name).toBe('Campanha Teste')
57
- expect(fetched?.channelId).toBe('instagram')
58
- })
59
-
60
- it('round-trips update and delete', async () => {
61
- const provider = makeProvider()
62
- const created = await provider.saveCampaign({
63
- name: 'temp',
64
- channelId: 'google',
65
- status: 'draft',
66
- start: new Date('2026-07-01').toISOString(),
67
- spend: 0,
68
- })
69
-
70
- const updated = await provider.saveCampaign({
71
- id: created.id,
72
- name: 'temp',
73
- channelId: 'google',
74
- status: 'paused',
75
- start: created.start,
76
- spend: 80,
77
- })
78
- expect(updated.status).toBe('paused')
79
-
80
- await provider.deleteCampaign(created.id)
81
- expect(await provider.getCampaign(created.id)).toBeNull()
82
- })
83
-
84
- it('funnel stages end on the vertical conversion label', async () => {
85
- const provider = makeProvider()
86
- const funnel = await provider.funnel({ range: '30d' })
87
- expect(funnel.length).toBeGreaterThanOrEqual(2)
88
- expect(funnel[funnel.length - 1].label).toBe(
89
- MARKETING_PRESETS.beauty.conversion.labelPlural,
90
- )
91
- })
92
- })
@@ -1,25 +0,0 @@
1
- import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
-
3
- // ---------------------------------------------------------------------------
4
- // Media asset upload for content posts (static art, covers). Public bucket
5
- // `mkt-media` (created by migration 003); path content/{tenantId}/{postId}-{ts}.{ext}
6
- // so re-uploads never collide and old URLs keep resolving until replaced.
7
- // ---------------------------------------------------------------------------
8
-
9
- export function canUploadMedia(): boolean {
10
- return !!getSupabaseClientOptional()
11
- }
12
-
13
- export async function uploadPostMedia(postId: string, file: File): Promise<string> {
14
- const supabase = getSupabaseClientOptional() as any
15
- if (!supabase) throw new Error('Supabase not initialized')
16
- const tenantId = getActiveTenantId()
17
- if (!tenantId) throw new Error('No active tenant')
18
-
19
- const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin'
20
- const path = `content/${tenantId}/${postId}-${Date.now()}.${ext}`
21
- const { error } = await supabase.storage.from('mkt-media').upload(path, file, { upsert: true })
22
- if (error) throw error
23
- const { data } = supabase.storage.from('mkt-media').getPublicUrl(path)
24
- return data.publicUrl as string
25
- }
@@ -1,253 +0,0 @@
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
- }