@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.
- 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 +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +241 -188
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
- package/dist/integrations/ranklayer/data/supabase.d.ts +24 -6
- package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -1
- package/dist/integrations/ranklayer/outbound.d.ts +16 -0
- package/dist/integrations/ranklayer/outbound.d.ts.map +1 -0
- package/dist/integrations/ranklayer/settings.d.ts +12 -0
- package/dist/integrations/ranklayer/settings.d.ts.map +1 -0
- package/dist/integrations/ranklayer/types.d.ts +0 -1
- package/dist/integrations/ranklayer/types.d.ts.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/migrations/index.d.ts +1 -1
- package/dist/migrations/index.d.ts.map +1 -1
- 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,268 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { X } from 'lucide-react'
|
|
3
|
-
import {
|
|
4
|
-
Button, Input, MarkdownEditor, SubpageHeader, Checkbox,
|
|
5
|
-
Sheet, SheetContent, SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription,
|
|
6
|
-
} from '@fayz-ai/ui'
|
|
7
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
8
|
-
import type { ContentPlan, PostFormat } from '../../data/contentTypes'
|
|
9
|
-
import { useContentPlannerStore } from './ContentPlannerContext'
|
|
10
|
-
import { POST_FORMATS } from './contentBits'
|
|
11
|
-
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// Plan brief — the "what we want" page: structured strategy fields (pillars,
|
|
14
|
-
// objective, tone, formats, cadence) plus a free-form markdown brief.
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
interface Draft {
|
|
18
|
-
name: string
|
|
19
|
-
objective: string
|
|
20
|
-
tone: string
|
|
21
|
-
pillars: string[]
|
|
22
|
-
formats: PostFormat[]
|
|
23
|
-
weeklyFrequency: number
|
|
24
|
-
weeksCount: number
|
|
25
|
-
briefMd: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function toDraft(plan: ContentPlan): Draft {
|
|
29
|
-
return {
|
|
30
|
-
name: plan.name,
|
|
31
|
-
objective: plan.objective ?? '',
|
|
32
|
-
tone: plan.tone ?? '',
|
|
33
|
-
pillars: plan.pillars,
|
|
34
|
-
formats: plan.formats,
|
|
35
|
-
weeklyFrequency: plan.weeklyFrequency,
|
|
36
|
-
weeksCount: plan.weeksCount,
|
|
37
|
-
briefMd: plan.briefMd,
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
42
|
-
return (
|
|
43
|
-
<div className="space-y-1">
|
|
44
|
-
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
|
45
|
-
{children}
|
|
46
|
-
</div>
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function useBriefDraft() {
|
|
51
|
-
const plan = useContentPlannerStore((s) => s.activePlan)
|
|
52
|
-
const savePlan = useContentPlannerStore((s) => s.savePlan)
|
|
53
|
-
|
|
54
|
-
const [draft, setDraft] = React.useState<Draft | null>(plan ? toDraft(plan) : null)
|
|
55
|
-
const [saving, setSaving] = React.useState(false)
|
|
56
|
-
|
|
57
|
-
React.useEffect(() => {
|
|
58
|
-
if (plan) setDraft(toDraft(plan))
|
|
59
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
-
}, [plan?.id])
|
|
61
|
-
|
|
62
|
-
const patch = (partial: Partial<Draft>) => setDraft((d) => (d ? { ...d, ...partial } : d))
|
|
63
|
-
const dirty = !!plan && !!draft && JSON.stringify(draft) !== JSON.stringify(toDraft(plan))
|
|
64
|
-
|
|
65
|
-
const save = async () => {
|
|
66
|
-
if (!plan || !draft) return
|
|
67
|
-
setSaving(true)
|
|
68
|
-
try {
|
|
69
|
-
await savePlan({
|
|
70
|
-
id: plan.id,
|
|
71
|
-
name: draft.name,
|
|
72
|
-
objective: draft.objective,
|
|
73
|
-
tone: draft.tone,
|
|
74
|
-
pillars: draft.pillars,
|
|
75
|
-
formats: draft.formats,
|
|
76
|
-
weeklyFrequency: draft.weeklyFrequency,
|
|
77
|
-
weeksCount: draft.weeksCount,
|
|
78
|
-
briefMd: draft.briefMd,
|
|
79
|
-
})
|
|
80
|
-
} finally {
|
|
81
|
-
setSaving(false)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return { plan, draft, patch, dirty, saving, save }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function BriefFields({ draft, patch }: { draft: Draft; patch: (p: Partial<Draft>) => void }) {
|
|
89
|
-
const t = useTranslation()
|
|
90
|
-
const [pillarInput, setPillarInput] = React.useState('')
|
|
91
|
-
|
|
92
|
-
const addPillar = () => {
|
|
93
|
-
const value = pillarInput.trim()
|
|
94
|
-
if (!value || draft.pillars.includes(value)) return
|
|
95
|
-
patch({ pillars: [...draft.pillars, value] })
|
|
96
|
-
setPillarInput('')
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const toggleFormat = (format: PostFormat) => {
|
|
100
|
-
patch({
|
|
101
|
-
formats: draft.formats.includes(format)
|
|
102
|
-
? draft.formats.filter((f) => f !== format)
|
|
103
|
-
: [...draft.formats, format],
|
|
104
|
-
})
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<div className="space-y-5">
|
|
109
|
-
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
110
|
-
<Field label={t('marketing.content.planName')}>
|
|
111
|
-
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
|
|
112
|
-
</Field>
|
|
113
|
-
<Field label={t('marketing.content.objective')}>
|
|
114
|
-
<Input
|
|
115
|
-
value={draft.objective}
|
|
116
|
-
onChange={(e) => patch({ objective: e.target.value })}
|
|
117
|
-
placeholder={t('marketing.content.objectivePlaceholder')}
|
|
118
|
-
/>
|
|
119
|
-
</Field>
|
|
120
|
-
<Field label={t('marketing.content.tone')}>
|
|
121
|
-
<Input
|
|
122
|
-
value={draft.tone}
|
|
123
|
-
onChange={(e) => patch({ tone: e.target.value })}
|
|
124
|
-
placeholder={t('marketing.content.tonePlaceholder')}
|
|
125
|
-
/>
|
|
126
|
-
</Field>
|
|
127
|
-
<div className="grid grid-cols-2 gap-3">
|
|
128
|
-
<Field label={t('marketing.content.weeklyFrequency')}>
|
|
129
|
-
<Input
|
|
130
|
-
type="number"
|
|
131
|
-
min={1}
|
|
132
|
-
max={14}
|
|
133
|
-
value={draft.weeklyFrequency}
|
|
134
|
-
onChange={(e) => patch({ weeklyFrequency: Math.max(1, Number(e.target.value) || 1) })}
|
|
135
|
-
/>
|
|
136
|
-
</Field>
|
|
137
|
-
<Field label={t('marketing.content.weeksCount')}>
|
|
138
|
-
<Input
|
|
139
|
-
type="number"
|
|
140
|
-
min={1}
|
|
141
|
-
max={52}
|
|
142
|
-
value={draft.weeksCount}
|
|
143
|
-
onChange={(e) => patch({ weeksCount: Math.max(1, Number(e.target.value) || 1) })}
|
|
144
|
-
/>
|
|
145
|
-
</Field>
|
|
146
|
-
</div>
|
|
147
|
-
</div>
|
|
148
|
-
|
|
149
|
-
<Field label={t('marketing.content.pillars')}>
|
|
150
|
-
<div className="flex flex-wrap items-center gap-1.5">
|
|
151
|
-
{draft.pillars.map((pillar) => (
|
|
152
|
-
<span
|
|
153
|
-
key={pillar}
|
|
154
|
-
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-foreground"
|
|
155
|
-
>
|
|
156
|
-
{pillar}
|
|
157
|
-
<button
|
|
158
|
-
type="button"
|
|
159
|
-
onClick={() => patch({ pillars: draft.pillars.filter((p) => p !== pillar) })}
|
|
160
|
-
className="text-muted-foreground hover:text-foreground"
|
|
161
|
-
>
|
|
162
|
-
<X className="h-3 w-3" />
|
|
163
|
-
</button>
|
|
164
|
-
</span>
|
|
165
|
-
))}
|
|
166
|
-
<Input
|
|
167
|
-
value={pillarInput}
|
|
168
|
-
onChange={(e) => setPillarInput(e.target.value)}
|
|
169
|
-
onKeyDown={(e) => {
|
|
170
|
-
if (e.key === 'Enter') {
|
|
171
|
-
e.preventDefault()
|
|
172
|
-
addPillar()
|
|
173
|
-
}
|
|
174
|
-
}}
|
|
175
|
-
onBlur={addPillar}
|
|
176
|
-
placeholder={t('marketing.content.pillarPlaceholder')}
|
|
177
|
-
className="h-7 w-44 text-xs"
|
|
178
|
-
/>
|
|
179
|
-
</div>
|
|
180
|
-
</Field>
|
|
181
|
-
|
|
182
|
-
<Field label={t('marketing.content.formatsLabel')}>
|
|
183
|
-
<div className="flex flex-wrap gap-4">
|
|
184
|
-
{POST_FORMATS.map((format) => (
|
|
185
|
-
<label key={format} className="flex items-center gap-2 text-sm text-foreground">
|
|
186
|
-
<Checkbox
|
|
187
|
-
checked={draft.formats.includes(format)}
|
|
188
|
-
onChange={() => toggleFormat(format)}
|
|
189
|
-
/>
|
|
190
|
-
{t(`marketing.content.format.${format}`)}
|
|
191
|
-
</label>
|
|
192
|
-
))}
|
|
193
|
-
</div>
|
|
194
|
-
</Field>
|
|
195
|
-
|
|
196
|
-
<Field label={t('marketing.content.briefBody')}>
|
|
197
|
-
<MarkdownEditor
|
|
198
|
-
value={draft.briefMd}
|
|
199
|
-
onChange={(briefMd) => patch({ briefMd })}
|
|
200
|
-
placeholder={t('marketing.content.briefPlaceholder')}
|
|
201
|
-
editLabel={t('marketing.content.edit')}
|
|
202
|
-
previewLabel={t('marketing.content.preview')}
|
|
203
|
-
minRows={10}
|
|
204
|
-
/>
|
|
205
|
-
</Field>
|
|
206
|
-
</div>
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Full-page variant — kept for the /marketing/content/brief deep link. */
|
|
211
|
-
export function PlanBriefView({ onBack }: { onBack: () => void }) {
|
|
212
|
-
const t = useTranslation()
|
|
213
|
-
const { plan, draft, patch, dirty, saving, save } = useBriefDraft()
|
|
214
|
-
|
|
215
|
-
if (!plan || !draft) {
|
|
216
|
-
return <div className="mx-auto max-w-3xl text-sm text-muted-foreground">…</div>
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return (
|
|
220
|
-
<div className="mx-auto max-w-3xl space-y-5">
|
|
221
|
-
<SubpageHeader
|
|
222
|
-
title={t('marketing.content.brief')}
|
|
223
|
-
subtitle={plan.name}
|
|
224
|
-
parentLabel={t('marketing.content.title')}
|
|
225
|
-
onBack={onBack}
|
|
226
|
-
actions={
|
|
227
|
-
<Button size="sm" onClick={() => void save()} disabled={!dirty || saving}>
|
|
228
|
-
{t('marketing.content.save')}
|
|
229
|
-
</Button>
|
|
230
|
-
}
|
|
231
|
-
/>
|
|
232
|
-
<BriefFields draft={draft} patch={patch} />
|
|
233
|
-
</div>
|
|
234
|
-
)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Right slide-over variant — the board's Briefing button opens this, keeping
|
|
238
|
-
* the calendar in sight while the strategy is edited. */
|
|
239
|
-
export function PlanBriefSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
240
|
-
const t = useTranslation()
|
|
241
|
-
const { plan, draft, patch, dirty, saving, save } = useBriefDraft()
|
|
242
|
-
|
|
243
|
-
return (
|
|
244
|
-
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
245
|
-
<SheetContent width="max-w-2xl">
|
|
246
|
-
<SheetHeader>
|
|
247
|
-
<SheetTitle>{t('marketing.content.brief')}</SheetTitle>
|
|
248
|
-
{plan && <SheetDescription>{plan.name}</SheetDescription>}
|
|
249
|
-
</SheetHeader>
|
|
250
|
-
<SheetBody>
|
|
251
|
-
{draft ? (
|
|
252
|
-
<BriefFields draft={draft} patch={patch} />
|
|
253
|
-
) : (
|
|
254
|
-
<p className="text-sm text-muted-foreground">…</p>
|
|
255
|
-
)}
|
|
256
|
-
</SheetBody>
|
|
257
|
-
<SheetFooter>
|
|
258
|
-
<Button variant="outline" onClick={onClose} disabled={saving}>
|
|
259
|
-
{t('marketing.content.cancel')}
|
|
260
|
-
</Button>
|
|
261
|
-
<Button onClick={() => void save()} disabled={!dirty || saving}>
|
|
262
|
-
{t('marketing.content.save')}
|
|
263
|
-
</Button>
|
|
264
|
-
</SheetFooter>
|
|
265
|
-
</SheetContent>
|
|
266
|
-
</Sheet>
|
|
267
|
-
)
|
|
268
|
-
}
|
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { ImagePlus, Loader2, X } from 'lucide-react'
|
|
3
|
-
import {
|
|
4
|
-
Button, Input, MarkdownEditor, SubpageHeader, ConfirmDialog, Skeleton,
|
|
5
|
-
ContentSplit, ContentSplitTrigger,
|
|
6
|
-
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
7
|
-
} from '@fayz-ai/ui'
|
|
8
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
9
|
-
import { canUploadMedia, uploadPostMedia } from '../../data/contentMedia'
|
|
10
|
-
import { RecordingChecklist } from './RecordingChecklist'
|
|
11
|
-
import { PostSocialPreview } from './PostSocialPreview'
|
|
12
|
-
import type { ContentPost, PostFormat, PostStatus } from '../../data/contentTypes'
|
|
13
|
-
import { useContentPlannerProvider, useContentPlannerStore } from './ContentPlannerContext'
|
|
14
|
-
import { POST_FORMATS, POST_STATUS_ORDER, PlatformPicker, StatusDot } from './contentBits'
|
|
15
|
-
import { POST_TEMPLATES } from './templates'
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Post page — the Notion-style page behind each board card: title, a compact
|
|
19
|
-
// meta row (format / status / date / hook / CTA) and the markdown script body.
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
function MediaAsset({ post, onChanged }: {
|
|
23
|
-
post: ContentPost
|
|
24
|
-
onChanged: (mediaUrl: string | null) => void
|
|
25
|
-
}) {
|
|
26
|
-
const t = useTranslation()
|
|
27
|
-
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
28
|
-
const [busy, setBusy] = React.useState(false)
|
|
29
|
-
const isVideo = post.mediaUrl ? /\.(mp4|webm|mov)($|\?)/i.test(post.mediaUrl) : false
|
|
30
|
-
|
|
31
|
-
const pick = () => inputRef.current?.click()
|
|
32
|
-
|
|
33
|
-
const upload = async (file: File) => {
|
|
34
|
-
setBusy(true)
|
|
35
|
-
try {
|
|
36
|
-
const url = await uploadPostMedia(post.id, file)
|
|
37
|
-
onChanged(url)
|
|
38
|
-
} finally {
|
|
39
|
-
setBusy(false)
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (!canUploadMedia()) {
|
|
44
|
-
return <p className="text-xs text-muted-foreground">{t('marketing.content.mediaUnavailable')}</p>
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return (
|
|
48
|
-
<div className="space-y-2">
|
|
49
|
-
<input
|
|
50
|
-
ref={inputRef}
|
|
51
|
-
type="file"
|
|
52
|
-
accept="image/*,video/*"
|
|
53
|
-
className="hidden"
|
|
54
|
-
onChange={(e) => {
|
|
55
|
-
const file = e.target.files?.[0]
|
|
56
|
-
if (file) void upload(file)
|
|
57
|
-
e.target.value = ''
|
|
58
|
-
}}
|
|
59
|
-
/>
|
|
60
|
-
{post.mediaUrl ? (
|
|
61
|
-
<div className="relative overflow-hidden rounded-card border border-border bg-muted/20">
|
|
62
|
-
{isVideo ? (
|
|
63
|
-
<video src={post.mediaUrl} controls className="max-h-72 w-full object-contain" />
|
|
64
|
-
) : (
|
|
65
|
-
<img src={post.mediaUrl} alt="" className="max-h-72 w-full object-contain" />
|
|
66
|
-
)}
|
|
67
|
-
<div className="flex items-center justify-end gap-2 border-t border-border p-2">
|
|
68
|
-
<Button variant="outline" size="sm" onClick={pick} disabled={busy}>
|
|
69
|
-
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <ImagePlus className="mr-1.5 h-3.5 w-3.5" />}
|
|
70
|
-
{t('marketing.content.mediaReplace')}
|
|
71
|
-
</Button>
|
|
72
|
-
<Button variant="ghost" size="sm" onClick={() => onChanged(null)} disabled={busy}>
|
|
73
|
-
<X className="mr-1 h-3.5 w-3.5" />
|
|
74
|
-
{t('marketing.content.mediaRemove')}
|
|
75
|
-
</Button>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
) : (
|
|
79
|
-
<button
|
|
80
|
-
type="button"
|
|
81
|
-
onClick={pick}
|
|
82
|
-
disabled={busy}
|
|
83
|
-
className="flex w-full flex-col items-center justify-center gap-2 rounded-card border border-dashed border-border p-6 text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
|
|
84
|
-
>
|
|
85
|
-
{busy ? <Loader2 className="h-5 w-5 animate-spin" /> : <ImagePlus className="h-5 w-5" />}
|
|
86
|
-
<span className="text-sm font-medium">{t('marketing.content.mediaUpload')}</span>
|
|
87
|
-
<span className="text-xs">{t('marketing.content.mediaHint')}</span>
|
|
88
|
-
</button>
|
|
89
|
-
)}
|
|
90
|
-
</div>
|
|
91
|
-
)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
interface Draft {
|
|
95
|
-
title: string
|
|
96
|
-
format: PostFormat
|
|
97
|
-
status: PostStatus
|
|
98
|
-
scheduledDate: string
|
|
99
|
-
platforms: string[]
|
|
100
|
-
hook: string
|
|
101
|
-
cta: string
|
|
102
|
-
contentMd: string
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function toDraft(post: ContentPost): Draft {
|
|
106
|
-
return {
|
|
107
|
-
title: post.title,
|
|
108
|
-
format: post.format,
|
|
109
|
-
status: post.status,
|
|
110
|
-
scheduledDate: post.scheduledDate ?? '',
|
|
111
|
-
platforms: post.platforms,
|
|
112
|
-
hook: post.hook ?? '',
|
|
113
|
-
cta: post.cta ?? '',
|
|
114
|
-
contentMd: post.contentMd,
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function PostPage({ postId, onBack }: { postId: string; onBack: () => void }) {
|
|
119
|
-
const t = useTranslation()
|
|
120
|
-
const provider = useContentPlannerProvider()
|
|
121
|
-
const storePost = useContentPlannerStore((s) => s.posts.find((p) => p.id === postId))
|
|
122
|
-
const savePost = useContentPlannerStore((s) => s.savePost)
|
|
123
|
-
const deletePost = useContentPlannerStore((s) => s.deletePost)
|
|
124
|
-
const accounts = useContentPlannerStore((s) => s.accounts)
|
|
125
|
-
const activeAccountId = useContentPlannerStore((s) => s.activeAccountId)
|
|
126
|
-
|
|
127
|
-
const [post, setPost] = React.useState<ContentPost | null>(storePost ?? null)
|
|
128
|
-
const [draft, setDraft] = React.useState<Draft | null>(storePost ? toDraft(storePost) : null)
|
|
129
|
-
const [saving, setSaving] = React.useState(false)
|
|
130
|
-
const [confirmDelete, setConfirmDelete] = React.useState(false)
|
|
131
|
-
|
|
132
|
-
// Deep links land here before the board loaded the plan — fetch directly.
|
|
133
|
-
React.useEffect(() => {
|
|
134
|
-
if (post?.id === postId) return
|
|
135
|
-
if (storePost) {
|
|
136
|
-
setPost(storePost)
|
|
137
|
-
setDraft(toDraft(storePost))
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
void provider.getPost(postId).then((fetched) => {
|
|
141
|
-
if (fetched) {
|
|
142
|
-
setPost(fetched)
|
|
143
|
-
setDraft(toDraft(fetched))
|
|
144
|
-
}
|
|
145
|
-
})
|
|
146
|
-
}, [postId, storePost, post?.id, provider])
|
|
147
|
-
|
|
148
|
-
if (!post || !draft) {
|
|
149
|
-
return (
|
|
150
|
-
<div className="mx-auto max-w-3xl space-y-5">
|
|
151
|
-
<div className="flex items-center justify-between">
|
|
152
|
-
<Skeleton className="h-8 w-56" />
|
|
153
|
-
<Skeleton className="h-8 w-40" />
|
|
154
|
-
</div>
|
|
155
|
-
<Skeleton className="h-10 w-full" />
|
|
156
|
-
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
157
|
-
{Array.from({ length: 3 }, (_, i) => <Skeleton key={i} className="h-14" />)}
|
|
158
|
-
</div>
|
|
159
|
-
<Skeleton className="h-64 w-full" />
|
|
160
|
-
</div>
|
|
161
|
-
)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const dirty =
|
|
165
|
-
draft.title !== post.title ||
|
|
166
|
-
draft.format !== post.format ||
|
|
167
|
-
draft.status !== post.status ||
|
|
168
|
-
draft.scheduledDate !== (post.scheduledDate ?? '') ||
|
|
169
|
-
draft.platforms.join(',') !== post.platforms.join(',') ||
|
|
170
|
-
draft.hook !== (post.hook ?? '') ||
|
|
171
|
-
draft.cta !== (post.cta ?? '') ||
|
|
172
|
-
draft.contentMd !== post.contentMd
|
|
173
|
-
|
|
174
|
-
const patch = (partial: Partial<Draft>) => setDraft((d) => (d ? { ...d, ...partial } : d))
|
|
175
|
-
|
|
176
|
-
const save = async () => {
|
|
177
|
-
setSaving(true)
|
|
178
|
-
try {
|
|
179
|
-
const updated = await savePost({
|
|
180
|
-
id: post.id,
|
|
181
|
-
title: draft.title,
|
|
182
|
-
format: draft.format,
|
|
183
|
-
status: draft.status,
|
|
184
|
-
scheduledDate: draft.scheduledDate || null,
|
|
185
|
-
platforms: draft.platforms,
|
|
186
|
-
hook: draft.hook,
|
|
187
|
-
cta: draft.cta,
|
|
188
|
-
contentMd: draft.contentMd,
|
|
189
|
-
})
|
|
190
|
-
setPost(updated)
|
|
191
|
-
setDraft(toDraft(updated))
|
|
192
|
-
} finally {
|
|
193
|
-
setSaving(false)
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const insertTemplate = () => {
|
|
198
|
-
patch({ contentMd: POST_TEMPLATES[draft.format](draft.title || t('marketing.content.untitled')) })
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const isStatic = draft.format === 'static'
|
|
202
|
-
const account = accounts.find((a) => a.id === activeAccountId) ?? null
|
|
203
|
-
|
|
204
|
-
// Recording day: the checklist panel starts expanded only when today IS the
|
|
205
|
-
// post's scheduled date — otherwise it waits collapsed in the rail.
|
|
206
|
-
const now = new Date()
|
|
207
|
-
const todayISO = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
208
|
-
const isReleaseDay = !!post.scheduledDate && post.scheduledDate === todayISO
|
|
209
|
-
|
|
210
|
-
const handleMediaChanged = (mediaUrl: string | null) => {
|
|
211
|
-
void savePost({ id: post.id, mediaUrl }).then((updated) => {
|
|
212
|
-
setPost(updated)
|
|
213
|
-
setDraft((d) => (d ? { ...d } : d))
|
|
214
|
-
})
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return (
|
|
218
|
-
<ContentSplit
|
|
219
|
-
key={post.id}
|
|
220
|
-
className="mx-auto max-w-5xl"
|
|
221
|
-
aside={<RecordingChecklist post={post} />}
|
|
222
|
-
asideTitle={t('marketing.content.checklistTitle')}
|
|
223
|
-
asideDescription={t('marketing.content.checklistDescription')}
|
|
224
|
-
collapsible
|
|
225
|
-
defaultCollapsed={!isReleaseDay}
|
|
226
|
-
>
|
|
227
|
-
<div className="space-y-5">
|
|
228
|
-
<SubpageHeader
|
|
229
|
-
title={draft.title || t('marketing.content.untitled')}
|
|
230
|
-
parentLabel={t('marketing.content.title')}
|
|
231
|
-
onBack={onBack}
|
|
232
|
-
actions={
|
|
233
|
-
<div className="flex items-center gap-2">
|
|
234
|
-
<ContentSplitTrigger label={t('marketing.content.checklistOpen')} />
|
|
235
|
-
<Button variant="outline" size="sm" onClick={() => setConfirmDelete(true)}>
|
|
236
|
-
{t('marketing.content.deletePost')}
|
|
237
|
-
</Button>
|
|
238
|
-
<Button size="sm" onClick={() => void save()} disabled={!dirty || saving}>
|
|
239
|
-
{t('marketing.content.save')}
|
|
240
|
-
</Button>
|
|
241
|
-
</div>
|
|
242
|
-
}
|
|
243
|
-
/>
|
|
244
|
-
|
|
245
|
-
<Input
|
|
246
|
-
value={draft.title}
|
|
247
|
-
onChange={(e) => patch({ title: e.target.value })}
|
|
248
|
-
placeholder={t('marketing.content.titlePlaceholder')}
|
|
249
|
-
className="text-base font-medium"
|
|
250
|
-
/>
|
|
251
|
-
|
|
252
|
-
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
253
|
-
<div className="space-y-1">
|
|
254
|
-
<label className="text-xs font-medium text-muted-foreground">{t('marketing.content.formatLabel')}</label>
|
|
255
|
-
<Select value={draft.format} onValueChange={(v) => patch({ format: v as PostFormat })}>
|
|
256
|
-
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
257
|
-
<SelectContent>
|
|
258
|
-
{POST_FORMATS.map((f) => (
|
|
259
|
-
<SelectItem key={f} value={f}>{t(`marketing.content.format.${f}`)}</SelectItem>
|
|
260
|
-
))}
|
|
261
|
-
</SelectContent>
|
|
262
|
-
</Select>
|
|
263
|
-
</div>
|
|
264
|
-
<div className="space-y-1">
|
|
265
|
-
<label className="text-xs font-medium text-muted-foreground">{t('marketing.content.statusLabel')}</label>
|
|
266
|
-
<Select value={draft.status} onValueChange={(v) => patch({ status: v as PostStatus })}>
|
|
267
|
-
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
268
|
-
<SelectContent>
|
|
269
|
-
{POST_STATUS_ORDER.map((s) => (
|
|
270
|
-
<SelectItem key={s} value={s}>
|
|
271
|
-
<span className="flex items-center gap-2">
|
|
272
|
-
<StatusDot status={s} />
|
|
273
|
-
{t(`marketing.content.status.${s}`)}
|
|
274
|
-
</span>
|
|
275
|
-
</SelectItem>
|
|
276
|
-
))}
|
|
277
|
-
</SelectContent>
|
|
278
|
-
</Select>
|
|
279
|
-
</div>
|
|
280
|
-
<div className="space-y-1">
|
|
281
|
-
<label className="text-xs font-medium text-muted-foreground">{t('marketing.content.scheduledDate')}</label>
|
|
282
|
-
<Input
|
|
283
|
-
type="date"
|
|
284
|
-
value={draft.scheduledDate}
|
|
285
|
-
onChange={(e) => patch({ scheduledDate: e.target.value })}
|
|
286
|
-
/>
|
|
287
|
-
</div>
|
|
288
|
-
<div className="space-y-1 sm:col-span-2">
|
|
289
|
-
<label className="text-xs font-medium text-muted-foreground">{t('marketing.content.hook')}</label>
|
|
290
|
-
<Input
|
|
291
|
-
value={draft.hook}
|
|
292
|
-
onChange={(e) => patch({ hook: e.target.value })}
|
|
293
|
-
placeholder={t('marketing.content.hookPlaceholder')}
|
|
294
|
-
/>
|
|
295
|
-
</div>
|
|
296
|
-
<div className="space-y-1">
|
|
297
|
-
<label className="text-xs font-medium text-muted-foreground">{t('marketing.content.cta')}</label>
|
|
298
|
-
<Input
|
|
299
|
-
value={draft.cta}
|
|
300
|
-
onChange={(e) => patch({ cta: e.target.value })}
|
|
301
|
-
placeholder={t('marketing.content.ctaPlaceholder')}
|
|
302
|
-
/>
|
|
303
|
-
</div>
|
|
304
|
-
<div className="space-y-1 sm:col-span-3">
|
|
305
|
-
<label className="text-xs font-medium text-muted-foreground">{t('marketing.content.platformsLabel')}</label>
|
|
306
|
-
<PlatformPicker value={draft.platforms} onChange={(platforms) => patch({ platforms })} />
|
|
307
|
-
</div>
|
|
308
|
-
</div>
|
|
309
|
-
|
|
310
|
-
{isStatic && (
|
|
311
|
-
<div className="space-y-1">
|
|
312
|
-
<label className="text-xs font-medium text-muted-foreground">
|
|
313
|
-
{t('marketing.content.mediaLabel')}
|
|
314
|
-
</label>
|
|
315
|
-
<MediaAsset post={post} onChanged={handleMediaChanged} />
|
|
316
|
-
</div>
|
|
317
|
-
)}
|
|
318
|
-
|
|
319
|
-
<div className="space-y-1">
|
|
320
|
-
<div className="flex items-center justify-between">
|
|
321
|
-
<label className="text-xs font-medium text-muted-foreground">
|
|
322
|
-
{isStatic ? t('marketing.content.captionLabel') : t('marketing.content.scriptLabel')}
|
|
323
|
-
</label>
|
|
324
|
-
{draft.contentMd.trim() === '' && (
|
|
325
|
-
<Button variant="outline" size="sm" onClick={insertTemplate}>
|
|
326
|
-
{t('marketing.content.insertTemplate')}
|
|
327
|
-
</Button>
|
|
328
|
-
)}
|
|
329
|
-
</div>
|
|
330
|
-
<MarkdownEditor
|
|
331
|
-
value={draft.contentMd}
|
|
332
|
-
onChange={(contentMd) => patch({ contentMd })}
|
|
333
|
-
placeholder={isStatic ? t('marketing.content.captionPlaceholder') : t('marketing.content.bodyPlaceholder')}
|
|
334
|
-
editLabel={t('marketing.content.edit')}
|
|
335
|
-
previewLabel={t('marketing.content.preview')}
|
|
336
|
-
renderPreview={
|
|
337
|
-
isStatic
|
|
338
|
-
? (value) => (
|
|
339
|
-
<PostSocialPreview
|
|
340
|
-
caption={value}
|
|
341
|
-
mediaUrl={post.mediaUrl}
|
|
342
|
-
accountName={account?.name ?? '@conta'}
|
|
343
|
-
accountHandle={account?.handle}
|
|
344
|
-
platforms={
|
|
345
|
-
draft.platforms.length > 0 ? draft.platforms : account?.platforms ?? []
|
|
346
|
-
}
|
|
347
|
-
/>
|
|
348
|
-
)
|
|
349
|
-
: undefined
|
|
350
|
-
}
|
|
351
|
-
/>
|
|
352
|
-
</div>
|
|
353
|
-
|
|
354
|
-
<ConfirmDialog
|
|
355
|
-
open={confirmDelete}
|
|
356
|
-
variant="destructive"
|
|
357
|
-
title={t('marketing.content.deletePost')}
|
|
358
|
-
description={t('marketing.content.deletePostConfirm')}
|
|
359
|
-
confirmLabel={t('marketing.content.deletePost')}
|
|
360
|
-
cancelLabel={t('marketing.content.cancel')}
|
|
361
|
-
onCancel={() => setConfirmDelete(false)}
|
|
362
|
-
onConfirm={() => {
|
|
363
|
-
setConfirmDelete(false)
|
|
364
|
-
void deletePost(post.id).then(onBack)
|
|
365
|
-
}}
|
|
366
|
-
/>
|
|
367
|
-
</div>
|
|
368
|
-
</ContentSplit>
|
|
369
|
-
)
|
|
370
|
-
}
|