@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/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import React8, { useEffect } from 'react';
2
- import { getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, useTranslation } from '@fayz-ai/core';
3
- import { createPluginContext, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, useOrganizationStore } from '@fayz-ai/saas';
4
- import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, DashboardCanvas, SegmentedControl, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, SelectSeparator, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, Checkbox, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
5
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
- import { ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, MousePointerClick, Layers, NotebookPen, ArrowUpRight, ArrowDownRight, Settings2, Columns3, CalendarDays, List, ChevronLeft, Loader2, ImagePlus, X, Sparkles, Bike, Utensils, DoorOpen, MessageCircle, Instagram, Mail, Globe, Megaphone, Search, Link2, AtSign, Pin, Twitter, Linkedin, Facebook, Youtube, Music2, Repeat2, Heart, BarChart2, Share2, ThumbsUp, Send, MoreHorizontal, Bookmark, GripVertical, Image, Trophy } from 'lucide-react';
1
+ import React8, { useState, useEffect, useMemo, useCallback } from 'react';
2
+ import { getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, useTranslation, useDataChanged, resolveDataProvider } from '@fayz-ai/core';
3
+ import { createPluginContext, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, PermissionGate, useLimitGuard, useOrganizationStore, CrudFormPage, invalidateLimit, CrudListView } from '@fayz-ai/saas';
4
+ import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, SegmentedControl, DashboardCanvas, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, SelectSeparator, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, Checkbox, toast, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
5
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
+ import { Sparkles, History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, MousePointerClick, Layers, NotebookPen, ArrowUpRight, ArrowDownRight, Settings2, Columns3, CalendarDays, List, ChevronLeft, Loader2, ImagePlus, X, Bike, Utensils, DoorOpen, MessageCircle, Instagram, Mail, Globe, Megaphone, Search, Link2, AtSign, Pin, Twitter, Linkedin, Facebook, Youtube, Music2, Repeat2, Heart, BarChart2, Share2, ThumbsUp, Send, MoreHorizontal, Bookmark, GripVertical, Image, Trophy } from 'lucide-react';
7
7
  import { createStore } from 'zustand/vanilla';
8
8
 
9
9
  // src/index.ts
@@ -213,11 +213,11 @@ function ChannelDetailView({ channelId, onBack }) {
213
213
  const range = useMarketingStore((s) => s.range);
214
214
  const channels = useMarketingStore((s) => s.channels);
215
215
  const lookup = useChannelLookup();
216
- const provider = useMarketingProvider();
216
+ const provider2 = useMarketingProvider();
217
217
  const [campaigns, setCampaigns] = React8.useState([]);
218
218
  React8.useEffect(() => {
219
- void provider.listCampaigns({ range, channelId }).then(setCampaigns);
220
- }, [provider, range, channelId]);
219
+ void provider2.listCampaigns({ range, channelId }).then(setCampaigns);
220
+ }, [provider2, range, channelId]);
221
221
  const perf = channels.find((c) => c.channelId === channelId);
222
222
  const channel = lookup.get(channelId);
223
223
  const columns = React8.useMemo(() => [
@@ -359,11 +359,11 @@ function CampaignsView() {
359
359
  /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("marketing.campaigns.subtitle") }),
360
360
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
361
361
  /* @__PURE__ */ jsx(RangeTabs, {}),
362
- /* @__PURE__ */ jsxs(Button, { onClick: () => setComposerOpen(true), children: [
362
+ /* @__PURE__ */ jsx(PermissionGate, { feature: "marketing", action: "create", children: /* @__PURE__ */ jsxs(Button, { onClick: () => setComposerOpen(true), children: [
363
363
  /* @__PURE__ */ jsx(Plus, { className: "mr-1.5 h-4 w-4" }),
364
364
  " ",
365
365
  t("marketing.campaigns.new")
366
- ] })
366
+ ] }) })
367
367
  ] })
368
368
  ] }),
369
369
  /* @__PURE__ */ jsx(DataTable, { columns, data: campaigns, emptyMessage: t("marketing.campaigns.empty") }),
@@ -1308,6 +1308,7 @@ function ContentView({ onOpenPost }) {
1308
1308
  const viewMode = useContentPlannerStore((s) => s.viewMode);
1309
1309
  const setViewMode = useContentPlannerStore((s) => s.setViewMode);
1310
1310
  const loadedTenantId = useContentPlannerStore((s) => s.loadedTenantId);
1311
+ const guardContentPosts = useLimitGuard("content_posts_month");
1311
1312
  const [briefOpen, setBriefOpen] = React8.useState(false);
1312
1313
  const [pendingDeleteId, setPendingDeleteId] = React8.useState(null);
1313
1314
  const tenantId = useOrganizationStore((state) => state.currentOrg?.id);
@@ -1335,6 +1336,7 @@ function ContentView({ onOpenPost }) {
1335
1336
  });
1336
1337
  };
1337
1338
  const createPost = async (week) => {
1339
+ if (await guardContentPosts() === "blocked") return;
1338
1340
  const targetPlan = await ensurePlan();
1339
1341
  if (!targetPlan) return;
1340
1342
  const start = targetPlan.startDate ? /* @__PURE__ */ new Date(`${targetPlan.startDate}T00:00:00`) : mondayOfCurrentWeek();
@@ -1347,6 +1349,7 @@ function ContentView({ onOpenPost }) {
1347
1349
  status: "idea",
1348
1350
  scheduledDate: toISODate(start)
1349
1351
  });
1352
+ invalidateLimit("content_posts_month");
1350
1353
  onOpenPost(post.id);
1351
1354
  };
1352
1355
  const movePost = async (postId, week) => {
@@ -1355,6 +1358,7 @@ function ContentView({ onOpenPost }) {
1355
1358
  await savePost({ id: postId, weekNumber: week });
1356
1359
  };
1357
1360
  const createPostAt = async (isoDate) => {
1361
+ if (await guardContentPosts() === "blocked") return;
1358
1362
  const targetPlan = await ensurePlan();
1359
1363
  if (!targetPlan) return;
1360
1364
  let week = 1;
@@ -1372,6 +1376,7 @@ function ContentView({ onOpenPost }) {
1372
1376
  status: "idea",
1373
1377
  scheduledDate: isoDate
1374
1378
  });
1379
+ invalidateLimit("content_posts_month");
1375
1380
  onOpenPost(post.id);
1376
1381
  };
1377
1382
  const busy = !loaded || loading;
@@ -1962,7 +1967,7 @@ function toDraft2(post) {
1962
1967
  }
1963
1968
  function PostPage({ postId, onBack }) {
1964
1969
  const t = useTranslation();
1965
- const provider = useContentPlannerProvider();
1970
+ const provider2 = useContentPlannerProvider();
1966
1971
  const storePost = useContentPlannerStore((s) => s.posts.find((p) => p.id === postId));
1967
1972
  const savePost = useContentPlannerStore((s) => s.savePost);
1968
1973
  const deletePost = useContentPlannerStore((s) => s.deletePost);
@@ -1979,13 +1984,13 @@ function PostPage({ postId, onBack }) {
1979
1984
  setDraft(toDraft2(storePost));
1980
1985
  return;
1981
1986
  }
1982
- void provider.getPost(postId).then((fetched) => {
1987
+ void provider2.getPost(postId).then((fetched) => {
1983
1988
  if (fetched) {
1984
1989
  setPost(fetched);
1985
1990
  setDraft(toDraft2(fetched));
1986
1991
  }
1987
1992
  });
1988
- }, [postId, storePost, post?.id, provider]);
1993
+ }, [postId, storePost, post?.id, provider2]);
1989
1994
  if (!post || !draft) {
1990
1995
  return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-3xl space-y-5", children: [
1991
1996
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
@@ -2172,6 +2177,245 @@ function PostPage({ postId, onBack }) {
2172
2177
  post.id
2173
2178
  );
2174
2179
  }
