@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,212 +0,0 @@
1
- import React from 'react'
2
- import {
3
- Heart, MessageCircle, Send, Bookmark, MoreHorizontal, ThumbsUp, Share2,
4
- Repeat2, BarChart2, Image as ImageIcon, Globe,
5
- } from 'lucide-react'
6
- import { cn } from '@fayz-ai/ui'
7
- import { useTranslation } from '@fayz-ai/core'
8
- import { PlatformIcon } from '../../components/icons'
9
-
10
- // ---------------------------------------------------------------------------
11
- // Social post preview — the caption "Visualizar" mode renders the post the way
12
- // the platform will show it (asset + caption + action row). One tab per
13
- // selected platform; Instagram is the frame for look-alikes (TikTok/Threads/
14
- // Pinterest/YouTube get their own frames in a later pass).
15
- // ---------------------------------------------------------------------------
16
-
17
- interface PreviewProps {
18
- caption: string
19
- mediaUrl?: string
20
- accountName: string
21
- accountHandle?: string
22
- platforms: string[]
23
- }
24
-
25
- /** Captions are plain text on every network — strip the markdown skeleton. */
26
- function toCaption(md: string): string {
27
- return md
28
- .replace(/^#+\s*/gm, '')
29
- .replace(/\*\*/g, '')
30
- .replace(/^[-*]\s+/gm, '')
31
- .trim()
32
- }
33
-
34
- function isVideo(url?: string): boolean {
35
- return !!url && /\.(mp4|webm|mov)($|\?)/i.test(url)
36
- }
37
-
38
- function Media({ mediaUrl, className }: { mediaUrl?: string; className?: string }) {
39
- if (!mediaUrl) {
40
- return (
41
- <div className={cn('flex aspect-square w-full items-center justify-center bg-muted/40', className)}>
42
- <ImageIcon className="h-8 w-8 text-muted-foreground/40" />
43
- </div>
44
- )
45
- }
46
- return isVideo(mediaUrl) ? (
47
- <video src={mediaUrl} controls className={cn('w-full bg-black object-contain', className)} />
48
- ) : (
49
- <img src={mediaUrl} alt="" className={cn('w-full object-cover', className)} />
50
- )
51
- }
52
-
53
- function Avatar({ name, ring }: { name: string; ring?: boolean }) {
54
- return (
55
- <span
56
- className={cn(
57
- 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground',
58
- ring && 'ring-2 ring-offset-2 ring-pink-500/60',
59
- )}
60
- >
61
- {(name.replace(/^@/, '')[0] ?? '?').toUpperCase()}
62
- </span>
63
- )
64
- }
65
-
66
- function InstagramFrame({ caption, mediaUrl, accountName, accountHandle }: PreviewProps) {
67
- const t = useTranslation()
68
- const handle = (accountHandle || accountName).replace(/^@/, '')
69
- return (
70
- <div className="mx-auto w-full max-w-sm overflow-hidden rounded-card border border-border bg-card">
71
- <div className="flex items-center gap-2.5 px-3 py-2.5">
72
- <Avatar name={handle} ring />
73
- <span className="flex-1 truncate text-sm font-semibold text-foreground">{handle}</span>
74
- <MoreHorizontal className="h-4 w-4 text-muted-foreground" />
75
- </div>
76
- <Media mediaUrl={mediaUrl} />
77
- <div className="space-y-2 px-3 py-2.5">
78
- <div className="flex items-center gap-4 text-foreground">
79
- <Heart className="h-6 w-6" />
80
- <MessageCircle className="h-6 w-6" />
81
- <Send className="h-6 w-6" />
82
- <Bookmark className="ml-auto h-6 w-6" />
83
- </div>
84
- <p className="whitespace-pre-wrap text-sm leading-snug text-foreground">
85
- <span className="mr-1.5 font-semibold">{handle}</span>
86
- {toCaption(caption)}
87
- </p>
88
- <p className="text-[11px] uppercase tracking-wide text-muted-foreground">
89
- {t('marketing.content.previewNow')}
90
- </p>
91
- </div>
92
- </div>
93
- )
94
- }
95
-
96
- function FacebookFrame({ caption, mediaUrl, accountName }: PreviewProps) {
97
- const t = useTranslation()
98
- return (
99
- <div className="mx-auto w-full max-w-sm overflow-hidden rounded-card border border-border bg-card">
100
- <div className="flex items-center gap-2.5 px-3 py-2.5">
101
- <Avatar name={accountName} />
102
- <div className="min-w-0 flex-1">
103
- <p className="truncate text-sm font-semibold text-foreground">{accountName.replace(/^@/, '')}</p>
104
- <p className="flex items-center gap-1 text-xs text-muted-foreground">
105
- {t('marketing.content.previewNow')} · <Globe className="h-3 w-3" />
106
- </p>
107
- </div>
108
- <MoreHorizontal className="h-4 w-4 text-muted-foreground" />
109
- </div>
110
- <p className="whitespace-pre-wrap px-3 pb-2.5 text-sm leading-snug text-foreground">{toCaption(caption)}</p>
111
- <Media mediaUrl={mediaUrl} className="aspect-auto" />
112
- <div className="flex items-center justify-around border-t border-border px-3 py-2 text-muted-foreground">
113
- <span className="flex items-center gap-1.5 text-xs font-medium"><ThumbsUp className="h-4 w-4" /> {t('marketing.content.previewLike')}</span>
114
- <span className="flex items-center gap-1.5 text-xs font-medium"><MessageCircle className="h-4 w-4" /> {t('marketing.content.previewComment')}</span>
115
- <span className="flex items-center gap-1.5 text-xs font-medium"><Share2 className="h-4 w-4" /> {t('marketing.content.previewShare')}</span>
116
- </div>
117
- </div>
118
- )
119
- }
120
-
121
- function LinkedinFrame({ caption, mediaUrl, accountName }: PreviewProps) {
122
- const t = useTranslation()
123
- return (
124
- <div className="mx-auto w-full max-w-sm overflow-hidden rounded-card border border-border bg-card">
125
- <div className="flex items-center gap-2.5 px-3 py-2.5">
126
- <Avatar name={accountName} />
127
- <div className="min-w-0 flex-1">
128
- <p className="truncate text-sm font-semibold text-foreground">{accountName.replace(/^@/, '')}</p>
129
- <p className="text-xs text-muted-foreground">{t('marketing.content.previewNow')}</p>
130
- </div>
131
- </div>
132
- <p className="whitespace-pre-wrap px-3 pb-2.5 text-sm leading-snug text-foreground">{toCaption(caption)}</p>
133
- <Media mediaUrl={mediaUrl} className="aspect-auto" />
134
- <div className="flex items-center justify-around border-t border-border px-3 py-2 text-muted-foreground">
135
- <ThumbsUp className="h-4 w-4" />
136
- <MessageCircle className="h-4 w-4" />
137
- <Repeat2 className="h-4 w-4" />
138
- <Send className="h-4 w-4" />
139
- </div>
140
- </div>
141
- )
142
- }
143
-
144
- function XFrame({ caption, mediaUrl, accountName, accountHandle }: PreviewProps) {
145
- const t = useTranslation()
146
- const handle = (accountHandle || accountName).replace(/^@/, '')
147
- return (
148
- <div className="mx-auto w-full max-w-sm rounded-card border border-border bg-card p-3">
149
- <div className="flex gap-2.5">
150
- <Avatar name={handle} />
151
- <div className="min-w-0 flex-1">
152
- <p className="truncate text-sm text-foreground">
153
- <span className="font-semibold">{accountName.replace(/^@/, '')}</span>
154
- <span className="text-muted-foreground"> @{handle} · {t('marketing.content.previewNow')}</span>
155
- </p>
156
- <p className="mt-0.5 whitespace-pre-wrap text-sm leading-snug text-foreground">{toCaption(caption)}</p>
157
- <div className="mt-2 overflow-hidden rounded-xl border border-border">
158
- <Media mediaUrl={mediaUrl} className="aspect-auto" />
159
- </div>
160
- <div className="mt-2 flex items-center justify-between pr-6 text-muted-foreground">
161
- <MessageCircle className="h-4 w-4" />
162
- <Repeat2 className="h-4 w-4" />
163
- <Heart className="h-4 w-4" />
164
- <BarChart2 className="h-4 w-4" />
165
- <Share2 className="h-4 w-4" />
166
- </div>
167
- </div>
168
- </div>
169
- </div>
170
- )
171
- }
172
-
173
- const FRAMES: Record<string, React.ComponentType<PreviewProps>> = {
174
- instagram: InstagramFrame,
175
- facebook: FacebookFrame,
176
- linkedin: LinkedinFrame,
177
- x: XFrame,
178
- }
179
-
180
- export function PostSocialPreview(props: PreviewProps) {
181
- const platforms = props.platforms.length > 0 ? props.platforms : ['instagram']
182
- const [selected, setSelected] = React.useState(platforms[0])
183
- const active = platforms.includes(selected) ? selected : platforms[0]
184
- const Frame = FRAMES[active] ?? InstagramFrame
185
-
186
- return (
187
- <div className="space-y-3 rounded-md border border-border bg-muted/20 p-4">
188
- {platforms.length > 1 && (
189
- <div className="flex items-center justify-center gap-1">
190
- {platforms.map((platform) => (
191
- <button
192
- key={platform}
193
- type="button"
194
- onClick={() => setSelected(platform)}
195
- aria-pressed={active === platform}
196
- className={cn(
197
- 'flex items-center gap-1.5 rounded-button px-2.5 py-1.5 text-xs font-medium capitalize transition-colors',
198
- active === platform
199
- ? 'bg-card text-foreground shadow-button'
200
- : 'text-muted-foreground hover:text-foreground',
201
- )}
202
- >
203
- <PlatformIcon platform={platform} className="h-3.5 w-3.5" />
204
- {platform}
205
- </button>
206
- ))}
207
- </div>
208
- )}
209
- <Frame {...props} />
210
- </div>
211
- )
212
- }
@@ -1,145 +0,0 @@
1
- import React from 'react'
2
- import { Plus, Sparkles, X } from 'lucide-react'
3
- import { Button, Checkbox, Input, cn } from '@fayz-ai/ui'
4
- import { useTranslation } from '@fayz-ai/core'
5
- import type { ChecklistItem, ContentPost } from '../../data/contentTypes'
6
- import { useContentPlannerStore } from './ContentPlannerContext'
7
-
8
- // ---------------------------------------------------------------------------
9
- // Recording-day checklist — the on-set companion inside the post's aside
10
- // panel (ContentSplit). Mobile-first: big touch targets, every change persists
11
- // immediately (the crew checks items off from a phone between takes).
12
- // "Generate from script" seeds one item per `## TAKE …` heading.
13
- // ---------------------------------------------------------------------------
14
-
15
- function newId(): string {
16
- return typeof crypto !== 'undefined' && 'randomUUID' in crypto
17
- ? crypto.randomUUID()
18
- : `ck-${Date.now()}-${Math.floor(Math.random() * 1e6)}`
19
- }
20
-
21
- export function RecordingChecklist({ post }: { post: ContentPost }) {
22
- const t = useTranslation()
23
- const savePost = useContentPlannerStore((s) => s.savePost)
24
- const [items, setItems] = React.useState<ChecklistItem[]>(post.checklist)
25
- const [text, setText] = React.useState('')
26
-
27
- // Re-sync when navigating between posts
28
- React.useEffect(() => {
29
- setItems(post.checklist)
30
- // eslint-disable-next-line react-hooks/exhaustive-deps
31
- }, [post.id])
32
-
33
- const persist = (next: ChecklistItem[]) => {
34
- setItems(next)
35
- void savePost({ id: post.id, checklist: next })
36
- }
37
-
38
- const toggle = (id: string) =>
39
- persist(items.map((i) => (i.id === id ? { ...i, done: !i.done } : i)))
40
-
41
- const remove = (id: string) => persist(items.filter((i) => i.id !== id))
42
-
43
- const add = () => {
44
- const value = text.trim()
45
- if (!value) return
46
- persist([...items, { id: newId(), text: value, done: false }])
47
- setText('')
48
- }
49
-
50
- const generateFromScript = () => {
51
- const takes = [...post.contentMd.matchAll(/^##\s+(TAKE\s+.+)$/gim)].map((m) =>
52
- m[1].trim(),
53
- )
54
- const existing = new Set(items.map((i) => i.text))
55
- const fresh = takes
56
- .filter((take) => !existing.has(take))
57
- .map((take) => ({ id: newId(), text: take, done: false }))
58
- if (fresh.length > 0) persist([...items, ...fresh])
59
- }
60
-
61
- const hasTakes = /^##\s+TAKE\s+/im.test(post.contentMd)
62
- const done = items.filter((i) => i.done).length
63
-
64
- return (
65
- <div className="space-y-3">
66
- {items.length > 0 && (
67
- <div className="space-y-1.5">
68
- <div className="flex items-center justify-between text-xs text-muted-foreground">
69
- <span>{done}/{items.length}</span>
70
- {hasTakes && (
71
- <button
72
- type="button"
73
- onClick={generateFromScript}
74
- className="flex items-center gap-1 font-medium text-muted-foreground transition-colors hover:text-foreground"
75
- >
76
- <Sparkles className="h-3 w-3" />
77
- {t('marketing.content.checklistGenerate')}
78
- </button>
79
- )}
80
- </div>
81
- <div className="h-1 overflow-hidden rounded-full bg-muted">
82
- <div
83
- className="h-full rounded-full bg-primary transition-[width]"
84
- style={{ width: items.length ? `${(done / items.length) * 100}%` : 0 }}
85
- />
86
- </div>
87
- </div>
88
- )}
89
-
90
- {items.length === 0 && (
91
- <div className="space-y-3 rounded-card border border-dashed border-border p-3 text-center">
92
- <p className="text-xs text-muted-foreground">
93
- {t('marketing.content.checklistEmpty')}
94
- </p>
95
- {hasTakes && (
96
- <Button variant="outline" size="sm" onClick={generateFromScript}>
97
- <Sparkles className="mr-1.5 h-3.5 w-3.5" />
98
- {t('marketing.content.checklistGenerate')}
99
- </Button>
100
- )}
101
- </div>
102
- )}
103
-
104
- <ul className="space-y-1">
105
- {items.map((item) => (
106
- <li key={item.id} className="group/item flex items-start gap-2.5 rounded-button px-1 py-2 transition-colors hover:bg-muted/40">
107
- {/* label wraps the row → the whole line is the touch target */}
108
- <label className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5">
109
- <Checkbox checked={item.done} onChange={() => toggle(item.id)} className="mt-0.5" />
110
- <span
111
- className={cn(
112
- 'min-w-0 flex-1 text-sm leading-snug',
113
- item.done ? 'text-muted-foreground line-through' : 'text-foreground',
114
- )}
115
- >
116
- {item.text}
117
- </span>
118
- </label>
119
- <button
120
- type="button"
121
- aria-label={t('marketing.content.cancel')}
122
- onClick={() => remove(item.id)}
123
- className="rounded p-0.5 text-muted-foreground/50 opacity-0 transition-opacity hover:text-destructive group-hover/item:opacity-100 focus:opacity-100"
124
- >
125
- <X className="h-3.5 w-3.5" />
126
- </button>
127
- </li>
128
- ))}
129
- </ul>
130
-
131
- <div className="flex items-center gap-2">
132
- <Input
133
- value={text}
134
- onChange={(e) => setText(e.target.value)}
135
- placeholder={t('marketing.content.checklistAddPlaceholder')}
136
- onKeyDown={(e) => { if (e.key === 'Enter') add() }}
137
- className="h-8 text-sm"
138
- />
139
- <Button variant="outline" size="sm" onClick={add} disabled={!text.trim()} aria-label={t('marketing.content.checklistAdd')}>
140
- <Plus className="h-4 w-4" />
141
- </Button>
142
- </div>
143
- </div>
144
- )
145
- }
@@ -1,114 +0,0 @@
1
- import React from 'react'
2
- import { cn } from '@fayz-ai/ui'
3
- import { useTranslation } from '@fayz-ai/core'
4
- import type { PostFormat, PostStatus } from '../../data/contentTypes'
5
- import { PLATFORMS, POST_STATUS_ORDER } from '../../data/contentTypes'
6
- import { PlatformIcon } from '../../components/icons'
7
-
8
- export const POST_FORMATS: PostFormat[] = ['reel', 'static', 'carousel', 'story', 'video', 'live', 'article']
9
- export { POST_STATUS_ORDER }
10
-
11
- const FORMAT_STYLE: Record<PostFormat, string> = {
12
- reel: 'bg-violet-100 text-violet-700',
13
- static: 'bg-sky-100 text-sky-700',
14
- carousel: 'bg-amber-100 text-amber-700',
15
- story: 'bg-pink-100 text-pink-700',
16
- video: 'bg-red-100 text-red-700',
17
- live: 'bg-rose-100 text-rose-700',
18
- article: 'bg-teal-100 text-teal-700',
19
- }
20
-
21
- // Status dot colors — same palette/semantics as plugin-agenda's booking
22
- // statuses (config.ts): gray backlog, indigo planned, warm amber/orange while
23
- // in production, blue when scheduled, emerald when done.
24
- export const POST_STATUS_COLOR: Record<PostStatus, string> = {
25
- idea: '#6b7280',
26
- script: '#6366f1',
27
- recording: '#f59e0b',
28
- editing: '#f97316',
29
- scheduled: '#3b82f6',
30
- published: '#10b981',
31
- }
32
-
33
- /** Small status dot (agenda/CRM convention) for selects and lists. */
34
- export function StatusDot({ status, className }: { status: PostStatus; className?: string }) {
35
- return (
36
- <span
37
- className={cn('h-2 w-2 shrink-0 rounded-full', className)}
38
- style={{ backgroundColor: POST_STATUS_COLOR[status] }}
39
- />
40
- )
41
- }
42
-
43
- const STATUS_STYLE: Record<PostStatus, string> = {
44
- idea: 'bg-slate-100 text-slate-600',
45
- script: 'bg-indigo-100 text-indigo-700',
46
- recording: 'bg-amber-100 text-amber-700',
47
- editing: 'bg-orange-100 text-orange-700',
48
- scheduled: 'bg-sky-100 text-sky-700',
49
- published: 'bg-emerald-100 text-emerald-700',
50
- }
51
-
52
- export function FormatBadge({ format }: { format: PostFormat }) {
53
- const t = useTranslation()
54
- return (
55
- <span className={cn('rounded-full px-2 py-0.5 text-xs font-medium', FORMAT_STYLE[format])}>
56
- {t(`marketing.content.format.${format}`)}
57
- </span>
58
- )
59
- }
60
-
61
- export function PostStatusBadge({ status }: { status: PostStatus }) {
62
- const t = useTranslation()
63
- return (
64
- <span className={cn('rounded-full px-2 py-0.5 text-xs font-medium', STATUS_STYLE[status])}>
65
- {t(`marketing.content.status.${status}`)}
66
- </span>
67
- )
68
- }
69
-
70
- /** Compact row of platform glyphs (post targets or account connections). */
71
- export function PlatformBadges({ platforms, className }: { platforms: string[]; className?: string }) {
72
- if (platforms.length === 0) return null
73
- return (
74
- <span className={cn('inline-flex items-center gap-1 text-muted-foreground', className)}>
75
- {platforms.map((p) => (
76
- <PlatformIcon key={p} platform={p} className="h-3.5 w-3.5" />
77
- ))}
78
- </span>
79
- )
80
- }
81
-
82
- /** Toggleable platform chips — used by the add-account form and the post page. */
83
- export function PlatformPicker({ value, onChange }: {
84
- value: string[]
85
- onChange: (platforms: string[]) => void
86
- }) {
87
- return (
88
- <div className="flex flex-wrap items-center gap-1.5">
89
- {PLATFORMS.map((platform) => {
90
- const selected = value.includes(platform)
91
- return (
92
- <button
93
- key={platform}
94
- type="button"
95
- aria-pressed={selected}
96
- onClick={() =>
97
- onChange(selected ? value.filter((p) => p !== platform) : [...value, platform])
98
- }
99
- className={cn(
100
- 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium capitalize transition-colors',
101
- selected
102
- ? 'border-primary bg-primary/10 text-foreground'
103
- : 'border-border text-muted-foreground hover:bg-muted/40',
104
- )}
105
- >
106
- <PlatformIcon platform={platform} className="h-3.5 w-3.5" />
107
- {platform}
108
- </button>
109
- )
110
- })}
111
- </div>
112
- )
113
- }
114
-
@@ -1,160 +0,0 @@
1
- import { createStore, type StoreApi } from 'zustand/vanilla'
2
- import { getActiveTenantId } from '@fayz-ai/core'
3
- import type {
4
- ContentPlan,
5
- ContentPlannerProvider,
6
- ContentPost,
7
- SaveContentPlanInput,
8
- SaveContentPostInput,
9
- SaveSocialAccountInput,
10
- SocialAccount,
11
- } from '../../data/contentTypes'
12
-
13
- // ---------------------------------------------------------------------------
14
- // Content-planner UI state — separate from the analytics store on purpose:
15
- // the Conteúdo tab loads lazily on first visit and never gates (or is gated
16
- // by) the overview's five analytics fetches.
17
- // ---------------------------------------------------------------------------
18
-
19
- export type BoardViewMode = 'board' | 'calendar' | 'list'
20
-
21
- export interface ContentPlannerUIState {
22
- loaded: boolean
23
- loading: boolean
24
- /** Tenant the current data was loaded for. The tenant hydrates async after
25
- * login/refresh, so views re-load when this diverges from the active one. */
26
- loadedTenantId: string | null
27
- /** Board / calendar / list — survives opening a post and coming back. */
28
- viewMode: BoardViewMode
29
- setViewMode(mode: BoardViewMode): void
30
- accounts: SocialAccount[]
31
- activeAccountId: string | null
32
- plans: ContentPlan[]
33
- activePlan: ContentPlan | null
34
- posts: ContentPost[]
35
-
36
- load(): Promise<void>
37
- setAccount(accountId: string): Promise<void>
38
- selectPlan(planId: string): Promise<void>
39
- saveAccount(input: SaveSocialAccountInput): Promise<SocialAccount>
40
- deleteAccount(id: string): Promise<void>
41
- savePlan(input: SaveContentPlanInput): Promise<ContentPlan>
42
- savePost(input: SaveContentPostInput): Promise<ContentPost>
43
- deletePost(id: string): Promise<void>
44
- }
45
-
46
- export function createContentPlannerStore(
47
- provider: ContentPlannerProvider,
48
- ): StoreApi<ContentPlannerUIState> {
49
- async function loadPlansFor(accountId: string): Promise<{ plans: ContentPlan[]; activePlan: ContentPlan | null; posts: ContentPost[] }> {
50
- const plans = await provider.listPlans(accountId)
51
- const activePlan = plans.find((p) => p.status === 'active') ?? plans[0] ?? null
52
- const posts = activePlan ? await provider.listPosts(activePlan.id) : []
53
- return { plans, activePlan, posts }
54
- }
55
-
56
- return createStore<ContentPlannerUIState>((set, get) => ({
57
- loaded: false,
58
- loading: false,
59
- loadedTenantId: null,
60
- viewMode: 'board',
61
- setViewMode(mode) { set({ viewMode: mode }) },
62
- accounts: [],
63
- activeAccountId: null,
64
- plans: [],
65
- activePlan: null,
66
- posts: [],
67
-
68
- async load() {
69
- if (get().loading) return
70
- set({ loading: true })
71
- try {
72
- const accounts = await provider.listAccounts()
73
- const current = get().activeAccountId
74
- const activeAccountId = (current && accounts.some((a) => a.id === current) ? current : accounts[0]?.id) ?? null
75
- const { plans, activePlan, posts } = activeAccountId
76
- ? await loadPlansFor(activeAccountId)
77
- : { plans: [], activePlan: null, posts: [] }
78
- set({
79
- accounts, activeAccountId, plans, activePlan, posts,
80
- loaded: true, loading: false,
81
- loadedTenantId: getActiveTenantId() ?? null,
82
- })
83
- } catch (err) {
84
- set({ loading: false, loaded: true })
85
- throw err
86
- }
87
- },
88
-
89
- async setAccount(accountId) {
90
- set({ activeAccountId: accountId, loading: true })
91
- try {
92
- const { plans, activePlan, posts } = await loadPlansFor(accountId)
93
- set({ plans, activePlan, posts, loading: false })
94
- } catch (err) {
95
- set({ loading: false })
96
- throw err
97
- }
98
- },
99
-
100
- async selectPlan(planId) {
101
- const plan = get().plans.find((p) => p.id === planId) ?? null
102
- const posts = plan ? await provider.listPosts(plan.id) : []
103
- set({ activePlan: plan, posts })
104
- },
105
-
106
- async saveAccount(input) {
107
- const account = await provider.saveAccount(input)
108
- const accounts = await provider.listAccounts()
109
- set({ accounts })
110
- if (!get().activeAccountId) await get().setAccount(account.id)
111
- return account
112
- },
113
-
114
- async deleteAccount(id) {
115
- await provider.deleteAccount(id)
116
- const accounts = await provider.listAccounts()
117
- const wasActive = get().activeAccountId === id
118
- set({ accounts })
119
- if (wasActive) {
120
- const next = accounts[0]?.id ?? null
121
- if (next) {
122
- await get().setAccount(next)
123
- } else {
124
- set({ activeAccountId: null, plans: [], activePlan: null, posts: [] })
125
- }
126
- }
127
- },
128
-
129
- async savePlan(input) {
130
- const plan = await provider.savePlan(input)
131
- const accountId = get().activeAccountId
132
- if (accountId) {
133
- const plans = await provider.listPlans(accountId)
134
- const activePlan = plans.find((p) => p.id === plan.id) ?? get().activePlan
135
- const posts = activePlan ? await provider.listPosts(activePlan.id) : []
136
- set({ plans, activePlan, posts })
137
- }
138
- return plan
139
- },
140
-
141
- async savePost(input) {
142
- const post = await provider.savePost(input)
143
- const planId = get().activePlan?.id
144
- if (planId && post.planId === planId) {
145
- const posts = await provider.listPosts(planId)
146
- set({ posts })
147
- }
148
- return post
149
- },
150
-
151
- async deletePost(id) {
152
- await provider.deletePost(id)
153
- const planId = get().activePlan?.id
154
- if (planId) {
155
- const posts = await provider.listPosts(planId)
156
- set({ posts })
157
- }
158
- },
159
- }))
160
- }