@fayz-ai/plugin-marketing 0.9.0-next.1 → 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,908 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import {
|
|
3
|
-
Plus, NotebookPen, ChevronLeft, ChevronRight,
|
|
4
|
-
Columns3, CalendarDays, List as ListIcon, Trash2, GripVertical, Settings2,
|
|
5
|
-
} from 'lucide-react'
|
|
6
|
-
import type { SocialAccount } from '../../data/contentTypes'
|
|
7
|
-
import {
|
|
8
|
-
Button, Input, Skeleton, cn, ConfirmDialog, SegmentedControl,
|
|
9
|
-
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectSeparator,
|
|
10
|
-
Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter,
|
|
11
|
-
} from '@fayz-ai/ui'
|
|
12
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
13
|
-
import { useOrganizationStore, useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
|
|
14
|
-
import { PlanBriefSheet } from './PlanBriefView'
|
|
15
|
-
import type { ContentPost } from '../../data/contentTypes'
|
|
16
|
-
import { useContentPlannerStore } from './ContentPlannerContext'
|
|
17
|
-
import { FormatBadge, PostStatusBadge, PlatformBadges, PlatformPicker } from './contentBits'
|
|
18
|
-
import type { BoardViewMode } from './contentStore'
|
|
19
|
-
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
// Content board — the spreadsheet, productized. Three lenses on the same plan
|
|
22
|
-
// (content-planning best practice: plan by cadence, schedule by date, audit by
|
|
23
|
-
// status): Board (weeks as columns), Calendar (posts on real dates) and List
|
|
24
|
-
// (flat audit table). The page never blanks out: with no account or plan the
|
|
25
|
-
// calendar renders as an empty ghost grid and the account selector carries an
|
|
26
|
-
// inline add-account flow with platform connections.
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
const DEFAULT_WEEKS = 8
|
|
30
|
-
const ADD_ACCOUNT_VALUE = '__add-account__'
|
|
31
|
-
// Custom MIME so week columns only react to post-card drags
|
|
32
|
-
const POST_DRAG_MIME = 'application/x-mkt-post'
|
|
33
|
-
|
|
34
|
-
function PostCard({ post, onOpen, onDelete }: {
|
|
35
|
-
post: ContentPost
|
|
36
|
-
onOpen: () => void
|
|
37
|
-
onDelete?: () => void
|
|
38
|
-
}) {
|
|
39
|
-
const t = useTranslation()
|
|
40
|
-
const [dragging, setDragging] = React.useState(false)
|
|
41
|
-
return (
|
|
42
|
-
<div
|
|
43
|
-
role="button"
|
|
44
|
-
tabIndex={0}
|
|
45
|
-
onClick={onOpen}
|
|
46
|
-
onKeyDown={(e) => { if (e.key === 'Enter') onOpen() }}
|
|
47
|
-
draggable
|
|
48
|
-
onDragStart={(e) => {
|
|
49
|
-
e.dataTransfer.setData(POST_DRAG_MIME, post.id)
|
|
50
|
-
e.dataTransfer.effectAllowed = 'move'
|
|
51
|
-
setDragging(true)
|
|
52
|
-
}}
|
|
53
|
-
onDragEnd={() => setDragging(false)}
|
|
54
|
-
className={cn(
|
|
55
|
-
'group/post relative w-full cursor-pointer rounded-card border border-border bg-card p-3 pl-7 text-left transition-colors hover:bg-muted/40',
|
|
56
|
-
dragging && 'opacity-50',
|
|
57
|
-
)}
|
|
58
|
-
>
|
|
59
|
-
<GripVertical
|
|
60
|
-
className="absolute left-1.5 top-3 h-4 w-4 cursor-grab text-muted-foreground/50 opacity-0 transition-opacity group-hover/post:opacity-100"
|
|
61
|
-
aria-hidden
|
|
62
|
-
/>
|
|
63
|
-
{onDelete && (
|
|
64
|
-
<button
|
|
65
|
-
type="button"
|
|
66
|
-
aria-label={t('marketing.content.deletePost')}
|
|
67
|
-
onClick={(e) => {
|
|
68
|
-
e.stopPropagation()
|
|
69
|
-
onDelete()
|
|
70
|
-
}}
|
|
71
|
-
className="absolute right-1.5 top-2 rounded p-1 text-muted-foreground/60 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover/post:opacity-100"
|
|
72
|
-
>
|
|
73
|
-
<Trash2 className="h-3.5 w-3.5" />
|
|
74
|
-
</button>
|
|
75
|
-
)}
|
|
76
|
-
<p className="pr-5 text-sm font-medium text-foreground">
|
|
77
|
-
{post.title || t('marketing.content.untitled')}
|
|
78
|
-
</p>
|
|
79
|
-
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
|
80
|
-
<FormatBadge format={post.format} />
|
|
81
|
-
<PostStatusBadge status={post.status} />
|
|
82
|
-
{post.scheduledDate && (
|
|
83
|
-
<span className="text-xs text-muted-foreground">{post.scheduledDate}</span>
|
|
84
|
-
)}
|
|
85
|
-
<PlatformBadges platforms={post.platforms} />
|
|
86
|
-
</div>
|
|
87
|
-
</div>
|
|
88
|
-
)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function WeekColumn({ week, rangeLabel, posts, onOpenPost, onNewPost, onDeletePost, onDropPost, disabled }: {
|
|
92
|
-
week: number
|
|
93
|
-
/** Real date range of the week (e.g. "6 – 12 out"). */
|
|
94
|
-
rangeLabel: string
|
|
95
|
-
posts: ContentPost[]
|
|
96
|
-
onOpenPost: (id: string) => void
|
|
97
|
-
onNewPost: (week: number) => void
|
|
98
|
-
onDeletePost?: (id: string) => void
|
|
99
|
-
onDropPost?: (postId: string, week: number) => void
|
|
100
|
-
disabled?: boolean
|
|
101
|
-
}) {
|
|
102
|
-
const t = useTranslation()
|
|
103
|
-
const [isDropTarget, setIsDropTarget] = React.useState(false)
|
|
104
|
-
return (
|
|
105
|
-
<div
|
|
106
|
-
className={cn(
|
|
107
|
-
'flex flex-col gap-2 rounded-card border border-border bg-muted/20 p-2.5 transition-colors',
|
|
108
|
-
isDropTarget && 'border-primary bg-primary/5 ring-1 ring-primary/40',
|
|
109
|
-
)}
|
|
110
|
-
onDragOver={(e) => {
|
|
111
|
-
if (!onDropPost || !e.dataTransfer.types.includes(POST_DRAG_MIME)) return
|
|
112
|
-
e.preventDefault()
|
|
113
|
-
e.dataTransfer.dropEffect = 'move'
|
|
114
|
-
setIsDropTarget(true)
|
|
115
|
-
}}
|
|
116
|
-
onDragLeave={() => setIsDropTarget(false)}
|
|
117
|
-
onDrop={(e) => {
|
|
118
|
-
if (!onDropPost) return
|
|
119
|
-
e.preventDefault()
|
|
120
|
-
setIsDropTarget(false)
|
|
121
|
-
const postId = e.dataTransfer.getData(POST_DRAG_MIME)
|
|
122
|
-
if (postId) onDropPost(postId, week)
|
|
123
|
-
}}
|
|
124
|
-
>
|
|
125
|
-
<div className="flex items-baseline justify-between px-1">
|
|
126
|
-
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
127
|
-
{t('marketing.content.week')} {week}
|
|
128
|
-
</p>
|
|
129
|
-
<p className="text-xs text-muted-foreground/80">{rangeLabel}</p>
|
|
130
|
-
</div>
|
|
131
|
-
{posts.map((post) => (
|
|
132
|
-
<PostCard
|
|
133
|
-
key={post.id}
|
|
134
|
-
post={post}
|
|
135
|
-
onOpen={() => onOpenPost(post.id)}
|
|
136
|
-
onDelete={onDeletePost ? () => onDeletePost(post.id) : undefined}
|
|
137
|
-
/>
|
|
138
|
-
))}
|
|
139
|
-
<button
|
|
140
|
-
type="button"
|
|
141
|
-
onClick={() => onNewPost(week)}
|
|
142
|
-
disabled={disabled}
|
|
143
|
-
className="flex items-center justify-center gap-1 rounded-card border border-dashed border-border p-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
144
|
-
>
|
|
145
|
-
<Plus className="h-3.5 w-3.5" />
|
|
146
|
-
{t('marketing.content.newPost')}
|
|
147
|
-
</button>
|
|
148
|
-
</div>
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Account modal — create ("+" option) or settings (cog on an account):
|
|
153
|
-
* name, handle, platform connections, and delete on the edit variant. */
|
|
154
|
-
function AccountModal({ open, account, onClose }: {
|
|
155
|
-
open: boolean
|
|
156
|
-
account?: SocialAccount | null
|
|
157
|
-
onClose: () => void
|
|
158
|
-
}) {
|
|
159
|
-
const t = useTranslation()
|
|
160
|
-
const saveAccount = useContentPlannerStore((s) => s.saveAccount)
|
|
161
|
-
const deleteAccount = useContentPlannerStore((s) => s.deleteAccount)
|
|
162
|
-
const isEdit = !!account
|
|
163
|
-
const [name, setName] = React.useState('')
|
|
164
|
-
const [handle, setHandle] = React.useState('')
|
|
165
|
-
const [platforms, setPlatforms] = React.useState<string[]>(['instagram'])
|
|
166
|
-
const [saving, setSaving] = React.useState(false)
|
|
167
|
-
const [confirmDelete, setConfirmDelete] = React.useState(false)
|
|
168
|
-
|
|
169
|
-
// Prefill per open (edit) / reset (create) so drafts don't leak between uses
|
|
170
|
-
React.useEffect(() => {
|
|
171
|
-
if (open) {
|
|
172
|
-
setName(account?.name ?? '')
|
|
173
|
-
setHandle(account?.handle ?? '')
|
|
174
|
-
setPlatforms(account?.platforms?.length ? account.platforms : ['instagram'])
|
|
175
|
-
setSaving(false)
|
|
176
|
-
setConfirmDelete(false)
|
|
177
|
-
}
|
|
178
|
-
}, [open, account])
|
|
179
|
-
|
|
180
|
-
const create = async () => {
|
|
181
|
-
if (!name.trim() || platforms.length === 0 || saving) return
|
|
182
|
-
setSaving(true)
|
|
183
|
-
try {
|
|
184
|
-
await saveAccount({
|
|
185
|
-
...(isEdit ? { id: account.id } : {}),
|
|
186
|
-
name: name.trim(),
|
|
187
|
-
handle: handle.trim() || undefined,
|
|
188
|
-
platforms,
|
|
189
|
-
})
|
|
190
|
-
onClose()
|
|
191
|
-
} finally {
|
|
192
|
-
setSaving(false)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const remove = async () => {
|
|
197
|
-
if (!account || saving) return
|
|
198
|
-
setSaving(true)
|
|
199
|
-
try {
|
|
200
|
-
await deleteAccount(account.id)
|
|
201
|
-
onClose()
|
|
202
|
-
} finally {
|
|
203
|
-
setSaving(false)
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return (
|
|
208
|
-
<Modal open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
|
209
|
-
<ModalContent size="sm">
|
|
210
|
-
<ModalHeader>
|
|
211
|
-
<ModalTitle>
|
|
212
|
-
{isEdit ? t('marketing.content.editAccountTitle') : t('marketing.content.addAccountTitle')}
|
|
213
|
-
</ModalTitle>
|
|
214
|
-
</ModalHeader>
|
|
215
|
-
<ModalBody className="space-y-4">
|
|
216
|
-
<div className="space-y-1">
|
|
217
|
-
<label className="text-xs font-medium text-muted-foreground">
|
|
218
|
-
{t('marketing.content.nameLabel')}
|
|
219
|
-
</label>
|
|
220
|
-
<Input
|
|
221
|
-
autoFocus
|
|
222
|
-
value={name}
|
|
223
|
-
onChange={(e) => setName(e.target.value)}
|
|
224
|
-
placeholder={t('marketing.content.accountNamePlaceholder')}
|
|
225
|
-
onKeyDown={(e) => { if (e.key === 'Enter') void create() }}
|
|
226
|
-
/>
|
|
227
|
-
</div>
|
|
228
|
-
<div className="space-y-1">
|
|
229
|
-
<label className="text-xs font-medium text-muted-foreground">
|
|
230
|
-
{t('marketing.content.handleLabel')}
|
|
231
|
-
</label>
|
|
232
|
-
<Input
|
|
233
|
-
value={handle}
|
|
234
|
-
onChange={(e) => setHandle(e.target.value)}
|
|
235
|
-
placeholder={t('marketing.content.handlePlaceholder')}
|
|
236
|
-
/>
|
|
237
|
-
</div>
|
|
238
|
-
<div className="space-y-1">
|
|
239
|
-
<label className="text-xs font-medium text-muted-foreground">
|
|
240
|
-
{t('marketing.content.platformsLabel')}
|
|
241
|
-
</label>
|
|
242
|
-
<PlatformPicker value={platforms} onChange={setPlatforms} />
|
|
243
|
-
</div>
|
|
244
|
-
</ModalBody>
|
|
245
|
-
<ModalFooter>
|
|
246
|
-
{/* Delete is a two-step INLINE confirm — never a dialog stacked on a
|
|
247
|
-
modal (no modal-over-modal, house rule). */}
|
|
248
|
-
{confirmDelete && isEdit ? (
|
|
249
|
-
<div className="flex w-full items-center gap-3 rounded-card border border-destructive/30 bg-destructive/5 p-3">
|
|
250
|
-
<p className="flex-1 text-xs text-destructive">
|
|
251
|
-
{t('marketing.content.deleteAccountConfirm')}
|
|
252
|
-
</p>
|
|
253
|
-
<Button variant="outline" size="sm" onClick={() => setConfirmDelete(false)} disabled={saving}>
|
|
254
|
-
{t('marketing.content.cancel')}
|
|
255
|
-
</Button>
|
|
256
|
-
<Button variant="destructive" size="sm" onClick={() => void remove()} disabled={saving}>
|
|
257
|
-
{t('marketing.content.deleteAccount')}
|
|
258
|
-
</Button>
|
|
259
|
-
</div>
|
|
260
|
-
) : (
|
|
261
|
-
<>
|
|
262
|
-
{isEdit && (
|
|
263
|
-
<Button
|
|
264
|
-
variant="outline"
|
|
265
|
-
onClick={() => setConfirmDelete(true)}
|
|
266
|
-
disabled={saving}
|
|
267
|
-
className="mr-auto text-destructive hover:text-destructive"
|
|
268
|
-
>
|
|
269
|
-
<Trash2 className="mr-1.5 h-4 w-4" />
|
|
270
|
-
{t('marketing.content.deleteAccount')}
|
|
271
|
-
</Button>
|
|
272
|
-
)}
|
|
273
|
-
<Button variant="outline" onClick={onClose} disabled={saving}>
|
|
274
|
-
{t('marketing.content.cancel')}
|
|
275
|
-
</Button>
|
|
276
|
-
<Button onClick={() => void create()} disabled={saving || !name.trim() || platforms.length === 0}>
|
|
277
|
-
{isEdit ? t('marketing.content.save') : t('marketing.content.createAccount')}
|
|
278
|
-
</Button>
|
|
279
|
-
</>
|
|
280
|
-
)}
|
|
281
|
-
</ModalFooter>
|
|
282
|
-
</ModalContent>
|
|
283
|
-
</Modal>
|
|
284
|
-
)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** Master account dropdown — always visible at the top. Each account carries
|
|
288
|
-
* a cog that opens its settings (edit / delete); the last option creates. */
|
|
289
|
-
function AccountSelector() {
|
|
290
|
-
const t = useTranslation()
|
|
291
|
-
const loaded = useContentPlannerStore((s) => s.loaded)
|
|
292
|
-
const accounts = useContentPlannerStore((s) => s.accounts)
|
|
293
|
-
const activeAccountId = useContentPlannerStore((s) => s.activeAccountId)
|
|
294
|
-
const setAccount = useContentPlannerStore((s) => s.setAccount)
|
|
295
|
-
const [open, setOpen] = React.useState(false)
|
|
296
|
-
const [modal, setModal] = React.useState<{ mode: 'create' } | { mode: 'edit'; account: SocialAccount } | null>(null)
|
|
297
|
-
|
|
298
|
-
const activeAccount = accounts.find((a) => a.id === activeAccountId) ?? null
|
|
299
|
-
|
|
300
|
-
if (!loaded) {
|
|
301
|
-
return (
|
|
302
|
-
<div className="flex items-center gap-1">
|
|
303
|
-
<Skeleton className="h-9 w-64" />
|
|
304
|
-
<Skeleton className="h-9 w-9" />
|
|
305
|
-
</div>
|
|
306
|
-
)
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const openSettings = (account: SocialAccount) => {
|
|
310
|
-
setOpen(false)
|
|
311
|
-
setModal({ mode: 'edit', account })
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
return (
|
|
315
|
-
<>
|
|
316
|
-
<div className="flex items-center gap-1">
|
|
317
|
-
<Select
|
|
318
|
-
open={open}
|
|
319
|
-
onOpenChange={setOpen}
|
|
320
|
-
value={activeAccountId ?? undefined}
|
|
321
|
-
onValueChange={(id) => {
|
|
322
|
-
if (id === ADD_ACCOUNT_VALUE) {
|
|
323
|
-
setModal({ mode: 'create' })
|
|
324
|
-
return
|
|
325
|
-
}
|
|
326
|
-
void setAccount(id)
|
|
327
|
-
}}
|
|
328
|
-
>
|
|
329
|
-
<SelectTrigger className="w-64">
|
|
330
|
-
<SelectValue placeholder={t('marketing.content.account')} />
|
|
331
|
-
</SelectTrigger>
|
|
332
|
-
<SelectContent>
|
|
333
|
-
{accounts.length === 0 ? (
|
|
334
|
-
<SelectItem value="__none__" disabled>
|
|
335
|
-
{t('marketing.content.noAccountsAvailable')}
|
|
336
|
-
</SelectItem>
|
|
337
|
-
) : (
|
|
338
|
-
accounts.map((acc) => (
|
|
339
|
-
<div key={acc.id} className="relative">
|
|
340
|
-
<SelectItem value={acc.id} className="pr-9">
|
|
341
|
-
<span className="flex items-center gap-2">
|
|
342
|
-
{acc.name}
|
|
343
|
-
<PlatformBadges platforms={acc.platforms} />
|
|
344
|
-
</span>
|
|
345
|
-
</SelectItem>
|
|
346
|
-
{/* Sits on top of the item — pointer events stop here so the
|
|
347
|
-
cog opens settings instead of selecting the account. */}
|
|
348
|
-
<button
|
|
349
|
-
type="button"
|
|
350
|
-
aria-label={t('marketing.content.accountSettings')}
|
|
351
|
-
onPointerDown={(e) => { e.stopPropagation(); e.preventDefault() }}
|
|
352
|
-
onPointerUp={(e) => { e.stopPropagation(); e.preventDefault() }}
|
|
353
|
-
onClick={(e) => {
|
|
354
|
-
e.stopPropagation()
|
|
355
|
-
openSettings(acc)
|
|
356
|
-
}}
|
|
357
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
358
|
-
>
|
|
359
|
-
<Settings2 className="h-3.5 w-3.5" />
|
|
360
|
-
</button>
|
|
361
|
-
</div>
|
|
362
|
-
))
|
|
363
|
-
)}
|
|
364
|
-
<SelectSeparator />
|
|
365
|
-
<SelectItem value={ADD_ACCOUNT_VALUE}>
|
|
366
|
-
<span className="flex items-center gap-1.5">
|
|
367
|
-
<Plus className="h-3.5 w-3.5" />
|
|
368
|
-
{t('marketing.content.addAccount')}
|
|
369
|
-
</span>
|
|
370
|
-
</SelectItem>
|
|
371
|
-
</SelectContent>
|
|
372
|
-
</Select>
|
|
373
|
-
{activeAccount && (
|
|
374
|
-
<Button
|
|
375
|
-
variant="ghost"
|
|
376
|
-
size="sm"
|
|
377
|
-
className="h-9 w-9 p-0 text-muted-foreground"
|
|
378
|
-
aria-label={t('marketing.content.accountSettings')}
|
|
379
|
-
onClick={() => setModal({ mode: 'edit', account: activeAccount })}
|
|
380
|
-
>
|
|
381
|
-
<Settings2 className="h-4 w-4" />
|
|
382
|
-
</Button>
|
|
383
|
-
)}
|
|
384
|
-
</div>
|
|
385
|
-
<AccountModal
|
|
386
|
-
open={!!modal}
|
|
387
|
-
account={modal?.mode === 'edit' ? modal.account : null}
|
|
388
|
-
onClose={() => setModal(null)}
|
|
389
|
-
/>
|
|
390
|
-
</>
|
|
391
|
-
)
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// ---------------------------------------------------------------------------
|
|
395
|
-
// View switcher
|
|
396
|
-
// ---------------------------------------------------------------------------
|
|
397
|
-
|
|
398
|
-
function ViewSwitcher({ mode, onChange }: { mode: BoardViewMode; onChange: (m: BoardViewMode) => void }) {
|
|
399
|
-
const t = useTranslation()
|
|
400
|
-
return (
|
|
401
|
-
<SegmentedControl<BoardViewMode>
|
|
402
|
-
size="md"
|
|
403
|
-
aria-label={t('marketing.content.viewBoard')}
|
|
404
|
-
value={mode}
|
|
405
|
-
onChange={onChange}
|
|
406
|
-
options={[
|
|
407
|
-
{ value: 'board', label: <span className="flex items-center gap-1.5"><Columns3 className="h-3.5 w-3.5" />{t('marketing.content.viewBoard')}</span> },
|
|
408
|
-
{ value: 'calendar', label: <span className="flex items-center gap-1.5"><CalendarDays className="h-3.5 w-3.5" />{t('marketing.content.viewCalendar')}</span> },
|
|
409
|
-
{ value: 'list', label: <span className="flex items-center gap-1.5"><ListIcon className="h-3.5 w-3.5" />{t('marketing.content.viewList')}</span> },
|
|
410
|
-
]}
|
|
411
|
-
/>
|
|
412
|
-
)
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// ---------------------------------------------------------------------------
|
|
416
|
-
// Board view — weeks as columns grouped by month blocks (cadence planning)
|
|
417
|
-
// ---------------------------------------------------------------------------
|
|
418
|
-
|
|
419
|
-
interface BoardWeek {
|
|
420
|
-
week: number
|
|
421
|
-
rangeLabel: string
|
|
422
|
-
monthLabel: string
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function shortDay(d: Date): string {
|
|
426
|
-
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/** Anchor each plan week to real dates (same reference the calendar view
|
|
430
|
-
* uses): week N starts at startDate + (N-1) weeks; month sections group by
|
|
431
|
-
* the month the week starts in. */
|
|
432
|
-
function buildBoardWeeks(startDate: Date, weeksCount: number): BoardWeek[] {
|
|
433
|
-
return Array.from({ length: weeksCount }, (_, i) => {
|
|
434
|
-
const start = new Date(startDate)
|
|
435
|
-
start.setDate(start.getDate() + i * 7)
|
|
436
|
-
const end = new Date(start)
|
|
437
|
-
end.setDate(start.getDate() + 6)
|
|
438
|
-
return {
|
|
439
|
-
week: i + 1,
|
|
440
|
-
rangeLabel: `${shortDay(start)} – ${shortDay(end)}`,
|
|
441
|
-
monthLabel: start.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }),
|
|
442
|
-
}
|
|
443
|
-
})
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function BoardView({ startDate, weeksCount, postsByWeek, onOpenPost, onNewPost, onDeletePost, onDropPost, disabled }: {
|
|
447
|
-
startDate: Date
|
|
448
|
-
weeksCount: number
|
|
449
|
-
postsByWeek: Map<number, ContentPost[]>
|
|
450
|
-
onOpenPost: (id: string) => void
|
|
451
|
-
onNewPost: (week: number) => void
|
|
452
|
-
onDeletePost: (id: string) => void
|
|
453
|
-
onDropPost: (postId: string, week: number) => void
|
|
454
|
-
disabled: boolean
|
|
455
|
-
}) {
|
|
456
|
-
const months: Array<{ label: string; weeks: BoardWeek[] }> = []
|
|
457
|
-
for (const bw of buildBoardWeeks(startDate, weeksCount)) {
|
|
458
|
-
const current = months[months.length - 1]
|
|
459
|
-
if (current && current.label === bw.monthLabel) current.weeks.push(bw)
|
|
460
|
-
else months.push({ label: bw.monthLabel, weeks: [bw] })
|
|
461
|
-
}
|
|
462
|
-
return (
|
|
463
|
-
<div className="space-y-8">
|
|
464
|
-
{months.map(({ label, weeks }) => (
|
|
465
|
-
<section key={label} className="space-y-3">
|
|
466
|
-
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
467
|
-
{label}
|
|
468
|
-
</h3>
|
|
469
|
-
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
470
|
-
{weeks.map(({ week, rangeLabel }) => (
|
|
471
|
-
<WeekColumn
|
|
472
|
-
key={week}
|
|
473
|
-
week={week}
|
|
474
|
-
rangeLabel={rangeLabel}
|
|
475
|
-
posts={postsByWeek.get(week) ?? []}
|
|
476
|
-
onOpenPost={onOpenPost}
|
|
477
|
-
onNewPost={onNewPost}
|
|
478
|
-
onDeletePost={onDeletePost}
|
|
479
|
-
onDropPost={onDropPost}
|
|
480
|
-
disabled={disabled}
|
|
481
|
-
/>
|
|
482
|
-
))}
|
|
483
|
-
</div>
|
|
484
|
-
</section>
|
|
485
|
-
))}
|
|
486
|
-
</div>
|
|
487
|
-
)
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// ---------------------------------------------------------------------------
|
|
491
|
-
// Calendar view — posts pinned to real dates, month by month
|
|
492
|
-
// ---------------------------------------------------------------------------
|
|
493
|
-
|
|
494
|
-
function toISODate(d: Date): string {
|
|
495
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function mondayOfCurrentWeek(): Date {
|
|
499
|
-
const now = new Date()
|
|
500
|
-
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
501
|
-
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
|
|
502
|
-
return monday
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function CalendarView({ posts, onOpenPost, onNewPostAt, disabled }: {
|
|
506
|
-
posts: ContentPost[]
|
|
507
|
-
onOpenPost: (id: string) => void
|
|
508
|
-
onNewPostAt: (isoDate: string) => void
|
|
509
|
-
disabled: boolean
|
|
510
|
-
}) {
|
|
511
|
-
const t = useTranslation()
|
|
512
|
-
const [cursor, setCursor] = React.useState(() => {
|
|
513
|
-
const first = posts.find((p) => p.scheduledDate)?.scheduledDate
|
|
514
|
-
const base = first ? new Date(`${first}T00:00:00`) : new Date()
|
|
515
|
-
return new Date(base.getFullYear(), base.getMonth(), 1)
|
|
516
|
-
})
|
|
517
|
-
// Days showing their full list when they hold more posts than the cap
|
|
518
|
-
const [expandedDay, setExpandedDay] = React.useState<string | null>(null)
|
|
519
|
-
const MAX_VISIBLE = 3
|
|
520
|
-
|
|
521
|
-
const postsByDate = React.useMemo(() => {
|
|
522
|
-
const map = new Map<string, ContentPost[]>()
|
|
523
|
-
for (const post of posts) {
|
|
524
|
-
if (!post.scheduledDate) continue
|
|
525
|
-
const list = map.get(post.scheduledDate) ?? []
|
|
526
|
-
list.push(post)
|
|
527
|
-
map.set(post.scheduledDate, list)
|
|
528
|
-
}
|
|
529
|
-
return map
|
|
530
|
-
}, [posts])
|
|
531
|
-
|
|
532
|
-
const unscheduled = posts.filter((p) => !p.scheduledDate)
|
|
533
|
-
|
|
534
|
-
// Monday-first grid covering the whole displayed month.
|
|
535
|
-
const days = React.useMemo(() => {
|
|
536
|
-
const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1)
|
|
537
|
-
const start = new Date(firstOfMonth)
|
|
538
|
-
start.setDate(start.getDate() - ((firstOfMonth.getDay() + 6) % 7))
|
|
539
|
-
const cells: Date[] = []
|
|
540
|
-
for (let i = 0; i < 42; i++) {
|
|
541
|
-
const d = new Date(start)
|
|
542
|
-
d.setDate(start.getDate() + i)
|
|
543
|
-
cells.push(d)
|
|
544
|
-
}
|
|
545
|
-
// Trim trailing full weeks outside the month
|
|
546
|
-
while (cells.length > 7 && cells[cells.length - 7].getMonth() !== cursor.getMonth()) {
|
|
547
|
-
cells.splice(cells.length - 7, 7)
|
|
548
|
-
}
|
|
549
|
-
return cells
|
|
550
|
-
}, [cursor])
|
|
551
|
-
|
|
552
|
-
const monthLabel = cursor.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
|
553
|
-
const weekdayLabels = days.slice(0, 7).map((d) =>
|
|
554
|
-
d.toLocaleDateString(undefined, { weekday: 'short' }),
|
|
555
|
-
)
|
|
556
|
-
const todayISO = toISODate(new Date())
|
|
557
|
-
|
|
558
|
-
return (
|
|
559
|
-
<div className="space-y-4">
|
|
560
|
-
<div className="flex items-center justify-between">
|
|
561
|
-
<h3 className="text-sm font-semibold capitalize text-foreground">{monthLabel}</h3>
|
|
562
|
-
<div className="flex items-center gap-1">
|
|
563
|
-
<Button variant="ghost" size="sm" onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1))}>
|
|
564
|
-
<ChevronLeft className="h-4 w-4" />
|
|
565
|
-
</Button>
|
|
566
|
-
<Button variant="ghost" size="sm" onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1))}>
|
|
567
|
-
<ChevronRight className="h-4 w-4" />
|
|
568
|
-
</Button>
|
|
569
|
-
</div>
|
|
570
|
-
</div>
|
|
571
|
-
|
|
572
|
-
{unscheduled.length > 0 && (
|
|
573
|
-
<div className="flex flex-wrap items-center gap-2 rounded-card border border-dashed border-border bg-muted/20 p-2.5">
|
|
574
|
-
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
575
|
-
{t('marketing.content.unscheduled')}
|
|
576
|
-
</span>
|
|
577
|
-
{unscheduled.map((post) => (
|
|
578
|
-
<button
|
|
579
|
-
key={post.id}
|
|
580
|
-
type="button"
|
|
581
|
-
onClick={() => onOpenPost(post.id)}
|
|
582
|
-
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted/40"
|
|
583
|
-
>
|
|
584
|
-
{post.title || t('marketing.content.untitled')}
|
|
585
|
-
<FormatBadge format={post.format} />
|
|
586
|
-
</button>
|
|
587
|
-
))}
|
|
588
|
-
</div>
|
|
589
|
-
)}
|
|
590
|
-
|
|
591
|
-
<div className="overflow-hidden rounded-card border border-border">
|
|
592
|
-
<div className="grid grid-cols-7 border-b border-border bg-muted/30">
|
|
593
|
-
{weekdayLabels.map((label, i) => (
|
|
594
|
-
<div key={i} className="px-2 py-1.5 text-center text-xs font-medium capitalize text-muted-foreground">
|
|
595
|
-
{label}
|
|
596
|
-
</div>
|
|
597
|
-
))}
|
|
598
|
-
</div>
|
|
599
|
-
<div className="grid grid-cols-7">
|
|
600
|
-
{days.map((day, i) => {
|
|
601
|
-
const iso = toISODate(day)
|
|
602
|
-
const inMonth = day.getMonth() === cursor.getMonth()
|
|
603
|
-
const dayPosts = postsByDate.get(iso) ?? []
|
|
604
|
-
return (
|
|
605
|
-
<div
|
|
606
|
-
key={i}
|
|
607
|
-
className={cn(
|
|
608
|
-
'group min-h-24 border-b border-r border-border p-1.5 [&:nth-child(7n)]:border-r-0',
|
|
609
|
-
!inMonth && 'bg-muted/20',
|
|
610
|
-
)}
|
|
611
|
-
>
|
|
612
|
-
<div className="flex items-center justify-between">
|
|
613
|
-
<span
|
|
614
|
-
className={cn(
|
|
615
|
-
'text-xs',
|
|
616
|
-
inMonth ? 'text-foreground' : 'text-muted-foreground/60',
|
|
617
|
-
iso === todayISO && 'flex h-5 w-5 items-center justify-center rounded-full bg-primary font-semibold text-primary-foreground',
|
|
618
|
-
)}
|
|
619
|
-
>
|
|
620
|
-
{day.getDate()}
|
|
621
|
-
</span>
|
|
622
|
-
{!disabled && (
|
|
623
|
-
<button
|
|
624
|
-
type="button"
|
|
625
|
-
onClick={() => onNewPostAt(iso)}
|
|
626
|
-
className="rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:bg-muted/60 hover:text-foreground group-hover:opacity-100"
|
|
627
|
-
aria-label={t('marketing.content.newPost')}
|
|
628
|
-
>
|
|
629
|
-
<Plus className="h-3.5 w-3.5" />
|
|
630
|
-
</button>
|
|
631
|
-
)}
|
|
632
|
-
</div>
|
|
633
|
-
<div className="mt-1 space-y-1">
|
|
634
|
-
{(expandedDay === iso ? dayPosts : dayPosts.slice(0, MAX_VISIBLE)).map((post) => (
|
|
635
|
-
<button
|
|
636
|
-
key={post.id}
|
|
637
|
-
type="button"
|
|
638
|
-
onClick={() => onOpenPost(post.id)}
|
|
639
|
-
className="block w-full truncate rounded border border-border bg-card px-1.5 py-1 text-left text-xs text-foreground transition-colors hover:bg-muted/40"
|
|
640
|
-
>
|
|
641
|
-
{post.title || t('marketing.content.untitled')}
|
|
642
|
-
</button>
|
|
643
|
-
))}
|
|
644
|
-
{dayPosts.length > MAX_VISIBLE && (
|
|
645
|
-
<button
|
|
646
|
-
type="button"
|
|
647
|
-
onClick={() => setExpandedDay(expandedDay === iso ? null : iso)}
|
|
648
|
-
className="block w-full px-1.5 py-0.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
649
|
-
>
|
|
650
|
-
{expandedDay === iso
|
|
651
|
-
? t('marketing.content.showLess')
|
|
652
|
-
: `+${dayPosts.length - MAX_VISIBLE}`}
|
|
653
|
-
</button>
|
|
654
|
-
)}
|
|
655
|
-
</div>
|
|
656
|
-
</div>
|
|
657
|
-
)
|
|
658
|
-
})}
|
|
659
|
-
</div>
|
|
660
|
-
</div>
|
|
661
|
-
</div>
|
|
662
|
-
)
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// ---------------------------------------------------------------------------
|
|
666
|
-
// List view — flat audit table (sorted by week, then position)
|
|
667
|
-
// ---------------------------------------------------------------------------
|
|
668
|
-
|
|
669
|
-
function ListView({ posts, onOpenPost }: { posts: ContentPost[]; onOpenPost: (id: string) => void }) {
|
|
670
|
-
const t = useTranslation()
|
|
671
|
-
if (posts.length === 0) {
|
|
672
|
-
return <p className="text-sm text-muted-foreground">{t('marketing.content.noPosts')}</p>
|
|
673
|
-
}
|
|
674
|
-
return (
|
|
675
|
-
<div className="overflow-hidden rounded-card border border-border">
|
|
676
|
-
<table className="w-full text-sm">
|
|
677
|
-
<thead>
|
|
678
|
-
<tr className="border-b border-border bg-muted/30 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
679
|
-
<th className="px-3 py-2">{t('marketing.content.listWeek')}</th>
|
|
680
|
-
<th className="px-3 py-2">{t('marketing.content.listTitle')}</th>
|
|
681
|
-
<th className="px-3 py-2">{t('marketing.content.listFormat')}</th>
|
|
682
|
-
<th className="px-3 py-2">{t('marketing.content.listStatus')}</th>
|
|
683
|
-
<th className="px-3 py-2">{t('marketing.content.listDate')}</th>
|
|
684
|
-
</tr>
|
|
685
|
-
</thead>
|
|
686
|
-
<tbody>
|
|
687
|
-
{posts.map((post) => (
|
|
688
|
-
<tr
|
|
689
|
-
key={post.id}
|
|
690
|
-
onClick={() => onOpenPost(post.id)}
|
|
691
|
-
className="cursor-pointer border-b border-border last:border-b-0 transition-colors hover:bg-muted/30"
|
|
692
|
-
>
|
|
693
|
-
<td className="px-3 py-2 text-muted-foreground">{post.weekNumber}</td>
|
|
694
|
-
<td className="px-3 py-2 font-medium text-foreground">
|
|
695
|
-
<span className="flex items-center gap-2">
|
|
696
|
-
{post.title || t('marketing.content.untitled')}
|
|
697
|
-
<PlatformBadges platforms={post.platforms} />
|
|
698
|
-
</span>
|
|
699
|
-
</td>
|
|
700
|
-
<td className="px-3 py-2"><FormatBadge format={post.format} /></td>
|
|
701
|
-
<td className="px-3 py-2"><PostStatusBadge status={post.status} /></td>
|
|
702
|
-
<td className="px-3 py-2 text-muted-foreground">{post.scheduledDate ?? '—'}</td>
|
|
703
|
-
</tr>
|
|
704
|
-
))}
|
|
705
|
-
</tbody>
|
|
706
|
-
</table>
|
|
707
|
-
</div>
|
|
708
|
-
)
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// ---------------------------------------------------------------------------
|
|
712
|
-
|
|
713
|
-
export function ContentView({ onOpenPost }: {
|
|
714
|
-
onOpenPost: (id: string) => void
|
|
715
|
-
}) {
|
|
716
|
-
const t = useTranslation()
|
|
717
|
-
const loaded = useContentPlannerStore((s) => s.loaded)
|
|
718
|
-
const loading = useContentPlannerStore((s) => s.loading)
|
|
719
|
-
const accounts = useContentPlannerStore((s) => s.accounts)
|
|
720
|
-
const activeAccountId = useContentPlannerStore((s) => s.activeAccountId)
|
|
721
|
-
const plan = useContentPlannerStore((s) => s.activePlan)
|
|
722
|
-
const posts = useContentPlannerStore((s) => s.posts)
|
|
723
|
-
const load = useContentPlannerStore((s) => s.load)
|
|
724
|
-
const savePlan = useContentPlannerStore((s) => s.savePlan)
|
|
725
|
-
const savePost = useContentPlannerStore((s) => s.savePost)
|
|
726
|
-
const deletePost = useContentPlannerStore((s) => s.deletePost)
|
|
727
|
-
const viewMode = useContentPlannerStore((s) => s.viewMode)
|
|
728
|
-
const setViewMode = useContentPlannerStore((s) => s.setViewMode)
|
|
729
|
-
const loadedTenantId = useContentPlannerStore((s) => s.loadedTenantId)
|
|
730
|
-
const guardContentPosts = useLimitGuard('content_posts_month')
|
|
731
|
-
|
|
732
|
-
const [briefOpen, setBriefOpen] = React.useState(false)
|
|
733
|
-
const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
|
|
734
|
-
|
|
735
|
-
// The tenant hydrates async after login/refresh — (re)load whenever the
|
|
736
|
-
// active tenant diverges from the one the current data belongs to. Also
|
|
737
|
-
// covers org switching.
|
|
738
|
-
const tenantId = useOrganizationStore((state) => state.currentOrg?.id)
|
|
739
|
-
React.useEffect(() => {
|
|
740
|
-
if (!loading && (!loaded || (tenantId && tenantId !== loadedTenantId))) void load()
|
|
741
|
-
}, [tenantId, loadedTenantId, loaded, loading, load])
|
|
742
|
-
|
|
743
|
-
const postsByWeek = React.useMemo(() => {
|
|
744
|
-
const map = new Map<number, ContentPost[]>()
|
|
745
|
-
for (const post of posts) {
|
|
746
|
-
const list = map.get(post.weekNumber) ?? []
|
|
747
|
-
list.push(post)
|
|
748
|
-
map.set(post.weekNumber, list)
|
|
749
|
-
}
|
|
750
|
-
return map
|
|
751
|
-
}, [posts])
|
|
752
|
-
|
|
753
|
-
const ensurePlan = async () => {
|
|
754
|
-
if (plan) return plan
|
|
755
|
-
if (!activeAccountId) return null
|
|
756
|
-
return savePlan({
|
|
757
|
-
accountId: activeAccountId,
|
|
758
|
-
name: t('marketing.content.defaultPlanName'),
|
|
759
|
-
status: 'active',
|
|
760
|
-
weeksCount: DEFAULT_WEEKS,
|
|
761
|
-
startDate: toISODate(mondayOfCurrentWeek()),
|
|
762
|
-
})
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// With an account but no plan yet, the first "new post" click bootstraps the
|
|
766
|
-
// default plan and drops the post straight into it. The scheduled date
|
|
767
|
-
// prefills with the first day of the selected week so the post lands dated.
|
|
768
|
-
const createPost = async (week: number) => {
|
|
769
|
-
// Client-side monthly-quota guard before the store create.
|
|
770
|
-
if ((await guardContentPosts()) === 'blocked') return
|
|
771
|
-
const targetPlan = await ensurePlan()
|
|
772
|
-
if (!targetPlan) return
|
|
773
|
-
const start = targetPlan.startDate
|
|
774
|
-
? new Date(`${targetPlan.startDate}T00:00:00`)
|
|
775
|
-
: mondayOfCurrentWeek()
|
|
776
|
-
start.setDate(start.getDate() + (week - 1) * 7)
|
|
777
|
-
const post = await savePost({
|
|
778
|
-
planId: targetPlan.id,
|
|
779
|
-
weekNumber: week,
|
|
780
|
-
title: '',
|
|
781
|
-
format: 'reel',
|
|
782
|
-
status: 'idea',
|
|
783
|
-
scheduledDate: toISODate(start),
|
|
784
|
-
})
|
|
785
|
-
invalidateLimit('content_posts_month')
|
|
786
|
-
onOpenPost(post.id)
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
const movePost = async (postId: string, week: number) => {
|
|
790
|
-
const post = posts.find((p) => p.id === postId)
|
|
791
|
-
if (!post || post.weekNumber === week) return
|
|
792
|
-
await savePost({ id: postId, weekNumber: week })
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// Calendar day click → derive the plan week from startDate when available.
|
|
796
|
-
const createPostAt = async (isoDate: string) => {
|
|
797
|
-
// Client-side monthly-quota guard before the store create.
|
|
798
|
-
if ((await guardContentPosts()) === 'blocked') return
|
|
799
|
-
const targetPlan = await ensurePlan()
|
|
800
|
-
if (!targetPlan) return
|
|
801
|
-
let week = 1
|
|
802
|
-
if (targetPlan.startDate) {
|
|
803
|
-
const start = new Date(`${targetPlan.startDate}T00:00:00`)
|
|
804
|
-
const day = new Date(`${isoDate}T00:00:00`)
|
|
805
|
-
const diff = Math.floor((day.getTime() - start.getTime()) / (7 * 24 * 3600 * 1000)) + 1
|
|
806
|
-
week = Math.min(Math.max(diff, 1), targetPlan.weeksCount)
|
|
807
|
-
}
|
|
808
|
-
const post = await savePost({
|
|
809
|
-
planId: targetPlan.id,
|
|
810
|
-
weekNumber: week,
|
|
811
|
-
title: '',
|
|
812
|
-
format: 'reel',
|
|
813
|
-
status: 'idea',
|
|
814
|
-
scheduledDate: isoDate,
|
|
815
|
-
})
|
|
816
|
-
invalidateLimit('content_posts_month')
|
|
817
|
-
onOpenPost(post.id)
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// Initial load, tenant hydration and account switches all re-skeleton the
|
|
821
|
-
// board so stale posts never flash under the new selection.
|
|
822
|
-
const busy = !loaded || loading
|
|
823
|
-
|
|
824
|
-
const hasAccount = accounts.length > 0
|
|
825
|
-
const weeksCount = plan?.weeksCount ?? DEFAULT_WEEKS
|
|
826
|
-
// Same date anchor the calendar view uses. Plans without a start date
|
|
827
|
-
// (legacy rows) display against the current week.
|
|
828
|
-
const boardStart = plan?.startDate
|
|
829
|
-
? new Date(`${plan.startDate}T00:00:00`)
|
|
830
|
-
: mondayOfCurrentWeek()
|
|
831
|
-
|
|
832
|
-
return (
|
|
833
|
-
<div className="mx-auto max-w-6xl space-y-6">
|
|
834
|
-
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
835
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
836
|
-
<AccountSelector />
|
|
837
|
-
{plan && <span className="text-sm text-muted-foreground">{plan.name}</span>}
|
|
838
|
-
</div>
|
|
839
|
-
<div className="flex items-center gap-2">
|
|
840
|
-
<ViewSwitcher mode={viewMode} onChange={setViewMode} />
|
|
841
|
-
{plan && (
|
|
842
|
-
<Button variant="outline" size="sm" onClick={() => setBriefOpen(true)}>
|
|
843
|
-
<NotebookPen className="mr-1.5 h-4 w-4" />
|
|
844
|
-
{t('marketing.content.brief')}
|
|
845
|
-
</Button>
|
|
846
|
-
)}
|
|
847
|
-
</div>
|
|
848
|
-
</div>
|
|
849
|
-
|
|
850
|
-
{!busy && !hasAccount && (
|
|
851
|
-
<p className="text-sm text-muted-foreground">
|
|
852
|
-
{t('marketing.content.noAccountsHint')}
|
|
853
|
-
</p>
|
|
854
|
-
)}
|
|
855
|
-
|
|
856
|
-
{busy && viewMode === 'board' && (
|
|
857
|
-
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
858
|
-
{Array.from({ length: 8 }, (_, i) => <Skeleton key={i} className="h-44" />)}
|
|
859
|
-
</div>
|
|
860
|
-
)}
|
|
861
|
-
{busy && viewMode === 'calendar' && <Skeleton className="h-96 w-full" />}
|
|
862
|
-
{busy && viewMode === 'list' && (
|
|
863
|
-
<div className="space-y-2">
|
|
864
|
-
{Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
|
865
|
-
</div>
|
|
866
|
-
)}
|
|
867
|
-
|
|
868
|
-
{!busy && viewMode === 'board' && (
|
|
869
|
-
<BoardView
|
|
870
|
-
startDate={boardStart}
|
|
871
|
-
weeksCount={weeksCount}
|
|
872
|
-
postsByWeek={postsByWeek}
|
|
873
|
-
onOpenPost={onOpenPost}
|
|
874
|
-
onNewPost={(w) => void createPost(w)}
|
|
875
|
-
onDeletePost={setPendingDeleteId}
|
|
876
|
-
onDropPost={(id, w) => void movePost(id, w)}
|
|
877
|
-
disabled={!hasAccount}
|
|
878
|
-
/>
|
|
879
|
-
)}
|
|
880
|
-
{!busy && viewMode === 'calendar' && (
|
|
881
|
-
<CalendarView
|
|
882
|
-
posts={posts}
|
|
883
|
-
onOpenPost={onOpenPost}
|
|
884
|
-
onNewPostAt={(iso) => void createPostAt(iso)}
|
|
885
|
-
disabled={!hasAccount}
|
|
886
|
-
/>
|
|
887
|
-
)}
|
|
888
|
-
{!busy && viewMode === 'list' && <ListView posts={posts} onOpenPost={onOpenPost} />}
|
|
889
|
-
|
|
890
|
-
<PlanBriefSheet open={briefOpen} onClose={() => setBriefOpen(false)} />
|
|
891
|
-
|
|
892
|
-
<ConfirmDialog
|
|
893
|
-
open={!!pendingDeleteId}
|
|
894
|
-
variant="destructive"
|
|
895
|
-
title={t('marketing.content.deletePost')}
|
|
896
|
-
description={t('marketing.content.deletePostConfirm')}
|
|
897
|
-
confirmLabel={t('marketing.content.deletePost')}
|
|
898
|
-
cancelLabel={t('marketing.content.cancel')}
|
|
899
|
-
onCancel={() => setPendingDeleteId(null)}
|
|
900
|
-
onConfirm={() => {
|
|
901
|
-
const id = pendingDeleteId
|
|
902
|
-
setPendingDeleteId(null)
|
|
903
|
-
if (id) void deletePost(id)
|
|
904
|
-
}}
|
|
905
|
-
/>
|
|
906
|
-
</div>
|
|
907
|
-
)
|
|
908
|
-
}
|