@fayz-ai/plugin-marketing 0.8.0 → 0.8.2

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 (74) hide show
  1. package/dist/MarketingContext.d.ts +2 -0
  2. package/dist/MarketingContext.d.ts.map +1 -1
  3. package/dist/MarketingPage.d.ts.map +1 -1
  4. package/dist/data/blogEntities.d.ts +28 -0
  5. package/dist/data/blogEntities.d.ts.map +1 -0
  6. package/dist/data/capability.test.d.ts +2 -0
  7. package/dist/data/capability.test.d.ts.map +1 -0
  8. package/dist/data/supabase.d.ts +10 -0
  9. package/dist/data/supabase.d.ts.map +1 -0
  10. package/dist/data/tables.d.ts +7 -0
  11. package/dist/data/tables.d.ts.map +1 -1
  12. package/dist/data/types.d.ts +2 -0
  13. package/dist/data/types.d.ts.map +1 -1
  14. package/dist/index.cjs +1239 -87
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1245 -94
  19. package/dist/index.js.map +1 -1
  20. package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
  21. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
  22. package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
  23. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
  24. package/dist/integrations/ranklayer/types.d.ts +16 -0
  25. package/dist/integrations/ranklayer/types.d.ts.map +1 -0
  26. package/dist/migrations/index.d.ts +4 -0
  27. package/dist/migrations/index.d.ts.map +1 -1
  28. package/dist/presets.d.ts +2 -0
  29. package/dist/presets.d.ts.map +1 -1
  30. package/dist/registries.d.ts +3 -3
  31. package/dist/registries.d.ts.map +1 -1
  32. package/dist/schema/index.d.ts +354 -0
  33. package/dist/schema/index.d.ts.map +1 -1
  34. package/dist/schema.cjs +30 -0
  35. package/dist/schema.cjs.map +1 -1
  36. package/dist/schema.js +30 -2
  37. package/dist/schema.js.map +1 -1
  38. package/dist/store.d.ts +2 -0
  39. package/dist/store.d.ts.map +1 -1
  40. package/dist/views/CampaignsView.d.ts.map +1 -1
  41. package/dist/views/blog/BlogAdminView.d.ts +5 -0
  42. package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
  43. package/dist/views/blog/BlogEntityForm.d.ts +11 -0
  44. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
  45. package/dist/views/blog/useEntityCrud.d.ts +29 -0
  46. package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
  47. package/dist/views/content/ContentView.d.ts.map +1 -1
  48. package/package.json +8 -6
  49. package/src/MarketingContext.tsx +2 -0
  50. package/src/MarketingPage.tsx +27 -2
  51. package/src/data/blogEntities.tsx +91 -0
  52. package/src/data/capability.test.ts +92 -0
  53. package/src/data/supabase.ts +401 -0
  54. package/src/data/tables.ts +9 -0
  55. package/src/data/types.ts +2 -0
  56. package/src/index.ts +153 -10
  57. package/src/integrations/ranklayer/RANKLAYER.md +40 -0
  58. package/src/integrations/ranklayer/connectorDef.tsx +98 -0
  59. package/src/integrations/ranklayer/data/supabase.ts +106 -0
  60. package/src/integrations/ranklayer/types.ts +16 -0
  61. package/src/migrations/004_ranklayer.sql +50 -0
  62. package/src/migrations/005_analytics_base.sql +60 -0
  63. package/src/migrations/006_analytics_views.sql +68 -0
  64. package/src/migrations/007_agent_rpcs.sql +119 -0
  65. package/src/migrations/index.ts +310 -1
  66. package/src/presets.ts +5 -3
  67. package/src/registries.ts +18 -6
  68. package/src/schema/index.ts +38 -1
  69. package/src/store.ts +21 -8
  70. package/src/views/CampaignsView.tsx +4 -1
  71. package/src/views/blog/BlogAdminView.tsx +99 -0
  72. package/src/views/blog/BlogEntityForm.tsx +101 -0
  73. package/src/views/blog/useEntityCrud.ts +50 -0
  74. package/src/views/content/ContentView.tsx +8 -1
