@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.
Files changed (58) hide show
  1. package/README.md +2 -0
  2. package/dist/MarketingContext.d.ts +2 -0
  3. package/dist/MarketingContext.d.ts.map +1 -1
  4. package/dist/MarketingPage.d.ts.map +1 -1
  5. package/dist/data/blogEntities.d.ts +28 -0
  6. package/dist/data/blogEntities.d.ts.map +1 -0
  7. package/dist/data/contentSupabase.d.ts.map +1 -1
  8. package/dist/data/tables.d.ts +6 -0
  9. package/dist/data/tables.d.ts.map +1 -0
  10. package/dist/index.cjs +630 -127
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +636 -133
  14. package/dist/index.js.map +1 -1
  15. package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
  16. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
  17. package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
  18. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
  19. package/dist/integrations/ranklayer/types.d.ts +16 -0
  20. package/dist/integrations/ranklayer/types.d.ts.map +1 -0
  21. package/dist/migrations/index.d.ts +9 -3
  22. package/dist/migrations/index.d.ts.map +1 -1
  23. package/dist/presets.d.ts +2 -0
  24. package/dist/presets.d.ts.map +1 -1
  25. package/dist/schema/index.d.ts +44 -44
  26. package/dist/schema/index.d.ts.map +1 -1
  27. package/dist/schema.cjs +9 -9
  28. package/dist/schema.cjs.map +1 -1
  29. package/dist/schema.js +9 -9
  30. package/dist/schema.js.map +1 -1
  31. package/dist/views/blog/BlogAdminView.d.ts +5 -0
  32. package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
  33. package/dist/views/blog/BlogEntityForm.d.ts +11 -0
  34. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
  35. package/dist/views/blog/useEntityCrud.d.ts +29 -0
  36. package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
  37. package/package.json +8 -5
  38. package/src/MarketingContext.tsx +2 -0
  39. package/src/MarketingPage.tsx +22 -1
  40. package/src/data/blogEntities.tsx +91 -0
  41. package/src/data/contentSupabase.ts +18 -17
  42. package/src/data/tables.ts +7 -0
  43. package/src/index.ts +27 -3
  44. package/src/integrations/ranklayer/RANKLAYER.md +40 -0
  45. package/src/integrations/ranklayer/connectorDef.tsx +98 -0
  46. package/src/integrations/ranklayer/data/supabase.ts +106 -0
  47. package/src/integrations/ranklayer/types.ts +16 -0
  48. package/src/migrations/000_plg_rename.sql +15 -0
  49. package/src/migrations/001_content_planner.sql +48 -48
  50. package/src/migrations/002_multi_platform.sql +5 -5
  51. package/src/migrations/003_recording_ops.sql +10 -10
  52. package/src/migrations/004_ranklayer.sql +50 -0
  53. package/src/migrations/index.ts +152 -74
  54. package/src/presets.ts +5 -3
  55. package/src/schema/index.ts +9 -9
  56. package/src/views/blog/BlogAdminView.tsx +99 -0
  57. package/src/views/blog/BlogEntityForm.tsx +92 -0
  58. 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("mkt_social_accounts", {
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("idx_mkt_social_accounts_tenant").on(t.tenantId)]);
14
- var mktContentPlans = pgTable("mkt_content_plans", {
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("idx_mkt_content_plans_tenant").on(t.tenantId),
31
- index("idx_mkt_content_plans_account").on(t.accountId)
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("mkt_content_posts", {
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("idx_mkt_content_posts_tenant").on(t.tenantId),
55
- index("idx_mkt_content_posts_plan").on(t.planId, t.weekNumber, t.position),
56
- index("idx_mkt_content_posts_status").on(t.tenantId, t.status)
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 };
@@ -1 +1 @@
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('mkt_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_mkt_social_accounts_tenant').on(t.tenantId)])\n\nexport const mktContentPlans = pgTable('mkt_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_mkt_content_plans_tenant').on(t.tenantId),\n index('idx_mkt_content_plans_account').on(t.accountId),\n])\n\nexport const mktContentPosts = pgTable('mkt_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_mkt_content_posts_tenant').on(t.tenantId),\n index('idx_mkt_content_posts_plan').on(t.planId, t.weekNumber, t.position),\n index('idx_mkt_content_posts_status').on(t.tenantId, t.status),\n])\n"]}
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,5 @@
1
+ import React from 'react';
2
+ export declare function BlogAdminView({ navigate }: {
3
+ navigate: (view: string) => void;
4
+ }): React.JSX.Element;
5
+ //# sourceMappingURL=BlogAdminView.d.ts.map
@@ -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
- "version": "0.2.1",
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/db": "^0.1.2",
36
- "@fayz-ai/saas": "^0.7.0",
37
- "@fayz-ai/core": "^0.7.0",
38
- "@fayz-ai/ui": "^0.7.0"
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",
@@ -15,6 +15,8 @@ export interface MarketingLabels {
15
15
  funnel: string
16
16
  landingPages: string
17
17
  content: string
18
+ blog: string
19
+ blogCategories: string
18
20
  settings: string
19
21
  }
20
22
 
@@ -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: mkt_social_accounts,
11
- // mkt_content_plans, mkt_content_posts (see src/migrations/001_content_planner.sql).
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('mkt_social_accounts')
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('mkt_social_accounts')
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('mkt_social_accounts')
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('mkt_social_accounts').delete().eq('id', id)
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('mkt_content_plans')
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('mkt_content_plans')
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('mkt_content_plans')
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('mkt_content_plans')
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('mkt_content_plans').delete().eq('id', id)
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('mkt_content_posts')
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('mkt_content_posts')
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('mkt_content_posts')
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('mkt_content_posts')
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('mkt_content_posts')
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('mkt_content_posts').delete().eq('id', id)
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 mkt_social_accounts, mkt_content_plans and mkt_content_posts tables',
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; mkt-media storage bucket',
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.