@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.
- package/dist/MarketingContext.d.ts +2 -0
- package/dist/MarketingContext.d.ts.map +1 -1
- package/dist/MarketingPage.d.ts.map +1 -1
- package/dist/data/blogEntities.d.ts +28 -0
- package/dist/data/blogEntities.d.ts.map +1 -0
- package/dist/data/capability.test.d.ts +2 -0
- package/dist/data/capability.test.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +10 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/tables.d.ts +7 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/types.d.ts +2 -0
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +1239 -87
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1245 -94
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
- package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
- package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
- package/dist/integrations/ranklayer/types.d.ts +16 -0
- package/dist/integrations/ranklayer/types.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +4 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/presets.d.ts +2 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/registries.d.ts +3 -3
- package/dist/registries.d.ts.map +1 -1
- package/dist/schema/index.d.ts +354 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema.cjs +30 -0
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.js +30 -2
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/views/CampaignsView.d.ts.map +1 -1
- package/dist/views/blog/BlogAdminView.d.ts +5 -0
- package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
- package/dist/views/blog/BlogEntityForm.d.ts +11 -0
- package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
- package/dist/views/blog/useEntityCrud.d.ts +29 -0
- package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
- package/dist/views/content/ContentView.d.ts.map +1 -1
- package/package.json +8 -6
- package/src/MarketingContext.tsx +2 -0
- package/src/MarketingPage.tsx +27 -2
- package/src/data/blogEntities.tsx +91 -0
- package/src/data/capability.test.ts +92 -0
- package/src/data/supabase.ts +401 -0
- package/src/data/tables.ts +9 -0
- package/src/data/types.ts +2 -0
- package/src/index.ts +153 -10
- package/src/integrations/ranklayer/RANKLAYER.md +40 -0
- package/src/integrations/ranklayer/connectorDef.tsx +98 -0
- package/src/integrations/ranklayer/data/supabase.ts +106 -0
- package/src/integrations/ranklayer/types.ts +16 -0
- package/src/migrations/004_ranklayer.sql +50 -0
- package/src/migrations/005_analytics_base.sql +60 -0
- package/src/migrations/006_analytics_views.sql +68 -0
- package/src/migrations/007_agent_rpcs.sql +119 -0
- package/src/migrations/index.ts +310 -1
- package/src/presets.ts +5 -3
- package/src/registries.ts +18 -6
- package/src/schema/index.ts +38 -1
- package/src/store.ts +21 -8
- package/src/views/CampaignsView.tsx +4 -1
- package/src/views/blog/BlogAdminView.tsx +99 -0
- package/src/views/blog/BlogEntityForm.tsx +101 -0
- package/src/views/blog/useEntityCrud.ts +50 -0
- 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
|
|