@@ -0,0 +1,101 @@
1
+ import React from 'react'
2
+ import { SubpageHeader, Button, toast } from '@fayz-ai/ui'
3
+ import { CrudFormPage, useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
4
+ import type { EntityDef } from '@fayz-ai/core'
5
+ import { useEntityCrud } from './useEntityCrud'
6
+ import { BLOG_POSTS_TABLE } from '../../data/blogEntities'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Generic create/edit for a blog entity (post or category), rendered through
10
+ // the shared CrudFormPage (declarative EntityDef → fields, incl. the markdown
11
+ // body + category relation). Persistence flows through the generic Supabase
12
+ // provider; this only wires load / save / delete + the back link.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export function BlogEntityForm<T extends { id: string }>({
16
+ entity,
17
+ editId,
18
+ parentLabel,
19
+ onDone,
20
+ }: {
21
+ entity: EntityDef<T>
22
+ editId?: string
23
+ parentLabel: string
24
+ onDone: () => void
25
+ }) {
26
+ const { getById, create, update, remove } = useEntityCrud(entity)
27
+ const isEdit = !!editId
28
+ // Plan quantity guard applies to blog POSTS only (categories are unbounded).
29
+ const isPost = (entity as { data?: { table?: string } }).data?.table === BLOG_POSTS_TABLE
30
+ const guardBlogPosts = useLimitGuard('blog_posts')
31
+ const namePlural = entity.namePlural ?? entity.name + 's'
32
+ const [initialData, setInitialData] = React.useState<Record<string, any> | null>(isEdit ? null : {})
33
+
34
+ React.useEffect(() => {
35
+ if (!editId) return
36
+ let cancelled = false
37
+ ;(async () => {
38
+ const item = await getById(editId)
39
+ if (!cancelled) setInitialData((item as Record<string, any>) ?? {})
40
+ })()
41
+ return () => { cancelled = true }
42
+ }, [editId, getById])
43
+
44
+ async function save(values: Record<string, any>) {
45
+ // Client-side plan guard before the provider create — posts only, on create.
46
+ if (!isEdit && isPost && (await guardBlogPosts()) === 'blocked') return
47
+ try {
48
+ if (isEdit) await update(editId!, values)
49
+ else {
50
+ await create(values)
51
+ if (isPost) invalidateLimit('blog_posts')
52
+ }
53
+ toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`)
54
+ onDone()
55
+ } catch (err) {
56
+ toast.error(`Failed to save ${entity.name.toLowerCase()}`)
57
+ throw err
58
+ }
59
+ }
60
+
61
+ async function del() {
62
+ if (!editId) return
63
+ try {
64
+ await remove(editId)
65
+ toast.success(`${entity.name} deleted`)
66
+ onDone()
67
+ } catch {
68
+ toast.error(`Failed to delete ${entity.name.toLowerCase()}`)
69
+ }
70
+ }
71
+
72
+ const title = isEdit
73
+ ? (initialData?.[entity.displayField ?? 'name'] ?? entity.name)
74
+ : `New ${entity.name.toLowerCase()}`
75
+
76
+ return (
77
+ <div className="space-y-4">
78
+ <SubpageHeader
79
+ title={String(title)}
80
+ onBack={onDone}
81
+ parentLabel={parentLabel}
82
+ actions={isEdit ? (
83
+ <Button variant="ghost" className="text-destructive hover:text-destructive" onClick={del}>
84
+ Delete
85
+ </Button>
86
+ ) : undefined}
87
+ />
88
+ {initialData !== null && (
89
+ <CrudFormPage
90
+ entityDef={entity}
91
+ mode={isEdit ? 'edit' : 'create'}
92
+ initialData={initialData}
93
+ namePlural={namePlural}
94
+ onCancel={onDone}
95
+ onSubmit={save}
96
+ hideBreadcrumb
97
+ />
98
+ )}
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,50 @@
1
+ import { useMemo, useState, useCallback } from 'react'
2
+ import { resolveDataProvider } from '@fayz-ai/core'
3
+ import type { EntityDef } from '@fayz-ai/core'
4
+
5
+ interface ListQuery {
6
+ search?: string
7
+ filters?: Record<string, unknown>
8
+ page?: number
9
+ pageSize?: number
10
+ sortBy?: string
11
+ sortDir?: 'asc' | 'desc'
12
+ }
13
+
14
+ /**
15
+ * Minimal store-less CRUD wiring for a plugin that owns its data + routing.
16
+ * Resolves the generic Supabase provider from the EntityDef's `data.table`
17
+ * (mock fallback when Supabase is not configured) and exposes list/get/save/
18
+ * remove. Used by the blog backoffice list + form views — the heavy list/form
19
+ * rendering stays in the shared CrudListView / CrudFormPage.
20
+ */
21
+ export function useEntityCrud<T extends { id: string }>(entity: EntityDef<T>) {
22
+ const provider = useMemo(() => resolveDataProvider<T>(entity), [entity])
23
+ const [items, setItems] = useState<T[] | null>(null)
24
+ const [total, setTotal] = useState(0)
25
+
26
+ const fetch = useCallback(async (query: ListQuery = {}) => {
27
+ try {
28
+ const res = await provider.list(query as any)
29
+ setItems(res.data)
30
+ setTotal(res.total)
31
+ } catch (err) {
32
+ // Table not provisioned yet (migration not applied to this pool) or a
33
+ // transient error — surface an empty list instead of a perpetual skeleton.
34
+ console.warn('[blog] list failed; showing empty. Apply the plugin-blog migration to this pool.', err)
35
+ setItems([])
36
+ setTotal(0)
37
+ }
38
+ }, [provider])
39
+
40
+ const getById = useCallback(async (id: string): Promise<T | null> => {
41
+ const res = await provider.list({ filters: { id }, page: 1, pageSize: 1 } as any)
42
+ return res.data[0] ?? null
43
+ }, [provider])
44
+
45
+ const create = useCallback((data: Record<string, unknown>) => provider.create(data as any), [provider])
46
+ const update = useCallback((id: string, data: Record<string, unknown>) => provider.update(id, data as any), [provider])
47
+ const remove = useCallback((id: string) => provider.remove(id), [provider])
48
+
49
+ return { items, total, fetch, getById, create, update, remove }
50
+ }
@@ -10,7 +10,7 @@ import {
10
10
  Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter,
11
11
  } from '@fayz-ai/ui'
12
12
  import { useTranslation } from '@fayz-ai/core'
13
- import { useOrganizationStore } from '@fayz-ai/saas'
13
+ import { useOrganizationStore, useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
14
14
  import { PlanBriefSheet } from './PlanBriefView'
15
15
  import type { ContentPost } from '../../data/contentTypes'
16
16
  import { useContentPlannerStore } from './ContentPlannerContext'
@@ -727,6 +727,7 @@ export function ContentView({ onOpenPost }: {
727
727
  const viewMode = useContentPlannerStore((s) => s.viewMode)
728
728
  const setViewMode = useContentPlannerStore((s) => s.setViewMode)
729
729
  const loadedTenantId = useContentPlannerStore((s) => s.loadedTenantId)
730
+ const guardContentPosts = useLimitGuard('content_posts_month')
730
731
 
731
732
  const [briefOpen, setBriefOpen] = React.useState(false)
732
733
  const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
@@ -765,6 +766,8 @@ export function ContentView({ onOpenPost }: {
765
766
  // default plan and drops the post straight into it. The scheduled date
766
767
  // prefills with the first day of the selected week so the post lands dated.
767
768
  const createPost = async (week: number) => {
769
+ // Client-side monthly-quota guard before the store create.
770
+ if ((await guardContentPosts()) === 'blocked') return
768
771
  const targetPlan = await ensurePlan()
769
772
  if (!targetPlan) return
770
773
  const start = targetPlan.startDate
@@ -779,6 +782,7 @@ export function ContentView({ onOpenPost }: {
779
782
  status: 'idea',
780
783
  scheduledDate: toISODate(start),
781
784
  })
785
+ invalidateLimit('content_posts_month')
782
786
  onOpenPost(post.id)
783
787
  }
784
788
 
@@ -790,6 +794,8 @@ export function ContentView({ onOpenPost }: {
790
794
 
791
795
  // Calendar day click → derive the plan week from startDate when available.
792
796
  const createPostAt = async (isoDate: string) => {
797
+ // Client-side monthly-quota guard before the store create.
798
+ if ((await guardContentPosts()) === 'blocked') return
793
799
  const targetPlan = await ensurePlan()
794
800
  if (!targetPlan) return
795
801
  let week = 1
@@ -807,6 +813,7 @@ export function ContentView({ onOpenPost }: {
807
813
  status: 'idea',
808
814
  scheduledDate: isoDate,
809
815
  })
816
+ invalidateLimit('content_posts_month')
810
817
  onOpenPost(post.id)
811
818
  }
812
819