@fayz-ai/plugin-marketing 0.2.1 → 0.8.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 +2 -0
- 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/contentSupabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +6 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/index.cjs +630 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +636 -133
- 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 +9 -3
- 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/schema/index.d.ts +44 -44
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema.cjs +9 -9
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.js +9 -9
- package/dist/schema.js.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/package.json +8 -5
- package/src/MarketingContext.tsx +2 -0
- package/src/MarketingPage.tsx +22 -1
- package/src/data/blogEntities.tsx +91 -0
- package/src/data/contentSupabase.ts +18 -17
- package/src/data/tables.ts +7 -0
- package/src/index.ts +27 -3
- 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/000_plg_rename.sql +15 -0
- package/src/migrations/001_content_planner.sql +48 -48
- package/src/migrations/002_multi_platform.sql +5 -5
- package/src/migrations/003_recording_ops.sql +10 -10
- package/src/migrations/004_ranklayer.sql +50 -0
- package/src/migrations/index.ts +152 -74
- package/src/presets.ts +5 -3
- package/src/schema/index.ts +9 -9
- package/src/views/blog/BlogAdminView.tsx +99 -0
- package/src/views/blog/BlogEntityForm.tsx +92 -0
- package/src/views/blog/useEntityCrud.ts +50 -0
package/dist/schema.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { pgTable, createdAt, boolean, text, tenantId, uuid, index, timestamps, integer, jsonb, date } from '@fayz-ai/db';
|
|
2
2
|
|
|
3
3
|
// src/schema/index.ts
|
|
4
|
-
var mktSocialAccounts = pgTable("
|
|
4
|
+
var mktSocialAccounts = pgTable("plg_marketing_social_accounts", {
|
|
5
5
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
6
6
|
tenantId: tenantId(),
|
|
7
7
|
name: text("name").notNull(),
|
|
@@ -10,8 +10,8 @@ var mktSocialAccounts = pgTable("mkt_social_accounts", {
|
|
|
10
10
|
platforms: text("platforms").array().notNull().default(["instagram"]),
|
|
11
11
|
isActive: boolean("is_active").notNull().default(true),
|
|
12
12
|
...createdAt
|
|
13
|
-
}, (t) => [index("
|
|
14
|
-
var mktContentPlans = pgTable("
|
|
13
|
+
}, (t) => [index("idx_plg_marketing_social_accounts_tenant").on(t.tenantId)]);
|
|
14
|
+
var mktContentPlans = pgTable("plg_marketing_content_plans", {
|
|
15
15
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
16
16
|
tenantId: tenantId(),
|
|
17
17
|
accountId: uuid("account_id").notNull().references(() => mktSocialAccounts.id, { onDelete: "cascade" }),
|
|
@@ -27,10 +27,10 @@ var mktContentPlans = pgTable("mkt_content_plans", {
|
|
|
27
27
|
briefMd: text("brief_md").notNull().default(""),
|
|
28
28
|
...timestamps
|
|
29
29
|
}, (t) => [
|
|
30
|
-
index("
|
|
31
|
-
index("
|
|
30
|
+
index("idx_plg_marketing_content_plans_tenant").on(t.tenantId),
|
|
31
|
+
index("idx_plg_marketing_content_plans_account").on(t.accountId)
|
|
32
32
|
]);
|
|
33
|
-
var mktContentPosts = pgTable("
|
|
33
|
+
var mktContentPosts = pgTable("plg_marketing_content_posts", {
|
|
34
34
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
35
35
|
tenantId: tenantId(),
|
|
36
36
|
planId: uuid("plan_id").notNull().references(() => mktContentPlans.id, { onDelete: "cascade" }),
|
|
@@ -51,9 +51,9 @@ var mktContentPosts = pgTable("mkt_content_posts", {
|
|
|
51
51
|
contentMd: text("content_md").notNull().default(""),
|
|
52
52
|
...timestamps
|
|
53
53
|
}, (t) => [
|
|
54
|
-
index("
|
|
55
|
-
index("
|
|
56
|
-
index("
|
|
54
|
+
index("idx_plg_marketing_content_posts_tenant").on(t.tenantId),
|
|
55
|
+
index("idx_plg_marketing_content_posts_plan").on(t.planId, t.weekNumber, t.position),
|
|
56
|
+
index("idx_plg_marketing_content_posts_status").on(t.tenantId, t.status)
|
|
57
57
|
]);
|
|
58
58
|
|
|
59
59
|
export { mktContentPlans, mktContentPosts, mktSocialAccounts };
|
package/dist/schema.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/schema/index.ts"],"names":[],"mappings":";;;AAaO,IAAM,iBAAA,GAAoB,QAAQ,
|
|
1
|
+
{"version":3,"sources":["../src/schema/index.ts"],"names":[],"mappings":"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","file":"schema.js","sourcesContent":["// Builders come from @fayz-ai/db (single drizzle-orm instance) — never import\n// pgTable/uuid directly from drizzle-orm/pg-core. See MIGRATION-ARCHITECTURE §6.\nimport {\n pgTable, uuid, text, boolean, integer, date, jsonb, index,\n tenantId, timestamps, createdAt,\n} from '@fayz-ai/db'\n\n// ---------------------------------------------------------------------------\n// Marketing plugin — Ring-1 schema (schema-as-code), content planner tables.\n// RLS policies + grants are companion SQL (Drizzle doesn't diff those) —\n// see src/migrations/001_content_planner.sql.\n// ---------------------------------------------------------------------------\n\nexport const mktSocialAccounts = pgTable('plg_marketing_social_accounts', {\n id: uuid('id').primaryKey().defaultRandom(),\n tenantId: tenantId(),\n name: text('name').notNull(),\n handle: text('handle'),\n // One account, many platform connections (Instagram, YouTube, TikTok, ...)\n platforms: text('platforms').array().notNull().default(['instagram']),\n isActive: boolean('is_active').notNull().default(true),\n ...createdAt,\n}, (t) => [index('idx_plg_marketing_social_accounts_tenant').on(t.tenantId)])\n\nexport const mktContentPlans = pgTable('plg_marketing_content_plans', {\n id: uuid('id').primaryKey().defaultRandom(),\n tenantId: tenantId(),\n accountId: uuid('account_id').notNull().references(() => mktSocialAccounts.id, { onDelete: 'cascade' }),\n name: text('name').notNull(),\n status: text('status').notNull().default('active'),\n weeksCount: integer('weeks_count').notNull().default(8),\n startDate: date('start_date'),\n objective: text('objective'),\n tone: text('tone'),\n pillars: jsonb('pillars').notNull().default([]),\n formats: jsonb('formats').notNull().default([]),\n weeklyFrequency: integer('weekly_frequency').notNull().default(3),\n briefMd: text('brief_md').notNull().default(''),\n ...timestamps,\n}, (t) => [\n index('idx_plg_marketing_content_plans_tenant').on(t.tenantId),\n index('idx_plg_marketing_content_plans_account').on(t.accountId),\n])\n\nexport const mktContentPosts = pgTable('plg_marketing_content_posts', {\n id: uuid('id').primaryKey().defaultRandom(),\n tenantId: tenantId(),\n planId: uuid('plan_id').notNull().references(() => mktContentPlans.id, { onDelete: 'cascade' }),\n weekNumber: integer('week_number').notNull().default(1),\n position: integer('position').notNull().default(0),\n title: text('title').notNull(),\n format: text('format').notNull().default('reel'),\n status: text('status').notNull().default('idea'),\n scheduledDate: date('scheduled_date'),\n // Target platforms for this post; empty = inherit the account's platforms\n platforms: text('platforms').array().notNull().default([]),\n // Recording-day checklist [{ id, text, done }]\n checklist: jsonb('checklist').notNull().default([]),\n // Uploaded asset (static posts: caption + final art instead of a script)\n mediaUrl: text('media_url'),\n hook: text('hook'),\n cta: text('cta'),\n contentMd: text('content_md').notNull().default(''),\n ...timestamps,\n}, (t) => [\n index('idx_plg_marketing_content_posts_tenant').on(t.tenantId),\n index('idx_plg_marketing_content_posts_plan').on(t.planId, t.weekNumber, t.position),\n index('idx_plg_marketing_content_posts_status').on(t.tenantId, t.status),\n])\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BlogAdminView.d.ts","sourceRoot":"","sources":["../../../src/views/blog/BlogAdminView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAgF3D,wBAAgB,aAAa,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,qBAkB/E"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { EntityDef } from '@fayz-ai/core';
|
|
3
|
+
export declare function BlogEntityForm<T extends {
|
|
4
|
+
id: string;
|
|
5
|
+
}>({ entity, editId, parentLabel, onDone, }: {
|
|
6
|
+
entity: EntityDef<T>;
|
|
7
|
+
editId?: string;
|
|
8
|
+
parentLabel: string;
|
|
9
|
+
onDone: () => void;
|
|
10
|
+
}): React.JSX.Element;
|
|
11
|
+
//# sourceMappingURL=BlogEntityForm.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BlogEntityForm.d.ts","sourceRoot":"","sources":["../../../src/views/blog/BlogEntityForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAGzB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAU9C,wBAAgB,cAAc,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,EACvD,MAAM,EACN,MAAM,EACN,WAAW,EACX,MAAM,GACP,EAAE;IACD,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,IAAI,CAAA;CACnB,qBAoEA"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EntityDef } from '@fayz-ai/core';
|
|
2
|
+
interface ListQuery {
|
|
3
|
+
search?: string;
|
|
4
|
+
filters?: Record<string, unknown>;
|
|
5
|
+
page?: number;
|
|
6
|
+
pageSize?: number;
|
|
7
|
+
sortBy?: string;
|
|
8
|
+
sortDir?: 'asc' | 'desc';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Minimal store-less CRUD wiring for a plugin that owns its data + routing.
|
|
12
|
+
* Resolves the generic Supabase provider from the EntityDef's `data.table`
|
|
13
|
+
* (mock fallback when Supabase is not configured) and exposes list/get/save/
|
|
14
|
+
* remove. Used by the blog backoffice list + form views — the heavy list/form
|
|
15
|
+
* rendering stays in the shared CrudListView / CrudFormPage.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useEntityCrud<T extends {
|
|
18
|
+
id: string;
|
|
19
|
+
}>(entity: EntityDef<T>): {
|
|
20
|
+
items: T[] | null;
|
|
21
|
+
total: number;
|
|
22
|
+
fetch: (query?: ListQuery) => Promise<void>;
|
|
23
|
+
getById: (id: string) => Promise<T | null>;
|
|
24
|
+
create: (data: Record<string, unknown>) => Promise<T>;
|
|
25
|
+
update: (id: string, data: Record<string, unknown>) => Promise<T>;
|
|
26
|
+
remove: (id: string) => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=useEntityCrud.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useEntityCrud.d.ts","sourceRoot":"","sources":["../../../src/views/blog/useEntityCrud.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,UAAU,SAAS;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,KAAK,GAAG,MAAM,CAAA;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC;;;oBAKlC,SAAS;kBAcV,MAAM,KAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;mBAK/B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;iBACzB,MAAM,QAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;iBACrC,MAAM;EAGvC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fayz-ai/plugin-marketing",
|
|
3
|
-
"
|
|
3
|
+
"fayz": {
|
|
4
|
+
"status": "beta"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.8.1",
|
|
4
7
|
"description": "Fayz SDK — marketing campaigns & broadcasts plugin",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"main": "./dist/index.cjs",
|
|
@@ -32,10 +35,10 @@
|
|
|
32
35
|
"@tanstack/react-table": "^8.20.0",
|
|
33
36
|
"lucide-react": "^0.400.0",
|
|
34
37
|
"zustand": "^4.5.0",
|
|
35
|
-
"@fayz-ai/
|
|
36
|
-
"@fayz-ai/
|
|
37
|
-
"@fayz-ai/
|
|
38
|
-
"@fayz-ai/
|
|
38
|
+
"@fayz-ai/core": "^0.8.1",
|
|
39
|
+
"@fayz-ai/ui": "^0.8.1",
|
|
40
|
+
"@fayz-ai/saas": "^0.8.1",
|
|
41
|
+
"@fayz-ai/db": "^0.8.0"
|
|
39
42
|
},
|
|
40
43
|
"devDependencies": {
|
|
41
44
|
"@types/react": "^18.3.0",
|
package/src/MarketingContext.tsx
CHANGED
package/src/MarketingPage.tsx
CHANGED
|
@@ -19,6 +19,9 @@ import type { ContentPlannerUIState } from './views/content/contentStore'
|
|
|
19
19
|
import { ContentView } from './views/content/ContentView'
|
|
20
20
|
import { PostPage } from './views/content/PostPage'
|
|
21
21
|
import { PlanBriefView } from './views/content/PlanBriefView'
|
|
22
|
+
import { BlogAdminView } from './views/blog/BlogAdminView'
|
|
23
|
+
import { BlogEntityForm } from './views/blog/BlogEntityForm'
|
|
24
|
+
import { buildBlogPostEntity, buildBlogCategoryEntity } from './data/blogEntities'
|
|
22
25
|
|
|
23
26
|
function buildNav(
|
|
24
27
|
config: ResolvedMarketingConfig,
|
|
@@ -51,6 +54,14 @@ function buildNav(
|
|
|
51
54
|
onClick: () => navigate('content'),
|
|
52
55
|
})
|
|
53
56
|
}
|
|
57
|
+
if (config.modules.blog) {
|
|
58
|
+
items.push({
|
|
59
|
+
id: 'blog', label: config.labels.blog, icon: 'Newspaper',
|
|
60
|
+
active: view === 'blog' || view === 'blog-new' || view.startsWith('blog-post:')
|
|
61
|
+
|| view === 'blog-cat-new' || view.startsWith('blog-cat:'),
|
|
62
|
+
onClick: () => navigate('blog'),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
54
65
|
return items
|
|
55
66
|
}
|
|
56
67
|
|
|
@@ -63,8 +74,9 @@ export function MarketingPage({ config, provider, store, contentProvider, conten
|
|
|
63
74
|
}) {
|
|
64
75
|
const t = useTranslation()
|
|
65
76
|
const { view, direction, navigate } = useModuleNavigation('/marketing', {
|
|
66
|
-
overview: 0, channels: 0, campaigns: 0, funnel: 0, 'landing-pages': 0, content: 0,
|
|
77
|
+
overview: 0, channels: 0, campaigns: 0, funnel: 0, 'landing-pages': 0, content: 0, blog: 0,
|
|
67
78
|
'channel-detail': 1, 'content-post': 1, 'content-brief': 1,
|
|
79
|
+
'blog-new': 1, 'blog-post': 1, 'blog-cat-new': 1, 'blog-cat': 1,
|
|
68
80
|
}, 'overview')
|
|
69
81
|
|
|
70
82
|
React.useEffect(() => { void store.getState().load() }, [store])
|
|
@@ -72,6 +84,10 @@ export function MarketingPage({ config, provider, store, contentProvider, conten
|
|
|
72
84
|
const isOverview = view === 'overview'
|
|
73
85
|
const nav = buildNav(config, view, navigate)
|
|
74
86
|
|
|
87
|
+
// Blog backoffice entities — one shared EntityDef each drives the list + form.
|
|
88
|
+
const blogPostEntity = React.useMemo(() => buildBlogPostEntity(), [])
|
|
89
|
+
const blogCategoryEntity = React.useMemo(() => buildBlogCategoryEntity(), [])
|
|
90
|
+
|
|
75
91
|
const quickActions = React.useMemo<PluginQuickAction[]>(() => [
|
|
76
92
|
{ id: 'new-campaign', label: t('marketing.campaigns.new'), icon: 'Megaphone', description: config.labels.campaigns, action: () => navigate('campaigns') },
|
|
77
93
|
], [t, config.labels.campaigns])
|
|
@@ -85,6 +101,11 @@ export function MarketingPage({ config, provider, store, contentProvider, conten
|
|
|
85
101
|
{ id: 'content', render: () => <ContentView onOpenPost={(id) => navigate(`content-post:${id}`)} /> },
|
|
86
102
|
{ id: 'content-post', render: ({ id }) => <PostPage postId={id!} onBack={() => navigate('content')} /> },
|
|
87
103
|
{ id: 'content-brief', render: () => <PlanBriefView onBack={() => navigate('content')} /> },
|
|
104
|
+
{ id: 'blog', render: () => <BlogAdminView navigate={navigate} /> },
|
|
105
|
+
{ id: 'blog-new', render: () => <BlogEntityForm entity={blogPostEntity} parentLabel={config.labels.blog} onDone={() => navigate('blog')} /> },
|
|
106
|
+
{ id: 'blog-post', render: ({ id }) => <BlogEntityForm entity={blogPostEntity} editId={id!} parentLabel={config.labels.blog} onDone={() => navigate('blog')} /> },
|
|
107
|
+
{ id: 'blog-cat-new', render: () => <BlogEntityForm entity={blogCategoryEntity} parentLabel={config.labels.blogCategories} onDone={() => navigate('blog')} /> },
|
|
108
|
+
{ id: 'blog-cat', render: ({ id }) => <BlogEntityForm entity={blogCategoryEntity} editId={id!} parentLabel={config.labels.blogCategories} onDone={() => navigate('blog')} /> },
|
|
88
109
|
{ id: 'overview', render: () => <OverviewView /> },
|
|
89
110
|
], 'overview')
|
|
90
111
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { EntityDef, FieldDef } from '@fayz-ai/core'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Blog backoffice entities — the single source of truth that drives BOTH the
|
|
5
|
+
// generic list (CrudListView columns + facets) and the create/edit form
|
|
6
|
+
// (CrudFormPage fields, incl. the markdown body + category relation).
|
|
7
|
+
//
|
|
8
|
+
// Persistence is the generic Supabase provider resolved from `data.table`
|
|
9
|
+
// (plg_blog_posts / plg_blog_categories, owned by @fayz-ai/plugin-blog's
|
|
10
|
+
// migrations). camelCase field keys map to snake_case columns automatically.
|
|
11
|
+
// The website reads the same rows through the anon v_public_blog_posts view.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export const BLOG_POSTS_TABLE = 'plg_blog_posts'
|
|
15
|
+
export const BLOG_CATEGORIES_TABLE = 'plg_blog_categories'
|
|
16
|
+
|
|
17
|
+
export interface BlogPostRow {
|
|
18
|
+
id: string
|
|
19
|
+
title: string
|
|
20
|
+
slug: string
|
|
21
|
+
excerpt: string
|
|
22
|
+
body: string
|
|
23
|
+
categoryId?: string
|
|
24
|
+
coverImage?: string
|
|
25
|
+
authorName?: string
|
|
26
|
+
authorRole?: string
|
|
27
|
+
authorAvatarUrl?: string
|
|
28
|
+
authorBio?: string
|
|
29
|
+
status: 'draft' | 'published'
|
|
30
|
+
readTime?: string
|
|
31
|
+
publishedAt?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BlogCategoryRow {
|
|
35
|
+
id: string
|
|
36
|
+
name: string
|
|
37
|
+
slug: string
|
|
38
|
+
description?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildBlogPostEntity(): EntityDef<BlogPostRow> {
|
|
42
|
+
const fields: FieldDef[] = [
|
|
43
|
+
{ key: 'title', label: 'Title', type: 'text', required: true, searchable: true, showInTable: true, sortable: true },
|
|
44
|
+
{ key: 'slug', label: 'Slug', type: 'text', required: true, searchable: true, showInTable: false, hint: 'URL identifier, e.g. "cannabis-and-oral-health"' },
|
|
45
|
+
{ key: 'status', label: 'Status', type: 'select', options: ['draft', 'published'], showInTable: true, defaultValue: 'draft' },
|
|
46
|
+
{ key: 'categoryId', label: 'Category', type: 'relation', relation: { table: BLOG_CATEGORIES_TABLE, valueField: 'id', labelField: 'name', tenantScoped: true }, showInTable: false },
|
|
47
|
+
{ key: 'excerpt', label: 'Excerpt', type: 'textarea', span: 2, showInTable: false, placeholder: 'Short summary shown on cards' },
|
|
48
|
+
{ key: 'body', label: 'Body', type: 'markdown', span: 2, showInTable: false },
|
|
49
|
+
{ key: 'coverImage', label: 'Cover image', type: 'image', showInTable: false, placeholder: 'https://…' },
|
|
50
|
+
{ key: 'readTime', label: 'Read time', type: 'text', showInTable: false, placeholder: '5 min' },
|
|
51
|
+
{ key: 'publishedAt', label: 'Published at', type: 'date', showInTable: true, sortable: true },
|
|
52
|
+
{ key: 'authorName', label: 'Author name', type: 'text', showInTable: false },
|
|
53
|
+
{ key: 'authorRole', label: 'Author role', type: 'text', showInTable: false, placeholder: 'e.g. Cirurgião-Dentista' },
|
|
54
|
+
{ key: 'authorAvatarUrl', label: 'Author avatar', type: 'image', showInTable: false, placeholder: 'https://…' },
|
|
55
|
+
{ key: 'authorBio', label: 'Author bio', type: 'textarea', span: 2, showInTable: false },
|
|
56
|
+
]
|
|
57
|
+
return {
|
|
58
|
+
name: 'Post',
|
|
59
|
+
namePlural: 'Posts',
|
|
60
|
+
icon: 'Newspaper',
|
|
61
|
+
fields,
|
|
62
|
+
displayField: 'title',
|
|
63
|
+
subtitleField: 'slug',
|
|
64
|
+
facets: [{ field: 'status', allLabel: 'All' }],
|
|
65
|
+
data: {
|
|
66
|
+
table: BLOG_POSTS_TABLE,
|
|
67
|
+
tenantScoped: true,
|
|
68
|
+
searchColumns: ['title', 'slug', 'excerpt'],
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildBlogCategoryEntity(): EntityDef<BlogCategoryRow> {
|
|
74
|
+
const fields: FieldDef[] = [
|
|
75
|
+
{ key: 'name', label: 'Name', type: 'text', required: true, searchable: true, showInTable: true, sortable: true },
|
|
76
|
+
{ key: 'slug', label: 'Slug', type: 'text', required: true, searchable: true, showInTable: true, hint: 'URL identifier' },
|
|
77
|
+
{ key: 'description', label: 'Description', type: 'textarea', span: 2, showInTable: false },
|
|
78
|
+
]
|
|
79
|
+
return {
|
|
80
|
+
name: 'Category',
|
|
81
|
+
namePlural: 'Categories',
|
|
82
|
+
icon: 'Tags',
|
|
83
|
+
fields,
|
|
84
|
+
displayField: 'name',
|
|
85
|
+
data: {
|
|
86
|
+
table: BLOG_CATEGORIES_TABLE,
|
|
87
|
+
tenantScoped: true,
|
|
88
|
+
searchColumns: ['name', 'slug'],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -5,10 +5,11 @@ import type {
|
|
|
5
5
|
ContentPost,
|
|
6
6
|
SocialAccount,
|
|
7
7
|
} from './contentTypes'
|
|
8
|
+
import { T } from './tables'
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
|
-
// Supabase content-planner provider. Tables:
|
|
11
|
-
//
|
|
11
|
+
// Supabase content-planner provider. Tables: plg_marketing_social_accounts,
|
|
12
|
+
// plg_marketing_content_plans, plg_marketing_content_posts (see src/migrations/001_content_planner.sql).
|
|
12
13
|
// RLS scopes rows to the user's tenants; we still filter tenant_id explicitly
|
|
13
14
|
// on reads so multi-tenant sessions see only the active tenant.
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
@@ -61,7 +62,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
61
62
|
const tenantId = getActiveTenantId()
|
|
62
63
|
if (!tenantId) return []
|
|
63
64
|
const { data } = await getClient()
|
|
64
|
-
.from(
|
|
65
|
+
.from(T.socialAccounts)
|
|
65
66
|
.select('*')
|
|
66
67
|
.eq('tenant_id', tenantId)
|
|
67
68
|
.eq('is_active', true)
|
|
@@ -77,7 +78,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
77
78
|
if (input.platforms !== undefined) payload.platforms = input.platforms
|
|
78
79
|
if (input.isActive !== undefined) payload.is_active = input.isActive
|
|
79
80
|
const { data, error } = await getClient()
|
|
80
|
-
.from(
|
|
81
|
+
.from(T.socialAccounts)
|
|
81
82
|
.update(payload)
|
|
82
83
|
.eq('id', input.id)
|
|
83
84
|
.select('*')
|
|
@@ -86,7 +87,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
86
87
|
return toAccount(data)
|
|
87
88
|
}
|
|
88
89
|
const { data, error } = await getClient()
|
|
89
|
-
.from(
|
|
90
|
+
.from(T.socialAccounts)
|
|
90
91
|
.insert({
|
|
91
92
|
tenant_id: requireTenantId(),
|
|
92
93
|
name: input.name,
|
|
@@ -101,7 +102,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
101
102
|
},
|
|
102
103
|
|
|
103
104
|
async deleteAccount(id) {
|
|
104
|
-
const { error } = await getClient().from(
|
|
105
|
+
const { error } = await getClient().from(T.socialAccounts).delete().eq('id', id)
|
|
105
106
|
if (error) throw error
|
|
106
107
|
},
|
|
107
108
|
|
|
@@ -109,7 +110,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
109
110
|
const tenantId = getActiveTenantId()
|
|
110
111
|
if (!tenantId) return []
|
|
111
112
|
const { data } = await getClient()
|
|
112
|
-
.from(
|
|
113
|
+
.from(T.contentPlans)
|
|
113
114
|
.select('*')
|
|
114
115
|
.eq('tenant_id', tenantId)
|
|
115
116
|
.eq('account_id', accountId)
|
|
@@ -119,7 +120,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
119
120
|
|
|
120
121
|
async getPlan(id) {
|
|
121
122
|
const { data } = await getClient()
|
|
122
|
-
.from(
|
|
123
|
+
.from(T.contentPlans)
|
|
123
124
|
.select('*')
|
|
124
125
|
.eq('id', id)
|
|
125
126
|
.maybeSingle()
|
|
@@ -140,7 +141,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
140
141
|
if (input.weeklyFrequency !== undefined) payload.weekly_frequency = input.weeklyFrequency
|
|
141
142
|
if (input.briefMd !== undefined) payload.brief_md = input.briefMd
|
|
142
143
|
const { data, error } = await getClient()
|
|
143
|
-
.from(
|
|
144
|
+
.from(T.contentPlans)
|
|
144
145
|
.update(payload)
|
|
145
146
|
.eq('id', input.id)
|
|
146
147
|
.select('*')
|
|
@@ -150,7 +151,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
150
151
|
}
|
|
151
152
|
if (!input.accountId) throw new Error('accountId is required')
|
|
152
153
|
const { data, error } = await getClient()
|
|
153
|
-
.from(
|
|
154
|
+
.from(T.contentPlans)
|
|
154
155
|
.insert({
|
|
155
156
|
tenant_id: requireTenantId(),
|
|
156
157
|
account_id: input.accountId,
|
|
@@ -172,12 +173,12 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
172
173
|
},
|
|
173
174
|
|
|
174
175
|
async deletePlan(id) {
|
|
175
|
-
await getClient().from(
|
|
176
|
+
await getClient().from(T.contentPlans).delete().eq('id', id)
|
|
176
177
|
},
|
|
177
178
|
|
|
178
179
|
async listPosts(planId) {
|
|
179
180
|
const { data } = await getClient()
|
|
180
|
-
.from(
|
|
181
|
+
.from(T.contentPosts)
|
|
181
182
|
.select('*')
|
|
182
183
|
.eq('plan_id', planId)
|
|
183
184
|
.order('week_number', { ascending: true })
|
|
@@ -187,7 +188,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
187
188
|
|
|
188
189
|
async getPost(id) {
|
|
189
190
|
const { data } = await getClient()
|
|
190
|
-
.from(
|
|
191
|
+
.from(T.contentPosts)
|
|
191
192
|
.select('*')
|
|
192
193
|
.eq('id', id)
|
|
193
194
|
.maybeSingle()
|
|
@@ -210,7 +211,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
210
211
|
if (input.cta !== undefined) payload.cta = input.cta
|
|
211
212
|
if (input.contentMd !== undefined) payload.content_md = input.contentMd
|
|
212
213
|
const { data, error } = await getClient()
|
|
213
|
-
.from(
|
|
214
|
+
.from(T.contentPosts)
|
|
214
215
|
.update(payload)
|
|
215
216
|
.eq('id', input.id)
|
|
216
217
|
.select('*')
|
|
@@ -223,7 +224,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
223
224
|
|
|
224
225
|
// Next position within the (plan, week) group.
|
|
225
226
|
const { data: maxData } = await getClient()
|
|
226
|
-
.from(
|
|
227
|
+
.from(T.contentPosts)
|
|
227
228
|
.select('position')
|
|
228
229
|
.eq('plan_id', input.planId)
|
|
229
230
|
.eq('week_number', week)
|
|
@@ -232,7 +233,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
232
233
|
const maxPos = maxData?.[0]?.position ?? -1
|
|
233
234
|
|
|
234
235
|
const { data, error } = await getClient()
|
|
235
|
-
.from(
|
|
236
|
+
.from(T.contentPosts)
|
|
236
237
|
.insert({
|
|
237
238
|
tenant_id: requireTenantId(),
|
|
238
239
|
plan_id: input.planId,
|
|
@@ -256,7 +257,7 @@ export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
|
256
257
|
},
|
|
257
258
|
|
|
258
259
|
async deletePost(id) {
|
|
259
|
-
await getClient().from(
|
|
260
|
+
await getClient().from(T.contentPosts).delete().eq('id', id)
|
|
260
261
|
},
|
|
261
262
|
}
|
|
262
263
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Physical table names for the marketing plugin (plg_marketing_* prefix). Import T
|
|
2
|
+
// and reference T.<key> in the data providers so a rename lands in one place.
|
|
3
|
+
export const T = {
|
|
4
|
+
socialAccounts: 'plg_marketing_social_accounts',
|
|
5
|
+
contentPlans: 'plg_marketing_content_plans',
|
|
6
|
+
contentPosts: 'plg_marketing_content_posts',
|
|
7
|
+
} as const
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,8 @@ import { createMockContentPlannerProvider } from './data/contentMock'
|
|
|
22
22
|
import { createSupabaseContentPlannerProvider } from './data/contentSupabase'
|
|
23
23
|
import { createMarketingStore } from './store'
|
|
24
24
|
import { createContentPlannerStore } from './views/content/contentStore'
|
|
25
|
-
import { MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS } from './migrations'
|
|
25
|
+
import { MIGRATION_000_PLG_RENAME, MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS, MIGRATION_004_RANKLAYER } from './migrations'
|
|
26
|
+
import { ranklayerConnectorDef } from './integrations/ranklayer/connectorDef'
|
|
26
27
|
import { DEFAULT_CURRENCY, type MarketingCurrency } from './format'
|
|
27
28
|
import { MARKETING_PRESETS, type MarketingDomain, type MarketingDomainModules } from './presets'
|
|
28
29
|
import type { AcquisitionChannel, ConversionModel } from './types'
|
|
@@ -74,6 +75,8 @@ const DEFAULT_LABELS: MarketingLabels = {
|
|
|
74
75
|
funnel: 'Funnel',
|
|
75
76
|
landingPages: 'Landing pages',
|
|
76
77
|
content: 'Content',
|
|
78
|
+
blog: 'Blog',
|
|
79
|
+
blogCategories: 'Categorias',
|
|
77
80
|
settings: 'Settings',
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -130,6 +133,8 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
|
|
|
130
133
|
subtitle: config.labels.pageSubtitle,
|
|
131
134
|
generalSettings: React.createElement(SettingsView),
|
|
132
135
|
registries: marketingRegistries,
|
|
136
|
+
// Enables the "Integrações" tab (renders manifest connectors for this host).
|
|
137
|
+
hostPluginId: 'marketing',
|
|
133
138
|
routeBase: '/settings/marketing',
|
|
134
139
|
}))
|
|
135
140
|
SettingsComponent.displayName = 'MarketingSettings'
|
|
@@ -141,12 +146,16 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
|
|
|
141
146
|
version: '1.0.0',
|
|
142
147
|
scope: options?.scope ?? 'universal',
|
|
143
148
|
verticalId: options?.verticalId,
|
|
149
|
+
// Admin content-planning surface (campaigns, content calendar) — targets the
|
|
150
|
+
// saas/admin Panel world, not the ecommerce storefront/operator console.
|
|
151
|
+
scaffolds: ['saas'],
|
|
144
152
|
defaultEnabled: true,
|
|
145
153
|
dependencies: [],
|
|
146
154
|
declaredFeatures: [
|
|
147
155
|
{ id: 'marketing', label: config.labels.pageTitle, group: 'Engage' },
|
|
148
156
|
...(config.modules.landingPages ? [{ id: 'marketing.landing-pages', label: config.labels.landingPages, group: 'Engage' }] : []),
|
|
149
157
|
...(config.modules.contentPlanner ? [{ id: 'marketing.content', label: config.labels.content, group: 'Engage' }] : []),
|
|
158
|
+
...(config.modules.blog ? [{ id: 'marketing.blog', label: config.labels.blog, group: 'Engage' }] : []),
|
|
150
159
|
],
|
|
151
160
|
navigation: [
|
|
152
161
|
{
|
|
@@ -177,11 +186,17 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
|
|
|
177
186
|
{ name: 'marketing.content.post.updated', description: 'A content post was updated' },
|
|
178
187
|
],
|
|
179
188
|
migrations: [
|
|
189
|
+
{
|
|
190
|
+
id: 'marketing-000-plg-rename',
|
|
191
|
+
version: '1.0.0',
|
|
192
|
+
sql: MIGRATION_000_PLG_RENAME,
|
|
193
|
+
description: 'Rename legacy mkt_* tables to plg_marketing_* in-place for pools provisioned before the industry-pool rename (guarded no-op on fresh pools)',
|
|
194
|
+
},
|
|
180
195
|
{
|
|
181
196
|
id: 'marketing-001-content-planner',
|
|
182
197
|
version: '1.0.0',
|
|
183
198
|
sql: MIGRATION_001_CONTENT_PLANNER,
|
|
184
|
-
description: 'Create
|
|
199
|
+
description: 'Create plg_marketing_social_accounts, plg_marketing_content_plans and plg_marketing_content_posts',
|
|
185
200
|
},
|
|
186
201
|
{
|
|
187
202
|
id: 'marketing-002-multi-platform',
|
|
@@ -193,9 +208,18 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
|
|
|
193
208
|
id: 'marketing-003-recording-ops',
|
|
194
209
|
version: '1.2.0',
|
|
195
210
|
sql: MIGRATION_003_RECORDING_OPS,
|
|
196
|
-
description: 'Recording-day checklist + media asset URL on posts;
|
|
211
|
+
description: 'Recording-day checklist + media asset URL on posts; content-media storage bucket',
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: 'marketing-004-ranklayer',
|
|
215
|
+
version: '1.3.0',
|
|
216
|
+
sql: MIGRATION_004_RANKLAYER,
|
|
217
|
+
description: 'RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)',
|
|
197
218
|
},
|
|
198
219
|
],
|
|
220
|
+
// RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
|
|
221
|
+
// (control plane only); the real sync lands via an external PR (RANKLAYER.md).
|
|
222
|
+
connectors: [ranklayerConnectorDef],
|
|
199
223
|
aiTools: [
|
|
200
224
|
{
|
|
201
225
|
id: 'marketing.channel-performance',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# RankLayer connector
|
|
2
|
+
|
|
3
|
+
Surfaces in **Marketing → settings → Integrações** (rendered by the shared
|
|
4
|
+
`ConnectorsHub`). Follows the connector contract in
|
|
5
|
+
`packages/core/src/integrations/index.ts` and mirrors the PlugBank precedent
|
|
6
|
+
(`beauty-saas/src/plugins/openbanking`).
|
|
7
|
+
|
|
8
|
+
## Status: SCAFFOLD
|
|
9
|
+
|
|
10
|
+
This ships the control plane only:
|
|
11
|
+
|
|
12
|
+
- `connectorDef.tsx` — the `ConnectorDefinition` (api-key form: API Key + site
|
|
13
|
+
domain, status badge, `ExtraPanel` with a "sync coming soon" note).
|
|
14
|
+
- `data/supabase.ts` — persists the connection row and reads the sync log.
|
|
15
|
+
- `../../migrations/004_ranklayer.sql` — `plg_marketing_ranklayer_integrations`
|
|
16
|
+
+ `plg_marketing_ranklayer_sync_log` (tenant RLS).
|
|
17
|
+
|
|
18
|
+
`testConnection` currently accepts any non-empty API key and **no real sync
|
|
19
|
+
runs** — RankLayer has no public API wired yet.
|
|
20
|
+
|
|
21
|
+
## What the RankLayer PR needs to fill in
|
|
22
|
+
|
|
23
|
+
1. **Edge function** `functions/ranklayer-sync/index.ts` holding the RankLayer
|
|
24
|
+
API key server-side (move it out of the `api_key` column into a secret if the
|
|
25
|
+
provider requires it), with actions:
|
|
26
|
+
- `test_connection` — validate the key against RankLayer.
|
|
27
|
+
- `sync` — the agreed direction. RankLayer generates/publishes SEO pages and
|
|
28
|
+
articles, so the likely first flow is **inbound**: pull RankLayer-published
|
|
29
|
+
articles and upsert them as `plg_blog_posts` (status `published`) so they
|
|
30
|
+
appear on the site's `/blog`. Confirm direction with the RankLayer team.
|
|
31
|
+
2. **Real `testConnection` / `sync`** in `data/supabase.ts` — replace the stubs
|
|
32
|
+
to invoke the edge function; write a `plg_marketing_ranklayer_sync_log` row
|
|
33
|
+
per run (the `SyncRun` audit shape).
|
|
34
|
+
3. **ExtraPanel** — swap the "coming soon" note for a "Sync now" button + the
|
|
35
|
+
real history table (the history rendering is already wired).
|
|
36
|
+
|
|
37
|
+
The connector is already registered on the marketing manifest
|
|
38
|
+
(`connectors: [ranklayerConnectorDef]`) and the Integrações tab is enabled
|
|
39
|
+
(`hostPluginId: 'marketing'` on the settings panel), so no wiring changes are
|
|
40
|
+
needed — only the data-plane implementation above.
|