@fayz-ai/plugin-marketing 0.9.0 → 0.9.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.
- package/README.md +3 -3
- package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
- package/dist/context.d.ts.map +1 -0
- package/dist/data/mock.d.ts +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -22
- package/dist/index.js.map +1 -1
- package/dist/lib/format.d.ts.map +1 -0
- package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
- package/dist/lib/presets.d.ts.map +1 -0
- package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
- package/dist/views/MarketingPage.d.ts.map +1 -0
- package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
- package/dist/views/content/ContentPlannerContext.d.ts +1 -1
- package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
- package/dist/views/dashboardWidgets.d.ts +1 -1
- package/dist/views/dashboardWidgets.d.ts.map +1 -1
- package/package.json +6 -9
- package/dist/MarketingContext.d.ts.map +0 -1
- package/dist/MarketingPage.d.ts.map +0 -1
- package/dist/format.d.ts.map +0 -1
- package/dist/presets.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/MarketingContext.tsx +0 -42
- package/src/MarketingPage.tsx +0 -139
- package/src/components/MarketingBits.tsx +0 -131
- package/src/components/icons.tsx +0 -36
- package/src/data/blogEntities.tsx +0 -91
- package/src/data/capability.test.ts +0 -92
- package/src/data/contentMedia.ts +0 -25
- package/src/data/contentMock.ts +0 -253
- package/src/data/contentSupabase.ts +0 -263
- package/src/data/contentTypes.ts +0 -138
- package/src/data/mock.ts +0 -308
- package/src/data/supabase.ts +0 -613
- package/src/data/tables.ts +0 -33
- package/src/data/types.ts +0 -97
- package/src/format.ts +0 -40
- package/src/index.ts +0 -457
- package/src/integrations/ranklayer/RANKLAYER.md +0 -40
- package/src/integrations/ranklayer/connectorDef.tsx +0 -102
- package/src/integrations/ranklayer/data/supabase.ts +0 -106
- package/src/integrations/ranklayer/types.ts +0 -16
- package/src/locales/en.ts +0 -192
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -192
- package/src/migrations/000_plg_rename.sql +0 -15
- package/src/migrations/001_content_planner.sql +0 -94
- package/src/migrations/002_multi_platform.sql +0 -25
- package/src/migrations/003_recording_ops.sql +0 -25
- package/src/migrations/004_ranklayer.sql +0 -50
- package/src/migrations/005_analytics_base.sql +0 -60
- package/src/migrations/006_analytics_views.sql +0 -68
- package/src/migrations/007_agent_rpcs.sql +0 -119
- package/src/migrations/008_landing_pages.sql +0 -112
- package/src/migrations/index.ts +0 -601
- package/src/presets.ts +0 -119
- package/src/registries.ts +0 -62
- package/src/schema/index.ts +0 -106
- package/src/store.ts +0 -102
- package/src/types.ts +0 -153
- package/src/views/CampaignComposer.tsx +0 -89
- package/src/views/CampaignsView.tsx +0 -59
- package/src/views/ChannelDetailView.tsx +0 -59
- package/src/views/ChannelsView.tsx +0 -46
- package/src/views/FunnelView.tsx +0 -56
- package/src/views/LandingPageComposer.tsx +0 -201
- package/src/views/LandingPagesView.tsx +0 -172
- package/src/views/OverviewView.tsx +0 -24
- package/src/views/SettingsView.tsx +0 -72
- package/src/views/blog/BlogAdminView.tsx +0 -99
- package/src/views/blog/BlogEntityForm.tsx +0 -101
- package/src/views/blog/useEntityCrud.ts +0 -50
- package/src/views/content/ContentPlannerContext.tsx +0 -13
- package/src/views/content/ContentView.tsx +0 -908
- package/src/views/content/PlanBriefView.tsx +0 -268
- package/src/views/content/PostPage.tsx +0 -370
- package/src/views/content/PostSocialPreview.tsx +0 -212
- package/src/views/content/RecordingChecklist.tsx +0 -145
- package/src/views/content/contentBits.tsx +0 -114
- package/src/views/content/contentStore.ts +0 -160
- package/src/views/content/templates.ts +0 -172
- package/src/views/dashboardWidgets.tsx +0 -173
- /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
|
-
}
|