@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.
Files changed (88) hide show
  1. package/README.md +3 -3
  2. package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/data/mock.d.ts +1 -1
  5. package/dist/data/mock.d.ts.map +1 -1
  6. package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
  7. package/dist/data/registries.d.ts.map +1 -0
  8. package/dist/index.d.ts +4 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +17 -22
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/format.d.ts.map +1 -0
  13. package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
  14. package/dist/lib/presets.d.ts.map +1 -0
  15. package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
  16. package/dist/views/MarketingPage.d.ts.map +1 -0
  17. package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
  18. package/dist/views/content/ContentPlannerContext.d.ts +1 -1
  19. package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
  20. package/dist/views/dashboardWidgets.d.ts +1 -1
  21. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  22. package/package.json +6 -9
  23. package/dist/MarketingContext.d.ts.map +0 -1
  24. package/dist/MarketingPage.d.ts.map +0 -1
  25. package/dist/format.d.ts.map +0 -1
  26. package/dist/presets.d.ts.map +0 -1
  27. package/dist/registries.d.ts.map +0 -1
  28. package/src/MarketingContext.tsx +0 -42
  29. package/src/MarketingPage.tsx +0 -139
  30. package/src/components/MarketingBits.tsx +0 -131
  31. package/src/components/icons.tsx +0 -36
  32. package/src/data/blogEntities.tsx +0 -91
  33. package/src/data/capability.test.ts +0 -92
  34. package/src/data/contentMedia.ts +0 -25
  35. package/src/data/contentMock.ts +0 -253
  36. package/src/data/contentSupabase.ts +0 -263
  37. package/src/data/contentTypes.ts +0 -138
  38. package/src/data/mock.ts +0 -308
  39. package/src/data/supabase.ts +0 -613
  40. package/src/data/tables.ts +0 -33
  41. package/src/data/types.ts +0 -97
  42. package/src/format.ts +0 -40
  43. package/src/index.ts +0 -457
  44. package/src/integrations/ranklayer/RANKLAYER.md +0 -40
  45. package/src/integrations/ranklayer/connectorDef.tsx +0 -102
  46. package/src/integrations/ranklayer/data/supabase.ts +0 -106
  47. package/src/integrations/ranklayer/types.ts +0 -16
  48. package/src/locales/en.ts +0 -192
  49. package/src/locales/index.ts +0 -7
  50. package/src/locales/pt-BR.ts +0 -192
  51. package/src/migrations/000_plg_rename.sql +0 -15
  52. package/src/migrations/001_content_planner.sql +0 -94
  53. package/src/migrations/002_multi_platform.sql +0 -25
  54. package/src/migrations/003_recording_ops.sql +0 -25
  55. package/src/migrations/004_ranklayer.sql +0 -50
  56. package/src/migrations/005_analytics_base.sql +0 -60
  57. package/src/migrations/006_analytics_views.sql +0 -68
  58. package/src/migrations/007_agent_rpcs.sql +0 -119
  59. package/src/migrations/008_landing_pages.sql +0 -112
  60. package/src/migrations/index.ts +0 -601
  61. package/src/presets.ts +0 -119
  62. package/src/registries.ts +0 -62
  63. package/src/schema/index.ts +0 -106
  64. package/src/store.ts +0 -102
  65. package/src/types.ts +0 -153
  66. package/src/views/CampaignComposer.tsx +0 -89
  67. package/src/views/CampaignsView.tsx +0 -59
  68. package/src/views/ChannelDetailView.tsx +0 -59
  69. package/src/views/ChannelsView.tsx +0 -46
  70. package/src/views/FunnelView.tsx +0 -56
  71. package/src/views/LandingPageComposer.tsx +0 -201
  72. package/src/views/LandingPagesView.tsx +0 -172
  73. package/src/views/OverviewView.tsx +0 -24
  74. package/src/views/SettingsView.tsx +0 -72
  75. package/src/views/blog/BlogAdminView.tsx +0 -99
  76. package/src/views/blog/BlogEntityForm.tsx +0 -101
  77. package/src/views/blog/useEntityCrud.ts +0 -50
  78. package/src/views/content/ContentPlannerContext.tsx +0 -13
  79. package/src/views/content/ContentView.tsx +0 -908
  80. package/src/views/content/PlanBriefView.tsx +0 -268
  81. package/src/views/content/PostPage.tsx +0 -370
  82. package/src/views/content/PostSocialPreview.tsx +0 -212
  83. package/src/views/content/RecordingChecklist.tsx +0 -145
  84. package/src/views/content/contentBits.tsx +0 -114
  85. package/src/views/content/contentStore.ts +0 -160
  86. package/src/views/content/templates.ts +0 -172
  87. package/src/views/dashboardWidgets.tsx +0 -173
  88. /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
- }