2180
+
2181
+ // src/data/blogEntities.tsx
2182
+ var BLOG_POSTS_TABLE = "plg_blog_posts";
2183
+ var BLOG_CATEGORIES_TABLE = "plg_blog_categories";
2184
+ function buildBlogPostEntity() {
2185
+ const fields = [
2186
+ { key: "title", label: "Title", type: "text", required: true, searchable: true, showInTable: true, sortable: true },
2187
+ { key: "slug", label: "Slug", type: "text", required: true, searchable: true, showInTable: false, hint: 'URL identifier, e.g. "cannabis-and-oral-health"' },
2188
+ { key: "status", label: "Status", type: "select", options: ["draft", "published"], showInTable: true, defaultValue: "draft" },
2189
+ { key: "categoryId", label: "Category", type: "relation", relation: { table: BLOG_CATEGORIES_TABLE, valueField: "id", labelField: "name", tenantScoped: true }, showInTable: false },
2190
+ { key: "excerpt", label: "Excerpt", type: "textarea", span: 2, showInTable: false, placeholder: "Short summary shown on cards" },
2191
+ { key: "body", label: "Body", type: "markdown", span: 2, showInTable: false },
2192
+ { key: "coverImage", label: "Cover image", type: "image", showInTable: false, placeholder: "https://\u2026" },
2193
+ { key: "readTime", label: "Read time", type: "text", showInTable: false, placeholder: "5 min" },
2194
+ { key: "publishedAt", label: "Published at", type: "date", showInTable: true, sortable: true },
2195
+ { key: "authorName", label: "Author name", type: "text", showInTable: false },
2196
+ { key: "authorRole", label: "Author role", type: "text", showInTable: false, placeholder: "e.g. Cirurgi\xE3o-Dentista" },
2197
+ { key: "authorAvatarUrl", label: "Author avatar", type: "image", showInTable: false, placeholder: "https://\u2026" },
2198
+ { key: "authorBio", label: "Author bio", type: "textarea", span: 2, showInTable: false }
2199
+ ];
2200
+ return {
2201
+ name: "Post",
2202
+ namePlural: "Posts",
2203
+ icon: "Newspaper",
2204
+ fields,
2205
+ displayField: "title",
2206
+ subtitleField: "slug",
2207
+ facets: [{ field: "status", allLabel: "All" }],
2208
+ data: {
2209
+ table: BLOG_POSTS_TABLE,
2210
+ tenantScoped: true,
2211
+ searchColumns: ["title", "slug", "excerpt"]
2212
+ }
2213
+ };
2214
+ }
2215
+ function buildBlogCategoryEntity() {
2216
+ const fields = [
2217
+ { key: "name", label: "Name", type: "text", required: true, searchable: true, showInTable: true, sortable: true },
2218
+ { key: "slug", label: "Slug", type: "text", required: true, searchable: true, showInTable: true, hint: "URL identifier" },
2219
+ { key: "description", label: "Description", type: "textarea", span: 2, showInTable: false }
2220
+ ];
2221
+ return {
2222
+ name: "Category",
2223
+ namePlural: "Categories",
2224
+ icon: "Tags",
2225
+ fields,
2226
+ displayField: "name",
2227
+ data: {
2228
+ table: BLOG_CATEGORIES_TABLE,
2229
+ tenantScoped: true,
2230
+ searchColumns: ["name", "slug"]
2231
+ }
2232
+ };
2233
+ }
2234
+ function useEntityCrud(entity) {
2235
+ const provider2 = useMemo(() => resolveDataProvider(entity), [entity]);
2236
+ const [items, setItems] = useState(null);
2237
+ const [total, setTotal] = useState(0);
2238
+ const fetch = useCallback(async (query = {}) => {
2239
+ try {
2240
+ const res = await provider2.list(query);
2241
+ setItems(res.data);
2242
+ setTotal(res.total);
2243
+ } catch (err) {
2244
+ console.warn("[blog] list failed; showing empty. Apply the plugin-blog migration to this pool.", err);
2245
+ setItems([]);
2246
+ setTotal(0);
2247
+ }
2248
+ }, [provider2]);
2249
+ const getById = useCallback(async (id) => {
2250
+ const res = await provider2.list({ filters: { id }, page: 1, pageSize: 1 });
2251
+ return res.data[0] ?? null;
2252
+ }, [provider2]);
2253
+ const create = useCallback((data) => provider2.create(data), [provider2]);
2254
+ const update = useCallback((id, data) => provider2.update(id, data), [provider2]);
2255
+ const remove = useCallback((id) => provider2.remove(id), [provider2]);
2256
+ return { items, total, fetch, getById, create, update, remove };
2257
+ }
2258
+ function PostsList({ navigate }) {
2259
+ const entity = useMemo(() => buildBlogPostEntity(), []);
2260
+ const { items, total, fetch } = useEntityCrud(entity);
2261
+ const [search, setSearch] = useState("");
2262
+ const [statusFilter, setStatusFilter] = useState();
2263
+ const [loadedOnce, setLoadedOnce] = useState(false);
2264
+ useEffect(() => {
2265
+ let active = true;
2266
+ Promise.resolve(
2267
+ fetch({ search: search || void 0, filters: statusFilter ? { status: statusFilter } : void 0 })
2268
+ ).finally(() => {
2269
+ if (active) setLoadedOnce(true);
2270
+ });
2271
+ return () => {
2272
+ active = false;
2273
+ };
2274
+ }, [search, statusFilter, fetch]);
2275
+ const facets = useMemo(() => [{
2276
+ field: "status",
2277
+ allLabel: "All",
2278
+ options: [{ value: "draft", label: "Draft" }, { value: "published", label: "Published" }]
2279
+ }], []);
2280
+ return /* @__PURE__ */ jsx(
2281
+ CrudListView,
2282
+ {
2283
+ entityDef: entity,
2284
+ items: loadedOnce ? items : null,
2285
+ total,
2286
+ search,
2287
+ onSearchChange: setSearch,
2288
+ searchPlaceholder: "Search posts\u2026",
2289
+ facets,
2290
+ activeFilters: { status: statusFilter },
2291
+ onFacetChange: (_field, value) => setStatusFilter(value),
2292
+ onNew: () => navigate("blog-new"),
2293
+ addLabel: "New post",
2294
+ onRowClick: (row) => navigate(`blog-post:${row.id}`)
2295
+ }
2296
+ );
2297
+ }
2298
+ function CategoriesList({ navigate }) {
2299
+ const entity = useMemo(() => buildBlogCategoryEntity(), []);
2300
+ const { items, total, fetch } = useEntityCrud(entity);
2301
+ const [search, setSearch] = useState("");
2302
+ const [loadedOnce, setLoadedOnce] = useState(false);
2303
+ useEffect(() => {
2304
+ let active = true;
2305
+ Promise.resolve(fetch({ search: search || void 0 })).finally(() => {
2306
+ if (active) setLoadedOnce(true);
2307
+ });
2308
+ return () => {
2309
+ active = false;
2310
+ };
2311
+ }, [search, fetch]);
2312
+ return /* @__PURE__ */ jsx(
2313
+ CrudListView,
2314
+ {
2315
+ entityDef: entity,
2316
+ items: loadedOnce ? items : null,
2317
+ total,
2318
+ search,
2319
+ onSearchChange: setSearch,
2320
+ searchPlaceholder: "Search categories\u2026",
2321
+ onNew: () => navigate("blog-cat-new"),
2322
+ addLabel: "New category",
2323
+ onRowClick: (row) => navigate(`blog-cat:${row.id}`)
2324
+ }
2325
+ );
2326
+ }
2327
+ function BlogAdminView({ navigate }) {
2328
+ const { labels } = useMarketingConfig();
2329
+ const [lens, setLens] = useState("posts");
2330
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
2331
+ /* @__PURE__ */ jsx(
2332
+ SegmentedControl,
2333
+ {
2334
+ "aria-label": "Blog section",
2335
+ options: [
2336
+ { value: "posts", label: labels.blog },
2337
+ { value: "categories", label: labels.blogCategories }
2338
+ ],
2339
+ value: lens,
2340
+ onChange: (v) => setLens(v)
2341
+ }
2342
+ ),
2343
+ lens === "posts" ? /* @__PURE__ */ jsx(PostsList, { navigate }) : /* @__PURE__ */ jsx(CategoriesList, { navigate })
2344
+ ] });
2345
+ }
2346
+ function BlogEntityForm({
2347
+ entity,
2348
+ editId,
2349
+ parentLabel,
2350
+ onDone
2351
+ }) {
2352
+ const { getById, create, update, remove } = useEntityCrud(entity);
2353
+ const isEdit = !!editId;
2354
+ const isPost = entity.data?.table === BLOG_POSTS_TABLE;
2355
+ const guardBlogPosts = useLimitGuard("blog_posts");
2356
+ const namePlural = entity.namePlural ?? entity.name + "s";
2357
+ const [initialData, setInitialData] = React8.useState(isEdit ? null : {});
2358
+ React8.useEffect(() => {
2359
+ if (!editId) return;
2360
+ let cancelled = false;
2361
+ (async () => {
2362
+ const item = await getById(editId);
2363
+ if (!cancelled) setInitialData(item ?? {});
2364
+ })();
2365
+ return () => {
2366
+ cancelled = true;
2367
+ };
2368
+ }, [editId, getById]);
2369
+ async function save(values) {
2370
+ if (!isEdit && isPost && await guardBlogPosts() === "blocked") return;
2371
+ try {
2372
+ if (isEdit) await update(editId, values);
2373
+ else {
2374
+ await create(values);
2375
+ if (isPost) invalidateLimit("blog_posts");
2376
+ }
2377
+ toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`);
2378
+ onDone();
2379
+ } catch (err) {
2380
+ toast.error(`Failed to save ${entity.name.toLowerCase()}`);
2381
+ throw err;
2382
+ }
2383
+ }
2384
+ async function del() {
2385
+ if (!editId) return;
2386
+ try {
2387
+ await remove(editId);
2388
+ toast.success(`${entity.name} deleted`);
2389
+ onDone();
2390
+ } catch {
2391
+ toast.error(`Failed to delete ${entity.name.toLowerCase()}`);
2392
+ }
2393
+ }
2394
+ const title = isEdit ? initialData?.[entity.displayField ?? "name"] ?? entity.name : `New ${entity.name.toLowerCase()}`;
2395
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
2396
+ /* @__PURE__ */ jsx(
2397
+ SubpageHeader,
2398
+ {
2399
+ title: String(title),
2400
+ onBack: onDone,
2401
+ parentLabel,
2402
+ actions: isEdit ? /* @__PURE__ */ jsx(Button, { variant: "ghost", className: "text-destructive hover:text-destructive", onClick: del, children: "Delete" }) : void 0
2403
+ }
2404
+ ),
2405
+ initialData !== null && /* @__PURE__ */ jsx(
2406
+ CrudFormPage,
2407
+ {
2408
+ entityDef: entity,
2409
+ mode: isEdit ? "edit" : "create",
2410
+ initialData,
2411
+ namePlural,
2412
+ onCancel: onDone,
2413
+ onSubmit: save,
2414
+ hideBreadcrumb: true
2415
+ }
2416
+ )
2417
+ ] });
2418
+ }
2175
2419
  function buildNav(config, view, navigate) {
2176
2420
  const items = [
2177
2421
  { id: "overview", label: config.labels.overview, icon: "BarChart3", active: view === "overview", onClick: () => navigate("overview") }
@@ -2203,9 +2447,18 @@ function buildNav(config, view, navigate) {
2203
2447
  onClick: () => navigate("content")
2204
2448
  });
2205
2449
  }
2450
+ if (config.modules.blog) {
2451
+ items.push({
2452
+ id: "blog",
2453
+ label: config.labels.blog,
2454
+ icon: "Newspaper",
2455
+ active: view === "blog" || view === "blog-new" || view.startsWith("blog-post:") || view === "blog-cat-new" || view.startsWith("blog-cat:"),
2456
+ onClick: () => navigate("blog")
2457
+ });
2458
+ }
2206
2459
  return items;
2207
2460
  }
2208
- function MarketingPage({ config, provider, store, contentProvider, contentStore }) {
2461
+ function MarketingPage({ config, provider: provider2, store, contentProvider, contentStore }) {
2209
2462
  const t = useTranslation();
2210
2463
  const { view, direction, navigate } = useModuleNavigation("/marketing", {
2211
2464
  overview: 0,
@@ -2214,15 +2467,25 @@ function MarketingPage({ config, provider, store, contentProvider, contentStore
2214
2467
  funnel: 0,
2215
2468
  "landing-pages": 0,
2216
2469
  content: 0,
2470
+ blog: 0,
2217
2471
  "channel-detail": 1,
2218
2472
  "content-post": 1,
2219
- "content-brief": 1
2473
+ "content-brief": 1,
2474
+ "blog-new": 1,
2475
+ "blog-post": 1,
2476
+ "blog-cat-new": 1,
2477
+ "blog-cat": 1
2220
2478
  }, "overview");
2221
2479
  React8.useEffect(() => {
2222
2480
  void store.getState().load();
2223
2481
  }, [store]);
2482
+ useDataChanged({ table: "plg_marketing_campaigns" }, () => {
2483
+ void store.getState().refresh();
2484
+ }, [store]);
2224
2485
  const isOverview = view === "overview";
2225
2486
  const nav = buildNav(config, view, navigate);
2487
+ const blogPostEntity = React8.useMemo(() => buildBlogPostEntity(), []);
2488
+ const blogCategoryEntity = React8.useMemo(() => buildBlogCategoryEntity(), []);
2226
2489
  const quickActions = React8.useMemo(() => [
2227
2490
  { id: "new-campaign", label: t("marketing.campaigns.new"), icon: "Megaphone", description: config.labels.campaigns, action: () => navigate("campaigns") }
2228
2491
  ], [t, config.labels.campaigns]);
@@ -2235,9 +2498,14 @@ function MarketingPage({ config, provider, store, contentProvider, contentStore
2235
2498
  { id: "content", render: () => /* @__PURE__ */ jsx(ContentView, { onOpenPost: (id) => navigate(`content-post:${id}`) }) },
2236
2499
  { id: "content-post", render: ({ id }) => /* @__PURE__ */ jsx(PostPage, { postId: id, onBack: () => navigate("content") }) },
2237
2500
  { id: "content-brief", render: () => /* @__PURE__ */ jsx(PlanBriefView, { onBack: () => navigate("content") }) },
2501
+ { id: "blog", render: () => /* @__PURE__ */ jsx(BlogAdminView, { navigate }) },
2502
+ { id: "blog-new", render: () => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogPostEntity, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
2503
+ { id: "blog-post", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogPostEntity, editId: id, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
2504
+ { id: "blog-cat-new", render: () => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2505
+ { id: "blog-cat", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, editId: id, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2238
2506
  { id: "overview", render: () => /* @__PURE__ */ jsx(OverviewView, {}) }
2239
2507
  ], "overview");
2240
- return /* @__PURE__ */ jsx(MarketingContextProvider, { config, provider, store, children: /* @__PURE__ */ jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsx(
2508
+ return /* @__PURE__ */ jsx(MarketingContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsx(
2241
2509
  ModulePage,
2242
2510
  {
2243
2511
  title: config.labels.pageTitle,
@@ -2329,6 +2597,20 @@ function SettingsView() {
2329
2597
  ] });
2330
2598
  }
2331
2599
 
2600
+ // src/data/tables.ts
2601
+ var T = {
2602
+ socialAccounts: "plg_marketing_social_accounts",
2603
+ contentPlans: "plg_marketing_content_plans",
2604
+ contentPosts: "plg_marketing_content_posts",
2605
+ channels: "plg_marketing_channels",
2606
+ campaigns: "plg_marketing_campaigns"
2607
+ };
2608
+ var V = {
2609
+ channels: "v_marketing_channels",
2610
+ campaigns: "v_marketing_campaigns",
2611
+ attribution: "v_marketing_attribution"
2612
+ };
2613
+
2332
2614
  // src/registries.ts
2333
2615
  var KIND_OPTIONS = [
2334
2616
  { value: "paid", label: "Paid" },
@@ -2346,10 +2628,18 @@ var channelEntity = {
2346
2628
  defaultSort: "label",
2347
2629
  fields: [
2348
2630
  { key: "label", label: "Channel", type: "text", required: true, showInTable: true, searchable: true },
2631
+ { key: "channelKey", label: "Key", type: "text", required: true, showInTable: true },
2349
2632
  { key: "kind", label: "Type", type: "select", options: KIND_OPTIONS, showInTable: true },
2350
- { key: "icon", label: "Icon", type: "text", showInTable: true }
2633
+ { key: "icon", label: "Icon", type: "text" },
2634
+ { key: "monthlySpend", label: "Monthly spend", type: "number", showInTable: true, defaultValue: 0 },
2635
+ { key: "isActive", label: "Active", type: "boolean", showInTable: true, defaultValue: true, inlineToggle: true }
2351
2636
  ],
2352
- facets: [{ field: "kind" }]
2637
+ facets: [{ field: "kind" }],
2638
+ data: {
2639
+ table: T.channels,
2640
+ tenantScoped: true,
2641
+ searchColumns: ["label", "channel_key"]
2642
+ }
2353
2643
  };
2354
2644
  function buildMarketingRegistries(channels) {
2355
2645
  return [
@@ -2359,8 +2649,17 @@ function buildMarketingRegistries(channels) {
2359
2649
  icon: "Radio",
2360
2650
  description: "Acquisition channels tracked for this workspace",
2361
2651
  display: "table",
2362
- readOnly: true,
2363
- seedData: channels.map((c) => ({ id: c.id, label: c.label, kind: c.kind, icon: c.icon }))
2652
+ seedData: [
2653
+ ...channels.map((c) => ({
2654
+ id: c.id,
2655
+ channelKey: c.id,
2656
+ label: c.label,
2657
+ kind: c.kind,
2658
+ icon: c.icon,
2659
+ isActive: true,
2660
+ monthlySpend: 0
2661
+ }))
2662
+ ]
2364
2663
  }
2365
2664
  ];
2366
2665
  }
@@ -2512,7 +2811,7 @@ var AGENCY = {
2512
2811
  { id: "referral", label: "Referral", icon: "Users", kind: "referral" },
2513
2812
  { id: "direct", label: "Direct", icon: "MousePointerClick", kind: "direct" }
2514
2813
  ],
2515
- modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, broadcasts: false, journeys: false }
2814
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
2516
2815
  };
2517
2816
  var BEAUTY = {
2518
2817
  conversion: {
@@ -2530,7 +2829,7 @@ var BEAUTY = {
2530
2829
  { id: "whatsapp", label: "WhatsApp", icon: "MessageCircle", kind: "social" },
2531
2830
  { id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
2532
2831
  ],
2533
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, broadcasts: false, journeys: false }
2832
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, blog: false, broadcasts: false, journeys: false }
2534
2833
  };
2535
2834
  var RESTO = {
2536
2835
  conversion: {
@@ -2548,7 +2847,7 @@ var RESTO = {
2548
2847
  { id: "instagram", label: "Instagram", icon: "Instagram", kind: "social" },
2549
2848
  { id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
2550
2849
  ],
2551
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, broadcasts: false, journeys: false }
2850
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
2552
2851
  };
2553
2852
  var MARKETING_PRESETS = {
2554
2853
  agency: AGENCY,
@@ -2781,6 +3080,276 @@ function createMockMarketingProvider(cfg) {
2781
3080
  }
2782
3081
  };
2783
3082
  }
3083
+ var RANGE_DAYS = { "7d": 7, "30d": 30, "90d": 90 };
3084
+ function getClient() {
3085
+ const supabase = getSupabaseClientOptional();
3086
+ if (!supabase) throw new Error("Supabase not initialized");
3087
+ return supabase;
3088
+ }
3089
+ function requireTenantId() {
3090
+ const tenantId = getActiveTenantId();
3091
+ if (!tenantId) throw new Error("No active tenant");
3092
+ return tenantId;
3093
+ }
3094
+ function normKey(value) {
3095
+ return (value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
3096
+ }
3097
+ function createSupabaseMarketingProvider(options) {
3098
+ const { channels, conversion, attributionBridge, sitesBridge } = options;
3099
+ const channelByNorm = /* @__PURE__ */ new Map();
3100
+ for (const ch of channels) {
3101
+ channelByNorm.set(normKey(ch.id), ch.id);
3102
+ channelByNorm.set(normKey(ch.label), ch.id);
3103
+ }
3104
+ const resolveChannel = (raw) => channelByNorm.get(normKey(raw)) ?? null;
3105
+ const seeded = /* @__PURE__ */ new Set();
3106
+ async function ensureChannels(tenantId) {
3107
+ if (seeded.has(tenantId)) return;
3108
+ seeded.add(tenantId);
3109
+ await getClient().from(T.channels).upsert(
3110
+ channels.map((c) => ({
3111
+ tenant_id: tenantId,
3112
+ channel_key: c.id,
3113
+ label: c.label,
3114
+ icon: c.icon,
3115
+ kind: c.kind
3116
+ })),
3117
+ { onConflict: "tenant_id,channel_key", ignoreDuplicates: true }
3118
+ );
3119
+ }
3120
+ const CACHE_TTL_MS = 3e4;
3121
+ const cache = /* @__PURE__ */ new Map();
3122
+ function memo(key, fn) {
3123
+ const hit = cache.get(key);
3124
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.promise;
3125
+ const promise = fn().catch((err) => {
3126
+ cache.delete(key);
3127
+ throw err;
3128
+ });
3129
+ cache.set(key, { at: Date.now(), promise });
3130
+ return promise;
3131
+ }
3132
+ function windowFor(range) {
3133
+ const days = RANGE_DAYS[range];
3134
+ const now = Math.floor(Date.now() / 6e4) * 6e4;
3135
+ return { since: new Date(now - days * 864e5), days };
3136
+ }
3137
+ function fetchAttribution(tenantId, since, until) {
3138
+ const key = `attr:${tenantId}:${since.getTime()}:${until?.getTime() ?? ""}`;
3139
+ return memo(key, async () => {
3140
+ let q = getClient().from(V.attribution).select("kind, source, channel_raw, occurred_at, value").eq("tenant_id", tenantId).gte("occurred_at", since.toISOString()).or(`kind.eq.lead,and(kind.eq.conversion,source.eq.${conversion.source})`);
3141
+ if (until) q = q.lt("occurred_at", until.toISOString());
3142
+ const { data, error } = await q;
3143
+ if (error) throw error;
3144
+ return data ?? [];
3145
+ });
3146
+ }
3147
+ function rollup(rows) {
3148
+ const map = /* @__PURE__ */ new Map();
3149
+ for (const row of rows) {
3150
+ const channelId = resolveChannel(row.channel_raw);
3151
+ if (!channelId) continue;
3152
+ const agg = map.get(channelId) ?? { leads: 0, conversions: 0, value: 0 };
3153
+ if (row.kind === "lead") agg.leads += 1;
3154
+ else {
3155
+ agg.conversions += 1;
3156
+ agg.value += Number(row.value ?? conversion.avgValue);
3157
+ }
3158
+ map.set(channelId, agg);
3159
+ }
3160
+ return map;
3161
+ }
3162
+ function fetchSpend(tenantId, days, since) {
3163
+ return memo(`spend:${tenantId}:${days}:${since.getTime()}`, () => fetchSpendRaw(tenantId, days, since));
3164
+ }
3165
+ async function fetchSpendRaw(tenantId, days, since) {
3166
+ const client = getClient();
3167
+ const [channelRows, campaignRows] = await Promise.all([
3168
+ client.from(V.channels).select("channel_key, monthly_spend").eq("tenant_id", tenantId),
3169
+ client.from(V.campaigns).select("channel_key, spend, status, starts_at, ends_at").eq("tenant_id", tenantId).neq("status", "draft")
3170
+ ]);
3171
+ const spend = /* @__PURE__ */ new Map();
3172
+ for (const row of channelRows.data ?? []) {
3173
+ const id = resolveChannel(row.channel_key);
3174
+ if (!id) continue;
3175
+ spend.set(id, (spend.get(id) ?? 0) + Number(row.monthly_spend) * days / 30);
3176
+ }
3177
+ for (const row of campaignRows.data ?? []) {
3178
+ const id = resolveChannel(row.channel_key);
3179
+ if (!id) continue;
3180
+ const ended = row.ends_at ? new Date(row.ends_at) : null;
3181
+ if (ended && ended < since) continue;
3182
+ spend.set(id, (spend.get(id) ?? 0) + Number(row.spend));
3183
+ }
3184
+ return spend;
3185
+ }
3186
+ async function channelPerformance(query) {
3187
+ const tenantId = requireTenantId();
3188
+ await ensureChannels(tenantId);
3189
+ const { since, days } = windowFor(query.range);
3190
+ const [rows, spend] = await Promise.all([
3191
+ fetchAttribution(tenantId, since),
3192
+ fetchSpend(tenantId, days, since)
3193
+ ]);
3194
+ const byChannel = rollup(rows);
3195
+ if (attributionBridge) {
3196
+ const bridged = await attributionBridge.conversionsByChannel(query);
3197
+ for (const b of bridged) {
3198
+ const agg = byChannel.get(b.channelId) ?? { leads: 0, conversions: 0, value: 0 };
3199
+ agg.conversions = b.conversions;
3200
+ agg.value = b.value;
3201
+ byChannel.set(b.channelId, agg);
3202
+ }
3203
+ }
3204
+ const list = channels.map((ch) => {
3205
+ const agg = byChannel.get(ch.id) ?? { leads: 0, conversions: 0, value: 0 };
3206
+ const chSpend = spend.get(ch.id) ?? 0;
3207
+ return {
3208
+ channelId: ch.id,
3209
+ reach: 0,
3210
+ leads: agg.leads,
3211
+ conversions: agg.conversions,
3212
+ spend: Math.round(chSpend),
3213
+ value: Math.round(agg.value),
3214
+ cvr: 0,
3215
+ cpa: agg.conversions > 0 && chSpend > 0 ? chSpend / agg.conversions : 0
3216
+ };
3217
+ });
3218
+ return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list;
3219
+ }
3220
+ function campaignFromRow(row) {
3221
+ return {
3222
+ id: row.id,
3223
+ name: row.name,
3224
+ channelId: row.channel_key,
3225
+ status: row.status,
3226
+ start: row.starts_at ?? row.created_at,
3227
+ end: row.ends_at ?? void 0,
3228
+ spend: Number(row.spend ?? 0),
3229
+ reach: 0,
3230
+ leads: 0,
3231
+ conversions: 0,
3232
+ value: 0
3233
+ };
3234
+ }
3235
+ function withDerived(campaign, rows) {
3236
+ const start = new Date(campaign.start).getTime();
3237
+ const end = campaign.end ? new Date(campaign.end).getTime() : Number.POSITIVE_INFINITY;
3238
+ let leads = 0;
3239
+ let conversions = 0;
3240
+ let value = 0;
3241
+ for (const row of rows) {
3242
+ if (resolveChannel(row.channel_raw) !== campaign.channelId) continue;
3243
+ const at = new Date(row.occurred_at).getTime();
3244
+ if (at < start || at > end) continue;
3245
+ if (row.kind === "lead") leads += 1;
3246
+ else {
3247
+ conversions += 1;
3248
+ value += Number(row.value ?? conversion.avgValue);
3249
+ }
3250
+ }
3251
+ return { ...campaign, leads, conversions, value: Math.round(value) };
3252
+ }
3253
+ return {
3254
+ channelPerformance,
3255
+ async overview(query) {
3256
+ const tenantId = requireTenantId();
3257
+ const perf = await channelPerformance(query);
3258
+ const { since, days } = windowFor(query.range);
3259
+ const prevRows = await fetchAttribution(
3260
+ tenantId,
3261
+ new Date(since.getTime() - days * 864e5),
3262
+ since
3263
+ );
3264
+ const conversions = perf.reduce((s, c) => s + c.conversions, 0);
3265
+ const spend = perf.reduce((s, c) => s + c.spend, 0);
3266
+ const value = perf.reduce((s, c) => s + c.value, 0);
3267
+ const conversionsPrev = prevRows.filter(
3268
+ (r) => r.kind === "conversion" && resolveChannel(r.channel_raw)
3269
+ ).length;
3270
+ const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0];
3271
+ return {
3272
+ conversions,
3273
+ conversionsPrev,
3274
+ cvr: 0,
3275
+ cvrPrev: 0,
3276
+ spend,
3277
+ cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
3278
+ value,
3279
+ topChannelId: top && top.conversions > 0 ? top.channelId : null,
3280
+ channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions }))
3281
+ };
3282
+ },
3283
+ async funnel(query) {
3284
+ if (attributionBridge) return attributionBridge.funnel(query);
3285
+ const tenantId = requireTenantId();
3286
+ const { since } = windowFor(query.range);
3287
+ const rows = await fetchAttribution(tenantId, since);
3288
+ const leads = rows.filter((r) => r.kind === "lead").length;
3289
+ const conversions = rows.filter((r) => r.kind === "conversion").length;
3290
+ return [
3291
+ { id: "leads", label: "Leads", count: leads, color: "#0ea5e9" },
3292
+ { id: "converted", label: conversion.labelPlural, count: conversions, color: "#22c55e" }
3293
+ ];
3294
+ },
3295
+ async listCampaigns(query) {
3296
+ const tenantId = requireTenantId();
3297
+ const fetchRows = () => memo(`campaigns:${tenantId}`, async () => {
3298
+ const { data: data2, error } = await getClient().from(V.campaigns).select("*").eq("tenant_id", tenantId).order("created_at", { ascending: false });
3299
+ if (error) throw error;
3300
+ return data2 ?? [];
3301
+ });
3302
+ const [data, rows] = await Promise.all([
3303
+ fetchRows(),
3304
+ fetchAttribution(tenantId, windowFor(query.range).since)
3305
+ ]);
3306
+ const list = query.channelId ? data.filter((r) => r.channel_key === query.channelId) : data;
3307
+ return list.map((row) => withDerived(campaignFromRow(row), rows));
3308
+ },
3309
+ async getCampaign(id) {
3310
+ const tenantId = requireTenantId();
3311
+ const { data, error } = await getClient().from(V.campaigns).select("*").eq("tenant_id", tenantId).eq("id", id).maybeSingle();
3312
+ if (error) throw error;
3313
+ if (!data) return null;
3314
+ const campaign = campaignFromRow(data);
3315
+ const rows = await fetchAttribution(tenantId, new Date(campaign.start));
3316
+ return withDerived(campaign, rows);
3317
+ },
3318
+ async saveCampaign(input) {
3319
+ const tenantId = requireTenantId();
3320
+ const payload = {
3321
+ name: input.name,
3322
+ channel_key: input.channelId,
3323
+ status: input.status,
3324
+ starts_at: input.start || null,
3325
+ ends_at: input.end || null,
3326
+ spend: input.spend ?? 0
3327
+ };
3328
+ if (input.id) {
3329
+ const { data: data2, error: error2 } = await getClient().from(T.campaigns).update(payload).eq("id", input.id).select("*").single();
3330
+ if (error2) throw error2;
3331
+ cache.clear();
3332
+ return campaignFromRow(data2);
3333
+ }
3334
+ const { data, error } = await getClient().from(T.campaigns).insert({ tenant_id: tenantId, ...payload }).select("*").single();
3335
+ if (error) throw error;
3336
+ cache.clear();
3337
+ return campaignFromRow(data);
3338
+ },
3339
+ async deleteCampaign(id) {
3340
+ const { error } = await getClient().from(T.campaigns).delete().eq("id", id);
3341
+ if (error) throw error;
3342
+ cache.clear();
3343
+ },
3344
+ invalidateCache() {
3345
+ cache.clear();
3346
+ },
3347
+ async landingPages(query) {
3348
+ if (sitesBridge) return sitesBridge.landingPages(query);
3349
+ return [];
3350
+ }
3351
+ };
3352
+ }
2784
3353
 
2785
3354
  // src/data/contentMock.ts
2786
3355
  var counter = 0;
@@ -3028,21 +3597,12 @@ function createMockContentPlannerProvider() {
3028
3597
  }
3029
3598
  };
3030
3599
  }
3031
-
3032
- // src/data/tables.ts
3033
- var T = {
3034
- socialAccounts: "plg_marketing_social_accounts",
3035
- contentPlans: "plg_marketing_content_plans",
3036
- contentPosts: "plg_marketing_content_posts"
3037
- };
3038
-
3039
- // src/data/contentSupabase.ts
3040
- function getClient() {
3600
+ function getClient2() {
3041
3601
  const supabase = getSupabaseClientOptional();
3042
3602
  if (!supabase) throw new Error("Supabase not initialized");
3043
3603
  return supabase;
3044
3604
  }
3045
- function requireTenantId() {
3605
+ function requireTenantId2() {
3046
3606
  const tenantId = getActiveTenantId();
3047
3607
  if (!tenantId) throw new Error("No active tenant");
3048
3608
  return tenantId;
@@ -3078,7 +3638,7 @@ function createSupabaseContentPlannerProvider() {
3078
3638
  async listAccounts() {
3079
3639
  const tenantId = getActiveTenantId();
3080
3640
  if (!tenantId) return [];
3081
- const { data } = await getClient().from(T.socialAccounts).select("*").eq("tenant_id", tenantId).eq("is_active", true).order("created_at", { ascending: true });
3641
+ const { data } = await getClient2().from(T.socialAccounts).select("*").eq("tenant_id", tenantId).eq("is_active", true).order("created_at", { ascending: true });
3082
3642
  return (data ?? []).map(toAccount);
3083
3643
  },
3084
3644
  async saveAccount(input) {
@@ -3088,12 +3648,12 @@ function createSupabaseContentPlannerProvider() {
3088
3648
  if (input.handle !== void 0) payload.handle = input.handle;
3089
3649
  if (input.platforms !== void 0) payload.platforms = input.platforms;
3090
3650
  if (input.isActive !== void 0) payload.is_active = input.isActive;
3091
- const { data: data2, error: error2 } = await getClient().from(T.socialAccounts).update(payload).eq("id", input.id).select("*").single();
3651
+ const { data: data2, error: error2 } = await getClient2().from(T.socialAccounts).update(payload).eq("id", input.id).select("*").single();
3092
3652
  if (error2) throw error2;
3093
3653
  return toAccount(data2);
3094
3654
  }
3095
- const { data, error } = await getClient().from(T.socialAccounts).insert({
3096
- tenant_id: requireTenantId(),
3655
+ const { data, error } = await getClient2().from(T.socialAccounts).insert({
3656
+ tenant_id: requireTenantId2(),
3097
3657
  name: input.name,
3098
3658
  handle: input.handle ?? null,
3099
3659
  platforms: input.platforms ?? ["instagram"],
@@ -3103,17 +3663,17 @@ function createSupabaseContentPlannerProvider() {
3103
3663
  return toAccount(data);
3104
3664
  },
3105
3665
  async deleteAccount(id) {
3106
- const { error } = await getClient().from(T.socialAccounts).delete().eq("id", id);
3666
+ const { error } = await getClient2().from(T.socialAccounts).delete().eq("id", id);
3107
3667
  if (error) throw error;
3108
3668
  },
3109
3669
  async listPlans(accountId) {
3110
3670
  const tenantId = getActiveTenantId();
3111
3671
  if (!tenantId) return [];
3112
- const { data } = await getClient().from(T.contentPlans).select("*").eq("tenant_id", tenantId).eq("account_id", accountId).order("created_at", { ascending: false });
3672
+ const { data } = await getClient2().from(T.contentPlans).select("*").eq("tenant_id", tenantId).eq("account_id", accountId).order("created_at", { ascending: false });
3113
3673
  return (data ?? []).map(toPlan);
3114
3674
  },
3115
3675
  async getPlan(id) {
3116
- const { data } = await getClient().from(T.contentPlans).select("*").eq("id", id).maybeSingle();
3676
+ const { data } = await getClient2().from(T.contentPlans).select("*").eq("id", id).maybeSingle();
3117
3677
  return data ? toPlan(data) : null;
3118
3678
  },
3119
3679
  async savePlan(input) {
@@ -3129,13 +3689,13 @@ function createSupabaseContentPlannerProvider() {
3129
3689
  if (input.formats !== void 0) payload.formats = input.formats;
3130
3690
  if (input.weeklyFrequency !== void 0) payload.weekly_frequency = input.weeklyFrequency;
3131
3691
  if (input.briefMd !== void 0) payload.brief_md = input.briefMd;
3132
- const { data: data2, error: error2 } = await getClient().from(T.contentPlans).update(payload).eq("id", input.id).select("*").single();
3692
+ const { data: data2, error: error2 } = await getClient2().from(T.contentPlans).update(payload).eq("id", input.id).select("*").single();
3133
3693
  if (error2) throw error2;
3134
3694
  return toPlan(data2);
3135
3695
  }
3136
3696
  if (!input.accountId) throw new Error("accountId is required");
3137
- const { data, error } = await getClient().from(T.contentPlans).insert({
3138
- tenant_id: requireTenantId(),
3697
+ const { data, error } = await getClient2().from(T.contentPlans).insert({
3698
+ tenant_id: requireTenantId2(),
3139
3699
  account_id: input.accountId,
3140
3700
  name: input.name ?? "Novo plano",
3141
3701
  status: input.status ?? "draft",
@@ -3152,14 +3712,14 @@ function createSupabaseContentPlannerProvider() {
3152
3712
  return toPlan(data);
3153
3713
  },
3154
3714
  async deletePlan(id) {
3155
- await getClient().from(T.contentPlans).delete().eq("id", id);
3715
+ await getClient2().from(T.contentPlans).delete().eq("id", id);
3156
3716
  },
3157
3717
  async listPosts(planId) {
3158
- const { data } = await getClient().from(T.contentPosts).select("*").eq("plan_id", planId).order("week_number", { ascending: true }).order("position", { ascending: true });
3718
+ const { data } = await getClient2().from(T.contentPosts).select("*").eq("plan_id", planId).order("week_number", { ascending: true }).order("position", { ascending: true });
3159
3719
  return (data ?? []).map(toPost);
3160
3720
  },
3161
3721
  async getPost(id) {
3162
- const { data } = await getClient().from(T.contentPosts).select("*").eq("id", id).maybeSingle();
3722
+ const { data } = await getClient2().from(T.contentPosts).select("*").eq("id", id).maybeSingle();
3163
3723
  return data ? toPost(data) : null;
3164
3724
  },
3165
3725
  async savePost(input) {
@@ -3177,16 +3737,16 @@ function createSupabaseContentPlannerProvider() {
3177
3737
  if (input.hook !== void 0) payload.hook = input.hook;
3178
3738
  if (input.cta !== void 0) payload.cta = input.cta;
3179
3739
  if (input.contentMd !== void 0) payload.content_md = input.contentMd;
3180
- const { data: data2, error: error2 } = await getClient().from(T.contentPosts).update(payload).eq("id", input.id).select("*").single();
3740
+ const { data: data2, error: error2 } = await getClient2().from(T.contentPosts).update(payload).eq("id", input.id).select("*").single();
3181
3741
  if (error2) throw error2;
3182
3742
  return toPost(data2);
3183
3743
  }
3184
3744
  if (!input.planId) throw new Error("planId is required");
3185
3745
  const week = input.weekNumber ?? 1;
3186
- const { data: maxData } = await getClient().from(T.contentPosts).select("position").eq("plan_id", input.planId).eq("week_number", week).order("position", { ascending: false }).limit(1);
3746
+ const { data: maxData } = await getClient2().from(T.contentPosts).select("position").eq("plan_id", input.planId).eq("week_number", week).order("position", { ascending: false }).limit(1);
3187
3747
  const maxPos = maxData?.[0]?.position ?? -1;
3188
- const { data, error } = await getClient().from(T.contentPosts).insert({
3189
- tenant_id: requireTenantId(),
3748
+ const { data, error } = await getClient2().from(T.contentPosts).insert({
3749
+ tenant_id: requireTenantId2(),
3190
3750
  plan_id: input.planId,
3191
3751
  week_number: week,
3192
3752
  position: input.position ?? maxPos + 1,
@@ -3205,11 +3765,11 @@ function createSupabaseContentPlannerProvider() {
3205
3765
  return toPost(data);
3206
3766
  },
3207
3767
  async deletePost(id) {
3208
- await getClient().from(T.contentPosts).delete().eq("id", id);
3768
+ await getClient2().from(T.contentPosts).delete().eq("id", id);
3209
3769
  }
3210
3770
  };
3211
3771
  }
3212
- function createMarketingStore(provider) {
3772
+ function createMarketingStore(provider2) {
3213
3773
  return createStore((set, get) => ({
3214
3774
  range: "30d",
3215
3775
  overview: null,
@@ -3221,35 +3781,44 @@ function createMarketingStore(provider) {
3221
3781
  async load() {
3222
3782
  const range = get().range;
3223
3783
  set({ loading: true });
3224
- const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
3225
- provider.overview({ range }),
3226
- provider.channelPerformance({ range }),
3227
- provider.listCampaigns({ range }),
3228
- provider.funnel({ range }),
3229
- provider.landingPages({ range })
3230
- ]);
3231
- set({ overview, channels, campaigns, funnel, landingPages, loading: false });
3784
+ try {
3785
+ const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
3786
+ provider2.overview({ range }),
3787
+ provider2.channelPerformance({ range }),
3788
+ provider2.listCampaigns({ range }),
3789
+ provider2.funnel({ range }),
3790
+ provider2.landingPages({ range })
3791
+ ]);
3792
+ set({ overview, channels, campaigns, funnel, landingPages, loading: false });
3793
+ } catch (err) {
3794
+ set({ loading: false });
3795
+ throw err;
3796
+ }
3797
+ },
3798
+ async refresh() {
3799
+ provider2.invalidateCache?.();
3800
+ await get().load();
3232
3801
  },
3233
3802
  async setRange(range) {
3234
3803
  set({ range });
3235
3804
  await get().load();
3236
3805
  },
3237
3806
  async saveCampaign(input) {
3238
- const created = await provider.saveCampaign(input);
3807
+ const created = await provider2.saveCampaign(input);
3239
3808
  await get().load();
3240
3809
  return created;
3241
3810
  },
3242
3811
  async deleteCampaign(id) {
3243
- await provider.deleteCampaign(id);
3812
+ await provider2.deleteCampaign(id);
3244
3813
  await get().load();
3245
3814
  }
3246
3815
  }));
3247
3816
  }
3248
- function createContentPlannerStore(provider) {
3817
+ function createContentPlannerStore(provider2) {
3249
3818
  async function loadPlansFor(accountId) {
3250
- const plans = await provider.listPlans(accountId);
3819
+ const plans = await provider2.listPlans(accountId);
3251
3820
  const activePlan = plans.find((p) => p.status === "active") ?? plans[0] ?? null;
3252
- const posts = activePlan ? await provider.listPosts(activePlan.id) : [];
3821
+ const posts = activePlan ? await provider2.listPosts(activePlan.id) : [];
3253
3822
  return { plans, activePlan, posts };
3254
3823
  }
3255
3824
  return createStore((set, get) => ({
@@ -3269,7 +3838,7 @@ function createContentPlannerStore(provider) {
3269
3838
  if (get().loading) return;
3270
3839
  set({ loading: true });
3271
3840
  try {
3272
- const accounts = await provider.listAccounts();
3841
+ const accounts = await provider2.listAccounts();
3273
3842
  const current = get().activeAccountId;
3274
3843
  const activeAccountId = (current && accounts.some((a) => a.id === current) ? current : accounts[0]?.id) ?? null;
3275
3844
  const { plans, activePlan, posts } = activeAccountId ? await loadPlansFor(activeAccountId) : { plans: [], activePlan: null, posts: [] };
@@ -3300,19 +3869,19 @@ function createContentPlannerStore(provider) {
3300
3869
  },
3301
3870
  async selectPlan(planId) {
3302
3871
  const plan = get().plans.find((p) => p.id === planId) ?? null;
3303
- const posts = plan ? await provider.listPosts(plan.id) : [];
3872
+ const posts = plan ? await provider2.listPosts(plan.id) : [];
3304
3873
  set({ activePlan: plan, posts });
3305
3874
  },
3306
3875
  async saveAccount(input) {
3307
- const account = await provider.saveAccount(input);
3308
- const accounts = await provider.listAccounts();
3876
+ const account = await provider2.saveAccount(input);
3877
+ const accounts = await provider2.listAccounts();
3309
3878
  set({ accounts });
3310
3879
  if (!get().activeAccountId) await get().setAccount(account.id);
3311
3880
  return account;
3312
3881
  },
3313
3882
  async deleteAccount(id) {
3314
- await provider.deleteAccount(id);
3315
- const accounts = await provider.listAccounts();
3883
+ await provider2.deleteAccount(id);
3884
+ const accounts = await provider2.listAccounts();
3316
3885
  const wasActive = get().activeAccountId === id;
3317
3886
  set({ accounts });
3318
3887
  if (wasActive) {
@@ -3325,30 +3894,30 @@ function createContentPlannerStore(provider) {
3325
3894
  }
3326
3895
  },
3327
3896
  async savePlan(input) {
3328
- const plan = await provider.savePlan(input);
3897
+ const plan = await provider2.savePlan(input);
3329
3898
  const accountId = get().activeAccountId;
3330
3899
  if (accountId) {
3331
- const plans = await provider.listPlans(accountId);
3900
+ const plans = await provider2.listPlans(accountId);
3332
3901
  const activePlan = plans.find((p) => p.id === plan.id) ?? get().activePlan;
3333
- const posts = activePlan ? await provider.listPosts(activePlan.id) : [];
3902
+ const posts = activePlan ? await provider2.listPosts(activePlan.id) : [];
3334
3903
  set({ plans, activePlan, posts });
3335
3904
  }
3336
3905
  return plan;
3337
3906
  },
3338
3907
  async savePost(input) {
3339
- const post = await provider.savePost(input);
3908
+ const post = await provider2.savePost(input);
3340
3909
  const planId = get().activePlan?.id;
3341
3910
  if (planId && post.planId === planId) {
3342
- const posts = await provider.listPosts(planId);
3911
+ const posts = await provider2.listPosts(planId);
3343
3912
  set({ posts });
3344
3913
  }
3345
3914
  return post;
3346
3915
  },
3347
3916
  async deletePost(id) {
3348
- await provider.deletePost(id);
3917
+ await provider2.deletePost(id);
3349
3918
  const planId = get().activePlan?.id;
3350
3919
  if (planId) {
3351
- const posts = await provider.listPosts(planId);
3920
+ const posts = await provider2.listPosts(planId);
3352
3921
  set({ posts });
3353
3922
  }
3354
3923
  }
@@ -3519,6 +4088,454 @@ CREATE POLICY plg_marketing_media_insert ON storage.objects FOR INSERT TO authen
3519
4088
  CREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'mkt-media');
3520
4089
  CREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');
3521
4090
  `;
4091
+ var MIGRATION_004_RANKLAYER = `-- Marketing Plugin: RankLayer integration (SEO content publishing).
4092
+ -- Control-plane state for the RankLayer connector rendered in Marketing \u2192
4093
+ -- Integra\xE7\xF5es. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
4094
+ -- (edge function + RankLayer API calls) lands via an external PR \u2014 see
4095
+ -- src/integrations/ranklayer/RANKLAYER.md.
4096
+
4097
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
4098
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4099
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4100
+ api_key text,
4101
+ site_domain text,
4102
+ active boolean NOT NULL DEFAULT true,
4103
+ last_sync_at timestamptz,
4104
+ created_at timestamptz NOT NULL DEFAULT now(),
4105
+ updated_at timestamptz NOT NULL DEFAULT now()
4106
+ );
4107
+ ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
4108
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
4109
+ ON public.plg_marketing_ranklayer_integrations(tenant_id);
4110
+
4111
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
4112
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4113
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4114
+ integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
4115
+ status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
4116
+ message text,
4117
+ fetched integer NOT NULL DEFAULT 0,
4118
+ written integer NOT NULL DEFAULT 0,
4119
+ created_at timestamptz NOT NULL DEFAULT now()
4120
+ );
4121
+ ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
4122
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
4123
+ ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
4124
+
4125
+ -- RLS: authenticated tenant CRUD
4126
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
4127
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
4128
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
4129
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
4130
+ CREATE POLICY plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
4131
+ CREATE POLICY plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
4132
+ CREATE POLICY plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
4133
+ CREATE POLICY plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
4134
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
4135
+
4136
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
4137
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
4138
+ CREATE POLICY plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
4139
+ CREATE POLICY plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
4140
+ GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
4141
+ `;
4142
+ var MIGRATION_005_ANALYTICS_BASE = `-- Marketing Plugin: analytics base tables (channels + campaigns).
4143
+ -- Prefix: plg_marketing_
4144
+ -- Channels are tenant-owned rows lazily seeded from the app's domain preset
4145
+ -- (channel_key = preset/config id). Campaign performance is DERIVED at read
4146
+ -- time from v_marketing_attribution (006); only spend is stored.
4147
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
4148
+
4149
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_channels (
4150
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4151
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4152
+ channel_key text NOT NULL,
4153
+ label text NOT NULL,
4154
+ icon text,
4155
+ kind text NOT NULL DEFAULT 'organic'
4156
+ CHECK (kind IN ('paid', 'organic', 'social', 'referral', 'direct', 'outbound')),
4157
+ is_active boolean NOT NULL DEFAULT true,
4158
+ monthly_spend numeric NOT NULL DEFAULT 0,
4159
+ created_at timestamptz NOT NULL DEFAULT now(),
4160
+ updated_at timestamptz NOT NULL DEFAULT now()
4161
+ );
4162
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_channels_tenant ON public.plg_marketing_channels(tenant_id);
4163
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_channels_tenant_key ON public.plg_marketing_channels(tenant_id, channel_key);
4164
+
4165
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_campaigns (
4166
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4167
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4168
+ name text NOT NULL,
4169
+ channel_key text NOT NULL,
4170
+ status text NOT NULL DEFAULT 'draft'
4171
+ CHECK (status IN ('active', 'paused', 'ended', 'draft')),
4172
+ starts_at timestamptz,
4173
+ ends_at timestamptz,
4174
+ spend numeric NOT NULL DEFAULT 0,
4175
+ created_at timestamptz NOT NULL DEFAULT now(),
4176
+ updated_at timestamptz NOT NULL DEFAULT now()
4177
+ );
4178
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_tenant ON public.plg_marketing_campaigns(tenant_id);
4179
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_channel ON public.plg_marketing_campaigns(tenant_id, channel_key);
4180
+
4181
+ DO $$
4182
+ DECLARE t text;
4183
+ BEGIN
4184
+ FOR t IN SELECT unnest(ARRAY['plg_marketing_channels','plg_marketing_campaigns'])
4185
+ LOOP
4186
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
4187
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
4188
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
4189
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
4190
+ END IF;
4191
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
4192
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
4193
+ END IF;
4194
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
4195
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
4196
+ END IF;
4197
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
4198
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
4199
+ END IF;
4200
+ END LOOP;
4201
+ END $$;
4202
+ `;
4203
+ var MIGRATION_006_ANALYTICS_VIEWS = `-- Marketing Plugin: analytics read views.
4204
+ -- v_marketing_channels / v_marketing_campaigns \u2014 bridge views for the surface
4205
+ -- and the agent's data primitives.
4206
+ -- v_marketing_attribution \u2014 GENERIC, spine-only attribution event stream
4207
+ -- (people/appointments/orders metadata). \`channel_raw\` is the unnormalized
4208
+ -- origin string; the provider matches it against the app's channel set
4209
+ -- (id or label, case/punctuation-insensitive).
4210
+ -- Apps whose origin lives in an app-local extension table (e.g. beauty's
4211
+ -- clients.origin) may CREATE OR REPLACE this view in their own incubator
4212
+ -- migration, KEEPING the column contract:
4213
+ -- (tenant_id uuid, kind text, source text, channel_raw text,
4214
+ -- occurred_at timestamptz, value numeric)
4215
+
4216
+ CREATE OR REPLACE VIEW public.v_marketing_channels WITH (security_invoker=true) AS
4217
+ SELECT c.id, c.tenant_id, c.channel_key, c.label, c.icon, c.kind,
4218
+ c.is_active, c.monthly_spend, c.created_at, c.updated_at
4219
+ FROM public.plg_marketing_channels c;
4220
+ GRANT SELECT ON public.v_marketing_channels TO authenticated;
4221
+
4222
+ CREATE OR REPLACE VIEW public.v_marketing_campaigns WITH (security_invoker=true) AS
4223
+ SELECT g.id, g.tenant_id, g.name, g.channel_key, ch.label AS channel_label,
4224
+ g.status, g.starts_at, g.ends_at, g.spend, g.created_at, g.updated_at
4225
+ FROM public.plg_marketing_campaigns g
4226
+ LEFT JOIN public.plg_marketing_channels ch
4227
+ ON ch.tenant_id = g.tenant_id AND ch.channel_key = g.channel_key;
4228
+ GRANT SELECT ON public.v_marketing_campaigns TO authenticated;
4229
+
4230
+ CREATE OR REPLACE VIEW public.v_marketing_attribution WITH (security_invoker=true) AS
4231
+ -- lead events (CRM leads; sourceId aligns with lead-sources/channel ids)
4232
+ SELECT p.tenant_id,
4233
+ 'lead'::text AS kind,
4234
+ 'crm'::text AS source,
4235
+ COALESCE(p.metadata->>'sourceId', p.metadata->>'sourceName', p.metadata->>'origin') AS channel_raw,
4236
+ p.created_at AS occurred_at,
4237
+ NULLIF(p.metadata->>'value', '')::numeric AS value
4238
+ FROM public.people p
4239
+ WHERE p.kind = 'lead'
4240
+ UNION ALL
4241
+ -- booking conversions (agenda verticals)
4242
+ SELECT b.tenant_id, 'conversion', 'agenda',
4243
+ COALESCE(b.metadata->>'origin', pc.metadata->>'origin', pc.metadata->>'sourceId', pc.metadata->>'sourceName'),
4244
+ b.starts_at,
4245
+ o.total
4246
+ FROM public.appointments b
4247
+ LEFT JOIN public.people pc ON pc.id = b.party_id
4248
+ LEFT JOIN public.orders o ON o.id = b.order_id
4249
+ WHERE b.status NOT IN ('cancelled', 'no_show')
4250
+ UNION ALL
4251
+ -- order conversions (commerce verticals)
4252
+ SELECT o.tenant_id, 'conversion', 'orders',
4253
+ COALESCE(o.metadata->>'origin', pp.metadata->>'origin', pp.metadata->>'sourceId'),
4254
+ o.created_at,
4255
+ o.total
4256
+ FROM public.orders o
4257
+ LEFT JOIN public.people pp ON pp.id = o.party_id
4258
+ WHERE o.kind NOT IN ('appointment', 'deal')
4259
+ AND o.status NOT IN ('cancelled', 'draft')
4260
+ UNION ALL
4261
+ -- won-deal conversions (CRM verticals)
4262
+ SELECT o.tenant_id, 'conversion', 'crm',
4263
+ COALESCE(pp.metadata->>'sourceId', pp.metadata->>'sourceName', o.metadata->>'origin'),
4264
+ o.updated_at,
4265
+ o.total
4266
+ FROM public.orders o
4267
+ LEFT JOIN public.people pp ON pp.id = o.party_id
4268
+ WHERE o.kind = 'deal' AND o.status = 'won';
4269
+
4270
+ GRANT SELECT ON public.v_marketing_attribution TO authenticated;
4271
+ `;
4272
+ var MIGRATION_007_AGENT_RPCS = `-- ============================================================================
4273
+ -- plugin-marketing 007: server-plane agent write RPC.
4274
+ --
4275
+ -- public.agent_marketing_create_campaign \u2014 guarded campaign create for the
4276
+ -- assistant. Mirrors agent_agenda_create_appointment (agenda 005):
4277
+ -- * actor-authorized: public.agent_guard (spine 015) runs role\u2192plan\u2192limit
4278
+ -- BEFORE anything \u2014 denial comes back as structured jsonb;
4279
+ -- * channel is validated against the tenant's plg_marketing_channels rows
4280
+ -- (key or label, case/punctuation-insensitive); when the tenant has no
4281
+ -- channel rows yet (surface not opened \u2192 lazy seed not run) the raw key is
4282
+ -- accepted as-is;
4283
+ -- * audited: audit_logs row with the acting user.
4284
+ --
4285
+ -- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
4286
+ -- \u2192 jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
4287
+ -- error text}. GRANT authenticated+service_role \u2014 NEVER anon.
4288
+ -- ============================================================================
4289
+
4290
+ CREATE OR REPLACE FUNCTION public.agent_marketing_create_campaign(
4291
+ p_tenant_id uuid,
4292
+ p_actor_user_id uuid,
4293
+ p_payload jsonb
4294
+ ) RETURNS jsonb
4295
+ LANGUAGE plpgsql SECURITY DEFINER
4296
+ SET search_path = public
4297
+ AS $$
4298
+ DECLARE
4299
+ v_denial jsonb;
4300
+ v_used int;
4301
+ v_name text;
4302
+ v_channel_raw text;
4303
+ v_channel_key text;
4304
+ v_status text;
4305
+ v_starts timestamptz;
4306
+ v_ends timestamptz;
4307
+ v_spend numeric;
4308
+ v_has_channels boolean;
4309
+ v_id uuid;
4310
+ BEGIN
4311
+ -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4312
+ v_name := left(trim(p_payload->>'name'), 200);
4313
+ v_channel_raw := trim(p_payload->>'channel_key');
4314
+ v_status := COALESCE(NULLIF(trim(p_payload->>'status'), ''), 'draft');
4315
+ v_spend := COALESCE(NULLIF(p_payload->>'spend', '')::numeric, 0);
4316
+ BEGIN
4317
+ v_starts := NULLIF(p_payload->>'starts_at', '')::timestamptz;
4318
+ v_ends := NULLIF(p_payload->>'ends_at', '')::timestamptz;
4319
+ EXCEPTION WHEN OTHERS THEN
4320
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
4321
+ END;
4322
+ IF v_name IS NULL OR v_name = '' OR v_channel_raw IS NULL OR v_channel_raw = '' THEN
4323
+ RETURN jsonb_build_object('ok', false, 'error', 'name and channel_key are required');
4324
+ END IF;
4325
+ IF v_status NOT IN ('active', 'paused', 'ended', 'draft') THEN
4326
+ RETURN jsonb_build_object('ok', false, 'error', 'status must be one of active|paused|ended|draft');
4327
+ END IF;
4328
+
4329
+ -- \u2500\u2500 authorization: role \u2192 plan \u2192 campaigns_active cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4330
+ SELECT count(*) INTO v_used FROM plg_marketing_campaigns c
4331
+ WHERE c.tenant_id = p_tenant_id AND c.status = 'active';
4332
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
4333
+ 'marketing', 'create', 'campaigns_active', v_used,
4334
+ CASE WHEN v_status = 'active' THEN 1 ELSE 0 END);
4335
+ IF v_denial IS NOT NULL THEN
4336
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
4337
+ END IF;
4338
+
4339
+ -- \u2500\u2500 channel resolution (key or label, normalized) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4340
+ SELECT EXISTS (SELECT 1 FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id)
4341
+ INTO v_has_channels;
4342
+ IF v_has_channels THEN
4343
+ SELECT ch.channel_key INTO v_channel_key
4344
+ FROM plg_marketing_channels ch
4345
+ WHERE ch.tenant_id = p_tenant_id
4346
+ AND (
4347
+ regexp_replace(lower(ch.channel_key), '[^a-z0-9]', '', 'g')
4348
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
4349
+ OR regexp_replace(lower(ch.label), '[^a-z0-9]', '', 'g')
4350
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
4351
+ )
4352
+ LIMIT 1;
4353
+ IF v_channel_key IS NULL THEN
4354
+ RETURN jsonb_build_object('ok', false, 'error',
4355
+ 'unknown channel "' || v_channel_raw || '" \u2014 valid channels: ' ||
4356
+ COALESCE((SELECT string_agg(ch.channel_key, ', ' ORDER BY ch.channel_key)
4357
+ FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id), ''));
4358
+ END IF;
4359
+ ELSE
4360
+ v_channel_key := v_channel_raw;
4361
+ END IF;
4362
+
4363
+ -- \u2500\u2500 write + audit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4364
+ INSERT INTO plg_marketing_campaigns (tenant_id, name, channel_key, status, starts_at, ends_at, spend)
4365
+ VALUES (p_tenant_id, v_name, v_channel_key, v_status, COALESCE(v_starts, now()), v_ends, v_spend)
4366
+ RETURNING id INTO v_id;
4367
+
4368
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
4369
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createCampaign', 'marketing_campaign', v_id::text,
4370
+ jsonb_build_object('payload', p_payload));
4371
+
4372
+ RETURN jsonb_build_object(
4373
+ 'ok', true,
4374
+ 'id', v_id,
4375
+ 'record', jsonb_build_object(
4376
+ 'id', v_id,
4377
+ 'name', v_name,
4378
+ 'channel_key', v_channel_key,
4379
+ 'status', v_status,
4380
+ 'starts_at', COALESCE(v_starts, now()),
4381
+ 'spend', v_spend
4382
+ )
4383
+ );
4384
+ END;
4385
+ $$;
4386
+
4387
+ REVOKE ALL ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM public;
4388
+ REVOKE EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM anon;
4389
+ GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb)
4390
+ TO authenticated, service_role;
4391
+ `;
4392
+ var INTEGRATIONS = "plg_marketing_ranklayer_integrations";
4393
+ var SYNC_LOG = "plg_marketing_ranklayer_sync_log";
4394
+ function getClient3() {
4395
+ const supabase = getSupabaseClientOptional();
4396
+ if (!supabase) throw new Error("[ranklayer] Supabase not initialized");
4397
+ return supabase;
4398
+ }
4399
+ function requireTenantId3() {
4400
+ const tenantId = getActiveTenantId();
4401
+ if (!tenantId) throw new Error("[ranklayer] No active tenant");
4402
+ return tenantId;
4403
+ }
4404
+ function toIntegration(row) {
4405
+ return {
4406
+ id: row.id,
4407
+ apiKey: row.api_key ?? void 0,
4408
+ siteDomain: row.site_domain ?? void 0,
4409
+ active: row.active ?? true,
4410
+ lastSyncAt: row.last_sync_at ?? void 0
4411
+ };
4412
+ }
4413
+ function createRankLayerProvider() {
4414
+ return {
4415
+ async getIntegration() {
4416
+ const tenantId = getActiveTenantId();
4417
+ if (!tenantId) return null;
4418
+ const { data } = await getClient3().from(INTEGRATIONS).select("*").eq("tenant_id", tenantId).maybeSingle();
4419
+ return data ? toIntegration(data) : null;
4420
+ },
4421
+ async saveIntegration(values) {
4422
+ const tenantId = requireTenantId3();
4423
+ const payload = {
4424
+ tenant_id: tenantId,
4425
+ api_key: values.apiKey,
4426
+ site_domain: values.siteDomain ?? null,
4427
+ active: true,
4428
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
4429
+ };
4430
+ const { data, error } = await getClient3().from(INTEGRATIONS).upsert(payload, { onConflict: "tenant_id" }).select("*").single();
4431
+ if (error) throw error;
4432
+ return toIntegration(data);
4433
+ },
4434
+ async disconnect() {
4435
+ const tenantId = getActiveTenantId();
4436
+ if (!tenantId) return;
4437
+ await getClient3().from(INTEGRATIONS).delete().eq("tenant_id", tenantId);
4438
+ },
4439
+ // SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
4440
+ // flow can be exercised. Replace with a real credential check in the PR.
4441
+ async testConnection(values) {
4442
+ if (!values.apiKey || values.apiKey.trim().length < 8) {
4443
+ return { ok: false, message: "Informe uma API Key v\xE1lida da RankLayer." };
4444
+ }
4445
+ return { ok: true, message: "Chave aceita (valida\xE7\xE3o real pendente \u2014 integra\xE7\xE3o em prepara\xE7\xE3o)." };
4446
+ },
4447
+ async getSyncLog(integrationId) {
4448
+ const { data } = await getClient3().from(SYNC_LOG).select("*").eq("integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
4449
+ return (data ?? []).map((r) => ({
4450
+ id: r.id,
4451
+ createdAt: r.created_at,
4452
+ status: r.status,
4453
+ message: r.message ?? void 0,
4454
+ fetched: r.fetched ?? 0,
4455
+ written: r.written ?? 0
4456
+ }));
4457
+ }
4458
+ };
4459
+ }
4460
+ var provider = createRankLayerProvider();
4461
+ function RankLayerExtraPanel() {
4462
+ const [integration, setIntegration] = useState(null);
4463
+ const [log, setLog] = useState([]);
4464
+ const [loaded, setLoaded] = useState(false);
4465
+ useEffect(() => {
4466
+ let cancelled = false;
4467
+ (async () => {
4468
+ try {
4469
+ const found = await provider.getIntegration();
4470
+ if (cancelled) return;
4471
+ setIntegration(found);
4472
+ if (found) setLog(await provider.getSyncLog(found.id));
4473
+ } catch {
4474
+ } finally {
4475
+ if (!cancelled) setLoaded(true);
4476
+ }
4477
+ })();
4478
+ return () => {
4479
+ cancelled = true;
4480
+ };
4481
+ }, []);
4482
+ if (!loaded || !integration) return null;
4483
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4 border-t pt-4", children: [
4484
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3 rounded-md border border-dashed p-3", children: [
4485
+ /* @__PURE__ */ jsx(Sparkles, { className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" }),
4486
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
4487
+ /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Sincroniza\xE7\xE3o de conte\xFAdo \u2014 em breve" }),
4488
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
4489
+ "A publica\xE7\xE3o autom\xE1tica de p\xE1ginas e artigos de SEO com a RankLayer est\xE1 em prepara\xE7\xE3o.",
4490
+ integration.siteDomain ? ` Dom\xEDnio configurado: ${integration.siteDomain}.` : "",
4491
+ " Assim que a integra\xE7\xE3o estiver ativa, o hist\xF3rico de sincroniza\xE7\xF5es aparece aqui."
4492
+ ] })
4493
+ ] })
4494
+ ] }),
4495
+ log.length > 0 && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
4496
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
4497
+ /* @__PURE__ */ jsx(History, { className: "h-4 w-4 text-muted-foreground" }),
4498
+ /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Hist\xF3rico" })
4499
+ ] }),
4500
+ /* @__PURE__ */ jsx("div", { className: "divide-y rounded-md border text-sm", children: log.map((r) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [
4501
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: new Date(r.createdAt).toLocaleString("pt-BR") }),
4502
+ /* @__PURE__ */ jsxs("span", { className: "ml-auto text-xs", children: [
4503
+ r.written,
4504
+ "/",
4505
+ r.fetched,
4506
+ " publicadas"
4507
+ ] }),
4508
+ r.status === "success" ? /* @__PURE__ */ jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsx(AlertCircle, { className: "h-3.5 w-3.5 text-warning" })
4509
+ ] }, r.id)) })
4510
+ ] })
4511
+ ] });
4512
+ }
4513
+ var ranklayerConnectorDef = {
4514
+ id: "ranklayer",
4515
+ hostPluginId: "marketing",
4516
+ name: "RankLayer",
4517
+ description: "Publica\xE7\xE3o de conte\xFAdo e p\xE1ginas de SEO por servi\xE7o/bairro para ser encontrado no Google e em assistentes de IA.",
4518
+ icon: "Search",
4519
+ authKind: "api-key",
4520
+ fields: [
4521
+ { key: "apiKey", label: "API Key", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" },
4522
+ { key: "siteDomain", label: "Dom\xEDnio do site", type: "text", placeholder: "exemplo.com.br" }
4523
+ ],
4524
+ async getStatus() {
4525
+ try {
4526
+ const found = await provider.getIntegration();
4527
+ return { connected: !!found?.apiKey, detail: found?.siteDomain };
4528
+ } catch {
4529
+ return { connected: false };
4530
+ }
4531
+ },
4532
+ testConnection: (values) => provider.testConnection({ apiKey: values.apiKey }),
4533
+ saveConnection: async (values) => {
4534
+ await provider.saveIntegration({ apiKey: values.apiKey, siteDomain: values.siteDomain });
4535
+ },
4536
+ disconnect: () => provider.disconnect(),
4537
+ ExtraPanel: RankLayerExtraPanel
4538
+ };
3522
4539
 
3523
4540
  // src/locales/en.ts
3524
4541
  var en = {
@@ -3878,6 +4895,8 @@ var DEFAULT_LABELS = {
3878
4895
  funnel: "Funnel",
3879
4896
  landingPages: "Landing pages",
3880
4897
  content: "Content",
4898
+ blog: "Blog",
4899
+ blogCategories: "Categorias",
3881
4900
  settings: "Settings"
3882
4901
  };
3883
4902
  function resolveConfig(options) {
@@ -3892,16 +4911,23 @@ function resolveConfig(options) {
3892
4911
  };
3893
4912
  return { config, domain };
3894
4913
  }
3895
- function createSafeProvider(config, domain) {
3896
- void getSupabaseClientOptional();
3897
- return createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain });
4914
+ function createSafeProvider(config, domain, options) {
4915
+ return createSafeDataProvider(
4916
+ () => createSupabaseMarketingProvider({
4917
+ channels: config.channels,
4918
+ conversion: config.conversion,
4919
+ attributionBridge: options?.attributionBridge,
4920
+ sitesBridge: options?.sitesBridge
4921
+ }),
4922
+ () => createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain })
4923
+ );
3898
4924
  }
