@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
package/dist/schema.js CHANGED
@@ -1,6 +1,34 @@
1
- import { pgTable, createdAt, boolean, text, tenantId, uuid, index, timestamps, integer, jsonb, date } from '@fayz-ai/db';
1
+ import { pgTable, timestamps, numeric, boolean, text, tenantId, uuid, index, uniqueIndex, timestamp, createdAt, integer, jsonb, date } from '@fayz-ai/db';
2
2
 
3
3
  // src/schema/index.ts
4
+ var mktChannels = pgTable("plg_marketing_channels", {
5
+ id: uuid("id").primaryKey().defaultRandom(),
6
+ tenantId: tenantId(),
7
+ channelKey: text("channel_key").notNull(),
8
+ label: text("label").notNull(),
9
+ icon: text("icon"),
10
+ kind: text("kind").notNull().default("organic"),
11
+ isActive: boolean("is_active").notNull().default(true),
12
+ monthlySpend: numeric("monthly_spend").notNull().default("0"),
13
+ ...timestamps
14
+ }, (t) => [
15
+ index("idx_plg_marketing_channels_tenant").on(t.tenantId),
16
+ uniqueIndex("u_plg_marketing_channels_tenant_key").on(t.tenantId, t.channelKey)
17
+ ]);
18
+ var mktCampaigns = pgTable("plg_marketing_campaigns", {
19
+ id: uuid("id").primaryKey().defaultRandom(),
20
+ tenantId: tenantId(),
21
+ name: text("name").notNull(),
22
+ channelKey: text("channel_key").notNull(),
23
+ status: text("status").notNull().default("draft"),
24
+ startsAt: timestamp("starts_at", { withTimezone: true }),
25
+ endsAt: timestamp("ends_at", { withTimezone: true }),
26
+ spend: numeric("spend").notNull().default("0"),
27
+ ...timestamps
28
+ }, (t) => [
29
+ index("idx_plg_marketing_campaigns_tenant").on(t.tenantId),
30
+ index("idx_plg_marketing_campaigns_channel").on(t.tenantId, t.channelKey)
31
+ ]);
4
32
  var mktSocialAccounts = pgTable("plg_marketing_social_accounts", {
5
33
  id: uuid("id").primaryKey().defaultRandom(),
6
34
  tenantId: tenantId(),
@@ -56,6 +84,6 @@ var mktContentPosts = pgTable("plg_marketing_content_posts", {
56
84
  index("idx_plg_marketing_content_posts_status").on(t.tenantId, t.status)
57
85
  ]);
58
86
 
59
- export { mktContentPlans, mktContentPosts, mktSocialAccounts };
87
+ export { mktCampaigns, mktChannels, mktContentPlans, mktContentPosts, mktSocialAccounts };
60
88
  //# sourceMappingURL=schema.js.map
61
89
  //# sourceMappingURL=schema.js.map
@@ -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('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"]}
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, uniqueIndex,\n numeric, timestamp,\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\n// Acquisition channels — tenant-owned rows seeded from the domain preset.\n// `channel_key` is the stable preset/config id ('instagram', 'paid-search'…),\n// aligned with plugin-crm's lead-sources where they overlap.\nexport const mktChannels = pgTable('plg_marketing_channels', {\n id: uuid('id').primaryKey().defaultRandom(),\n tenantId: tenantId(),\n channelKey: text('channel_key').notNull(),\n label: text('label').notNull(),\n icon: text('icon'),\n kind: text('kind').notNull().default('organic'),\n isActive: boolean('is_active').notNull().default(true),\n monthlySpend: numeric('monthly_spend').notNull().default('0'),\n ...timestamps,\n}, (t) => [\n index('idx_plg_marketing_channels_tenant').on(t.tenantId),\n uniqueIndex('u_plg_marketing_channels_tenant_key').on(t.tenantId, t.channelKey),\n])\n\n// Acquisition campaigns — real CRUD; performance is DERIVED at read time from\n// v_marketing_attribution (channel match within the campaign window), only\n// spend is stored.\nexport const mktCampaigns = pgTable('plg_marketing_campaigns', {\n id: uuid('id').primaryKey().defaultRandom(),\n tenantId: tenantId(),\n name: text('name').notNull(),\n channelKey: text('channel_key').notNull(),\n status: text('status').notNull().default('draft'),\n startsAt: timestamp('starts_at', { withTimezone: true }),\n endsAt: timestamp('ends_at', { withTimezone: true }),\n spend: numeric('spend').notNull().default('0'),\n ...timestamps,\n}, (t) => [\n index('idx_plg_marketing_campaigns_tenant').on(t.tenantId),\n index('idx_plg_marketing_campaigns_channel').on(t.tenantId, t.channelKey),\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"]}
package/dist/store.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface MarketingUIState {
10
10
  landingPages: LandingPagePerf[];
11
11
  loading: boolean;
12
12
  load(): Promise<void>;
13
+ /** Invalidate the provider cache and reload (external writes, e.g. agent RPCs). */
14
+ refresh(): Promise<void>;
13
15
  setRange(range: DateRangeKey): Promise<void>;
14
16
  saveCampaign(input: SaveCampaignInput): Promise<Campaign>;
15
17
  deleteCampaign(id: string): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC5D,OAAO,KAAK,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAC5E,OAAO,KAAK,EACV,QAAQ,EACR,kBAAkB,EAClB,YAAY,EACZ,WAAW,EACX,eAAe,EACf,iBAAiB,EAClB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,YAAY,CAAA;IACnB,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAA;IAClC,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,SAAS,EAAE,QAAQ,EAAE,CAAA;IACrB,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,YAAY,EAAE,eAAe,EAAE,CAAA;IAC/B,OAAO,EAAE,OAAO,CAAA;IAEhB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACzD,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1C;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,qBAAqB,GAAG,QAAQ,CAAC,gBAAgB,CAAC,CAuChG"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC5D,OAAO,KAAK,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAC5E,OAAO,KAAK,EACV,QAAQ,EACR,kBAAkB,EAClB,YAAY,EACZ,WAAW,EACX,eAAe,EACf,iBAAiB,EAClB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,YAAY,CAAA;IACnB,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAA;IAClC,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,SAAS,EAAE,QAAQ,EAAE,CAAA;IACrB,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,YAAY,EAAE,eAAe,EAAE,CAAA;IAC/B,OAAO,EAAE,OAAO,CAAA;IAEhB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,mFAAmF;IACnF,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACzD,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1C;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,qBAAqB,GAAG,QAAQ,CAAC,gBAAgB,CAAC,CAkDhG"}
@@ -1 +1 @@
1
- {"version":3,"file":"CampaignsView.d.ts","sourceRoot":"","sources":["../../src/views/CampaignsView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAWzB,wBAAgB,aAAa,sBA4C5B"}
1
+ {"version":3,"file":"CampaignsView.d.ts","sourceRoot":"","sources":["../../src/views/CampaignsView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAYzB,wBAAgB,aAAa,sBA8C5B"}
@@ -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;AAW9C,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,qBA4EA"}
@@ -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"}
@@ -1 +1 @@
1
- {"version":3,"file":"ContentView.d.ts","sourceRoot":"","sources":["../../../src/views/content/ContentView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAwsBzB,wBAAgB,WAAW,CAAC,EAAE,UAAU,EAAE,EAAE;IAC1C,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CACjC,qBA0LA"}
1
+ {"version":3,"file":"ContentView.d.ts","sourceRoot":"","sources":["../../../src/views/content/ContentView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAwsBzB,wBAAgB,WAAW,CAAC,EAAE,UAAU,EAAE,EAAE;IAC1C,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CACjC,qBAiMA"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.8.0",
6
+ "version": "0.8.2",
7
7
  "description": "Fayz SDK — marketing campaigns & broadcasts plugin",
8
8
  "type": "module",
9
9
  "main": "./dist/index.cjs",
@@ -35,17 +35,18 @@
35
35
  "@tanstack/react-table": "^8.20.0",
36
36
  "lucide-react": "^0.400.0",
37
37
  "zustand": "^4.5.0",
38
- "@fayz-ai/ui": "^0.8.0",
39
- "@fayz-ai/core": "^0.8.0",
40
- "@fayz-ai/saas": "^0.8.0",
41
- "@fayz-ai/db": "^0.8.0"
38
+ "@fayz-ai/ui": "^0.8.4",
39
+ "@fayz-ai/core": "^0.8.4",
40
+ "@fayz-ai/db": "^0.8.4",
41
+ "@fayz-ai/saas": "^0.8.4"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/react": "^18.3.0",
45
45
  "react": "^18.3.0",
46
46
  "tsup": "^8.2.0",
47
47
  "typescript": "^5.5.0",
48
- "@types/react-dom": "^18.3.0"
48
+ "@types/react-dom": "^18.3.0",
49
+ "vitest": "^2.1.9"
49
50
  },
50
51
  "license": "MIT",
51
52
  "keywords": [
@@ -60,6 +61,7 @@
60
61
  "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
61
62
  "dev": "tsup --watch",
62
63
  "typecheck": "tsc --noEmit",
64
+ "test": "vitest run",
63
65
  "clean": "rm -rf dist"
64
66
  }
65
67
  }
@@ -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
 
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import type { StoreApi } from 'zustand/vanilla'
3
3
  import { ModulePage, type ModuleNavItem } from '@fayz-ai/ui'
4
- import { useTranslation } from '@fayz-ai/core'
4
+ import { useTranslation, useDataChanged } from '@fayz-ai/core'
5
5
  import type { PluginQuickAction } from '@fayz-ai/core'
6
6
  import { useModuleNavigation, ModuleActionBar, createViewRouter } from '@fayz-ai/saas'
7
7
  import { MarketingContextProvider, type ResolvedMarketingConfig } from './MarketingContext'
@@ -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,15 +74,24 @@ 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])
71
83
 
84
+ // Agent RPC writes (createCampaign) land outside the provider — refresh past
85
+ // its read cache when any agent write is announced.
86
+ useDataChanged({ table: 'plg_marketing_campaigns' }, () => { void store.getState().refresh() }, [store])
87
+
72
88
  const isOverview = view === 'overview'
73
89
  const nav = buildNav(config, view, navigate)
74
90
 
91
+ // Blog backoffice entities — one shared EntityDef each drives the list + form.
92
+ const blogPostEntity = React.useMemo(() => buildBlogPostEntity(), [])
93
+ const blogCategoryEntity = React.useMemo(() => buildBlogCategoryEntity(), [])
94
+
75
95
  const quickActions = React.useMemo<PluginQuickAction[]>(() => [
76
96
  { id: 'new-campaign', label: t('marketing.campaigns.new'), icon: 'Megaphone', description: config.labels.campaigns, action: () => navigate('campaigns') },
77
97
  ], [t, config.labels.campaigns])
@@ -85,6 +105,11 @@ export function MarketingPage({ config, provider, store, contentProvider, conten
85
105
  { id: 'content', render: () => <ContentView onOpenPost={(id) => navigate(`content-post:${id}`)} /> },
86
106
  { id: 'content-post', render: ({ id }) => <PostPage postId={id!} onBack={() => navigate('content')} /> },
87
107
  { id: 'content-brief', render: () => <PlanBriefView onBack={() => navigate('content')} /> },
108
+ { id: 'blog', render: () => <BlogAdminView navigate={navigate} /> },
109
+ { id: 'blog-new', render: () => <BlogEntityForm entity={blogPostEntity} parentLabel={config.labels.blog} onDone={() => navigate('blog')} /> },
110
+ { id: 'blog-post', render: ({ id }) => <BlogEntityForm entity={blogPostEntity} editId={id!} parentLabel={config.labels.blog} onDone={() => navigate('blog')} /> },
111
+ { id: 'blog-cat-new', render: () => <BlogEntityForm entity={blogCategoryEntity} parentLabel={config.labels.blogCategories} onDone={() => navigate('blog')} /> },
112
+ { id: 'blog-cat', render: ({ id }) => <BlogEntityForm entity={blogCategoryEntity} editId={id!} parentLabel={config.labels.blogCategories} onDone={() => navigate('blog')} /> },
88
113
  { id: 'overview', render: () => <OverviewView /> },
89
114
  ], 'overview')
90
115
 
@@ -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
+ }
@@ -0,0 +1,92 @@
1
+ // Capability test for the marketing analytics data slice (mock provider) —
2
+ // template: plugin-tasks/src/data/capability.test.ts. The Supabase provider
3
+ // implements the same MarketingDataProvider interface against
4
+ // plg_marketing_channels/_campaigns + v_marketing_attribution.
5
+ import { describe, it, expect } from 'vitest'
6
+ import { createMockMarketingProvider } from './mock'
7
+ import { MARKETING_PRESETS } from '../presets'
8
+
9
+ function makeProvider() {
10
+ const preset = MARKETING_PRESETS.beauty
11
+ return createMockMarketingProvider({
12
+ channels: preset.channels,
13
+ conversion: preset.conversion,
14
+ domain: 'beauty',
15
+ })
16
+ }
17
+
18
+ describe('plugin-marketing capability · analytics slice (mock provider)', () => {
19
+ it('reports per-channel performance for every configured channel', async () => {
20
+ const provider = makeProvider()
21
+ const perf = await provider.channelPerformance({ range: '30d' })
22
+ expect(perf.map((c) => c.channelId).sort()).toEqual(
23
+ MARKETING_PRESETS.beauty.channels.map((c) => c.id).sort(),
24
+ )
25
+ for (const c of perf) {
26
+ expect(c.conversions).toBeGreaterThanOrEqual(0)
27
+ expect(c.cvr).toBeGreaterThanOrEqual(0)
28
+ }
29
+ })
30
+
31
+ it('overview aggregates the same channels the mix reports', async () => {
32
+ const provider = makeProvider()
33
+ const overview = await provider.overview({ range: '30d' })
34
+ expect(overview.channelMix.length).toBe(MARKETING_PRESETS.beauty.channels.length)
35
+ expect(overview.conversions).toBe(
36
+ overview.channelMix.reduce((s, c) => s + c.conversions, 0),
37
+ )
38
+ expect(overview.topChannelId).toBeTruthy()
39
+ })
40
+
41
+ it('persists a created campaign: it surfaces in list and get', async () => {
42
+ const provider = makeProvider()
43
+ const created = await provider.saveCampaign({
44
+ name: 'Campanha Teste',
45
+ channelId: 'instagram',
46
+ status: 'active',
47
+ start: new Date('2026-07-01').toISOString(),
48
+ spend: 150,
49
+ })
50
+ expect(created.id).toBeTruthy()
51
+
52
+ const listed = await provider.listCampaigns({ range: '30d' })
53
+ expect(listed.some((c) => c.id === created.id)).toBe(true)
54
+
55
+ const fetched = await provider.getCampaign(created.id)
56
+ expect(fetched?.name).toBe('Campanha Teste')
57
+ expect(fetched?.channelId).toBe('instagram')
58
+ })
59
+
60
+ it('round-trips update and delete', async () => {
61
+ const provider = makeProvider()
62
+ const created = await provider.saveCampaign({
63
+ name: 'temp',
64
+ channelId: 'google',
65
+ status: 'draft',
66
+ start: new Date('2026-07-01').toISOString(),
67
+ spend: 0,
68
+ })
69
+
70
+ const updated = await provider.saveCampaign({
71
+ id: created.id,
72
+ name: 'temp',
73
+ channelId: 'google',
74
+ status: 'paused',
75
+ start: created.start,
76
+ spend: 80,
77
+ })
78
+ expect(updated.status).toBe('paused')
79
+
80
+ await provider.deleteCampaign(created.id)
81
+ expect(await provider.getCampaign(created.id)).toBeNull()
82
+ })
83
+
84
+ it('funnel stages end on the vertical conversion label', async () => {
85
+ const provider = makeProvider()
86
+ const funnel = await provider.funnel({ range: '30d' })
87
+ expect(funnel.length).toBeGreaterThanOrEqual(2)
88
+ expect(funnel[funnel.length - 1].label).toBe(
89
+ MARKETING_PRESETS.beauty.conversion.labelPlural,
90
+ )
91
+ })
92
+ })