3899
4925
  function createMarketingPlugin(options) {
3900
4926
  registerTranslations(marketingLocales);
3901
4927
  const { config, domain } = resolveConfig(options);
3902
- const provider = options?.dataProvider ?? createSafeProvider(config, domain);
3903
- const store = createMarketingStore(provider);
3904
- const dashboardWidgets = createMarketingDashboardWidgets({ config, provider, store });
4928
+ const provider2 = options?.dataProvider ?? createSafeProvider(config, domain, options);
4929
+ const store = createMarketingStore(provider2);
4930
+ const dashboardWidgets = createMarketingDashboardWidgets({ config, provider: provider2, store });
3905
4931
  const contentProvider = options?.contentPlanner?.dataProvider ?? createSafeDataProvider(
3906
4932
  () => createSupabaseContentPlannerProvider(),
3907
4933
  () => createMockContentPlannerProvider()
@@ -3909,8 +4935,8 @@ function createMarketingPlugin(options) {
3909
4935
  const contentStore = createContentPlannerStore(contentProvider);
3910
4936
  const PageComponent = () => React8.createElement(
3911
4937
  MarketingContextProvider,
3912
- { config, provider, store },
3913
- React8.createElement(MarketingPage, { config, provider, store, contentProvider, contentStore })
4938
+ { config, provider: provider2, store },
4939
+ React8.createElement(MarketingPage, { config, provider: provider2, store, contentProvider, contentStore })
3914
4940
  );
3915
4941
  PageComponent.displayName = "MarketingPage";
3916
4942
  const marketingRegistries = [
@@ -3919,12 +4945,14 @@ function createMarketingPlugin(options) {
3919
4945
  ];
3920
4946
  const SettingsComponent = () => React8.createElement(
3921
4947
  MarketingContextProvider,
3922
- { config, provider, store },
4948
+ { config, provider: provider2, store },
3923
4949
  React8.createElement(PluginSettingsPanel, {
3924
4950
  title: config.labels.settings ?? config.labels.pageTitle,
3925
4951
  subtitle: config.labels.pageSubtitle,
3926
4952
  generalSettings: React8.createElement(SettingsView),
3927
4953
  registries: marketingRegistries,
4954
+ // Enables the "Integrações" tab (renders manifest connectors for this host).
4955
+ hostPluginId: "marketing",
3928
4956
  routeBase: "/settings/marketing"
3929
4957
  })
3930
4958
  );
@@ -3944,7 +4972,96 @@ function createMarketingPlugin(options) {
3944
4972
  declaredFeatures: [
3945
4973
  { id: "marketing", label: config.labels.pageTitle, group: "Engage" },
3946
4974
  ...config.modules.landingPages ? [{ id: "marketing.landing-pages", label: config.labels.landingPages, group: "Engage" }] : [],
3947
- ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : []
4975
+ ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : [],
4976
+ ...config.modules.blog ? [{ id: "marketing.blog", label: config.labels.blog, group: "Engage" }] : []
4977
+ ],
4978
+ declaredLimits: [
4979
+ ...config.modules.campaigns ? [{ key: "campaigns_active", label: "Active campaigns", table: T.campaigns }] : [],
4980
+ ...config.modules.contentPlanner ? [{ key: "content_posts_month", label: "Content posts / month", table: "plg_marketing_content_posts", period: "month" }] : [],
4981
+ ...config.modules.blog ? [{ key: "blog_posts", label: "Blog posts", table: "plg_blog_posts" }] : []
4982
+ ],
4983
+ // Read-models for the agent's data primitives (searchRecords/queryData) —
4984
+ // mirrors agenda's `agenda:appointments`. Keys are stable ASCII; fields are
4985
+ // the provider's camelCase output shape.
4986
+ queryEntities: [
4987
+ ...config.modules.campaigns ? [{
4988
+ key: "marketing:campaigns",
4989
+ writable: false,
4990
+ entity: {
4991
+ name: "Campaign",
4992
+ namePlural: "Campaigns",
4993
+ icon: "Megaphone",
4994
+ permission: { feature: "marketing", action: "read" },
4995
+ fields: [
4996
+ { key: "name", label: "Name", type: "text", searchable: true },
4997
+ { key: "channelKey", label: "Channel", type: "text", searchable: true },
4998
+ { key: "channelLabel", label: "Channel label", type: "text" },
4999
+ { key: "status", label: "Status", type: "text" },
5000
+ { key: "startsAt", label: "Starts at", type: "text" },
5001
+ { key: "endsAt", label: "Ends at", type: "text" },
5002
+ { key: "spend", label: "Spend", type: "number" },
5003
+ { key: "createdAt", label: "Created at", type: "text" }
5004
+ ],
5005
+ data: {
5006
+ table: "v_marketing_campaigns",
5007
+ tenantScoped: true,
5008
+ searchColumns: ["name", "channel_key"]
5009
+ }
5010
+ }
5011
+ }] : [],
5012
+ ...config.modules.channels ? [{
5013
+ key: "marketing:channels",
5014
+ writable: false,
5015
+ entity: {
5016
+ name: "Marketing channel",
5017
+ namePlural: "Marketing channels",
5018
+ icon: "Radio",
5019
+ permission: { feature: "marketing", action: "read" },
5020
+ fields: [
5021
+ { key: "channelKey", label: "Key", type: "text", searchable: true },
5022
+ { key: "label", label: "Channel", type: "text", searchable: true },
5023
+ { key: "kind", label: "Type", type: "text" },
5024
+ { key: "isActive", label: "Active", type: "boolean" },
5025
+ { key: "monthlySpend", label: "Monthly spend", type: "number" }
5026
+ ],
5027
+ data: {
5028
+ table: "v_marketing_channels",
5029
+ tenantScoped: true,
5030
+ searchColumns: ["label", "channel_key"]
5031
+ }
5032
+ }
5033
+ }] : [],
5034
+ ...config.modules.contentPlanner ? [{
5035
+ key: "marketing:contentPosts",
5036
+ writable: false,
5037
+ entity: {
5038
+ name: "Content post",
5039
+ namePlural: "Content posts",
5040
+ icon: "FileText",
5041
+ permission: { feature: "marketing.content", action: "read" },
5042
+ fields: [
5043
+ { key: "title", label: "Title", type: "text", searchable: true },
5044
+ { key: "format", label: "Format", type: "text" },
5045
+ { key: "status", label: "Status", type: "text" },
5046
+ { key: "weekNumber", label: "Week", type: "number" },
5047
+ { key: "scheduledDate", label: "Scheduled date", type: "text" },
5048
+ { key: "hook", label: "Hook", type: "text", searchable: true }
5049
+ ],
5050
+ data: {
5051
+ table: T.contentPosts,
5052
+ tenantScoped: true,
5053
+ searchColumns: ["title", "hook"]
5054
+ }
5055
+ }
5056
+ }] : []
5057
+ ],
5058
+ declaredRpcs: [
5059
+ ...config.modules.campaigns ? [{
5060
+ name: "agent_marketing_create_campaign",
5061
+ kind: "write",
5062
+ description: "Guarded campaign create: agent_guard (role\u2192plan\u2192campaigns_active cap), channel validated against the tenant channel set, audited.",
5063
+ audits: true
5064
+ }] : []
3948
5065
  ],
3949
5066
  navigation: [
3950
5067
  {
@@ -3998,8 +5115,35 @@ function createMarketingPlugin(options) {
3998
5115
  version: "1.2.0",
3999
5116
  sql: MIGRATION_003_RECORDING_OPS,
4000
5117
  description: "Recording-day checklist + media asset URL on posts; content-media storage bucket"
5118
+ },
5119
+ {
5120
+ id: "marketing-004-ranklayer",
5121
+ version: "1.3.0",
5122
+ sql: MIGRATION_004_RANKLAYER,
5123
+ description: "RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)"
5124
+ },
5125
+ {
5126
+ id: "marketing-005-analytics-base",
5127
+ version: "1.4.0",
5128
+ sql: MIGRATION_005_ANALYTICS_BASE,
5129
+ description: "Analytics base: plg_marketing_channels + plg_marketing_campaigns with canonical tenant RLS"
5130
+ },
5131
+ {
5132
+ id: "marketing-006-analytics-views",
5133
+ version: "1.4.0",
5134
+ sql: MIGRATION_006_ANALYTICS_VIEWS,
5135
+ description: "Read views v_marketing_channels/_campaigns + generic spine-only v_marketing_attribution"
5136
+ },
5137
+ {
5138
+ id: "marketing-007-agent-rpcs",
5139
+ version: "1.4.0",
5140
+ sql: MIGRATION_007_AGENT_RPCS,
5141
+ description: "agent_marketing_create_campaign \u2014 guarded, audited server-plane campaign create"
4001
5142
  }
4002
5143
  ],
5144
+ // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
5145
+ // (control plane only); the real sync lands via an external PR (RANKLAYER.md).
5146
+ connectors: [ranklayerConnectorDef],
4003
5147
  aiTools: [
4004
5148
  {
4005
5149
  id: "marketing.channel-performance",
@@ -4040,18 +5184,25 @@ function createMarketingPlugin(options) {
4040
5184
  {
4041
5185
  id: "marketing.create-campaign",
4042
5186
  name: "createCampaign",
4043
- description: "Creates an acquisition campaign on a channel.",
5187
+ description: "Creates an acquisition campaign on a channel. Resolve the channel first via queryData/searchRecords on marketing channels (use its channel key). The user confirms before creating.",
4044
5188
  icon: "Plus",
4045
5189
  mode: "persist",
5190
+ limitKey: "campaigns_active",
5191
+ execution: { plane: "server", kind: "rpc", rpc: "agent_marketing_create_campaign" },
4046
5192
  category: "Marketing",
4047
5193
  parameters: {
4048
5194
  type: "object",
4049
5195
  properties: {
4050
5196
  name: { type: "string", description: "Campaign name" },
4051
- channelId: { type: "string", description: "Acquisition channel id" }
5197
+ channel_key: { type: "string", description: "Acquisition channel key (e.g. 'instagram') or its label" },
5198
+ status: { type: "string", enum: ["draft", "active", "paused"], description: "Defaults to draft" },
5199
+ starts_at: { type: "string", description: "ISO start date-time (defaults to now)" },
5200
+ ends_at: { type: "string", description: "ISO end date-time (optional)" },
5201
+ spend: { type: "number", description: "Planned/committed spend (optional)" }
4052
5202
  },
4053
- required: ["name", "channelId"]
5203
+ required: ["name", "channel_key"]
4054
5204
  },
5205
+ suggestions: [{ label: "Create an Instagram campaign for this month" }],
4055
5206
  permission: { feature: "marketing", action: "create" }
4056
5207
  }
4057
5208
  ],
@@ -4070,6 +5221,6 @@ function createMarketingPlugin(options) {
4070
5221
  };
4071
5222
  }
4072
5223
 
4073
- export { MARKETING_PRESETS, createMarketingPlugin, createMockContentPlannerProvider, createMockMarketingProvider, createSupabaseContentPlannerProvider };
5224
+ export { MARKETING_PRESETS, createMarketingPlugin, createMockContentPlannerProvider, createMockMarketingProvider, createSupabaseContentPlannerProvider, createSupabaseMarketingProvider };
4074
5225
  //# sourceMappingURL=index.js.map
4075
5226
  //# sourceMappingURL=index.js.map