@fayz-ai/plugin-marketing 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) 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/index.cjs +509 -43
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +515 -49
  10. package/dist/index.js.map +1 -1
  11. package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
  12. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
  13. package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
  14. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
  15. package/dist/integrations/ranklayer/types.d.ts +16 -0
  16. package/dist/integrations/ranklayer/types.d.ts.map +1 -0
  17. package/dist/migrations/index.d.ts +1 -0
  18. package/dist/migrations/index.d.ts.map +1 -1
  19. package/dist/presets.d.ts +2 -0
  20. package/dist/presets.d.ts.map +1 -1
  21. package/dist/views/blog/BlogAdminView.d.ts +5 -0
  22. package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
  23. package/dist/views/blog/BlogEntityForm.d.ts +11 -0
  24. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
  25. package/dist/views/blog/useEntityCrud.d.ts +29 -0
  26. package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
  27. package/package.json +4 -4
  28. package/src/MarketingContext.tsx +2 -0
  29. package/src/MarketingPage.tsx +22 -1
  30. package/src/data/blogEntities.tsx +91 -0
  31. package/src/index.ts +16 -1
  32. package/src/integrations/ranklayer/RANKLAYER.md +40 -0
  33. package/src/integrations/ranklayer/connectorDef.tsx +98 -0
  34. package/src/integrations/ranklayer/data/supabase.ts +106 -0
  35. package/src/integrations/ranklayer/types.ts +16 -0
  36. package/src/migrations/004_ranklayer.sql +50 -0
  37. package/src/migrations/index.ts +54 -1
  38. package/src/presets.ts +5 -3
  39. package/src/views/blog/BlogAdminView.tsx +99 -0
  40. package/src/views/blog/BlogEntityForm.tsx +92 -0
  41. package/src/views/blog/useEntityCrud.ts +50 -0
package/dist/index.cjs CHANGED
@@ -219,11 +219,11 @@ function ChannelDetailView({ channelId, onBack }) {
219
219
  const range = useMarketingStore((s) => s.range);
220
220
  const channels = useMarketingStore((s) => s.channels);
221
221
  const lookup = useChannelLookup();
222
- const provider = useMarketingProvider();
222
+ const provider2 = useMarketingProvider();
223
223
  const [campaigns, setCampaigns] = React8__default.default.useState([]);
224
224
  React8__default.default.useEffect(() => {
225
- void provider.listCampaigns({ range, channelId }).then(setCampaigns);
226
- }, [provider, range, channelId]);
225
+ void provider2.listCampaigns({ range, channelId }).then(setCampaigns);
226
+ }, [provider2, range, channelId]);
227
227
  const perf = channels.find((c) => c.channelId === channelId);
228
228
  const channel = lookup.get(channelId);
229
229
  const columns = React8__default.default.useMemo(() => [
@@ -1968,7 +1968,7 @@ function toDraft2(post) {
1968
1968
  }
1969
1969
  function PostPage({ postId, onBack }) {
1970
1970
  const t = core.useTranslation();
1971
- const provider = useContentPlannerProvider();
1971
+ const provider2 = useContentPlannerProvider();
1972
1972
  const storePost = useContentPlannerStore((s) => s.posts.find((p) => p.id === postId));
1973
1973
  const savePost = useContentPlannerStore((s) => s.savePost);
1974
1974
  const deletePost = useContentPlannerStore((s) => s.deletePost);
@@ -1985,13 +1985,13 @@ function PostPage({ postId, onBack }) {
1985
1985
  setDraft(toDraft2(storePost));
1986
1986
  return;
1987
1987
  }
1988
- void provider.getPost(postId).then((fetched) => {
1988
+ void provider2.getPost(postId).then((fetched) => {
1989
1989
  if (fetched) {
1990
1990
  setPost(fetched);
1991
1991
  setDraft(toDraft2(fetched));
1992
1992
  }
1993
1993
  });
1994
- }, [postId, storePost, post?.id, provider]);
1994
+ }, [postId, storePost, post?.id, provider2]);
1995
1995
  if (!post || !draft) {
1996
1996
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mx-auto max-w-3xl space-y-5", children: [
1997
1997
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between", children: [
@@ -2178,6 +2178,239 @@ function PostPage({ postId, onBack }) {
2178
2178
  post.id
2179
2179
  );
2180
2180
  }
2181
+
2182
+ // src/data/blogEntities.tsx
2183
+ var BLOG_POSTS_TABLE = "plg_blog_posts";
2184
+ var BLOG_CATEGORIES_TABLE = "plg_blog_categories";
2185
+ function buildBlogPostEntity() {
2186
+ const fields = [
2187
+ { key: "title", label: "Title", type: "text", required: true, searchable: true, showInTable: true, sortable: true },
2188
+ { key: "slug", label: "Slug", type: "text", required: true, searchable: true, showInTable: false, hint: 'URL identifier, e.g. "cannabis-and-oral-health"' },
2189
+ { key: "status", label: "Status", type: "select", options: ["draft", "published"], showInTable: true, defaultValue: "draft" },
2190
+ { key: "categoryId", label: "Category", type: "relation", relation: { table: BLOG_CATEGORIES_TABLE, valueField: "id", labelField: "name", tenantScoped: true }, showInTable: false },
2191
+ { key: "excerpt", label: "Excerpt", type: "textarea", span: 2, showInTable: false, placeholder: "Short summary shown on cards" },
2192
+ { key: "body", label: "Body", type: "markdown", span: 2, showInTable: false },
2193
+ { key: "coverImage", label: "Cover image", type: "image", showInTable: false, placeholder: "https://\u2026" },
2194
+ { key: "readTime", label: "Read time", type: "text", showInTable: false, placeholder: "5 min" },
2195
+ { key: "publishedAt", label: "Published at", type: "date", showInTable: true, sortable: true },
2196
+ { key: "authorName", label: "Author name", type: "text", showInTable: false },
2197
+ { key: "authorRole", label: "Author role", type: "text", showInTable: false, placeholder: "e.g. Cirurgi\xE3o-Dentista" },
2198
+ { key: "authorAvatarUrl", label: "Author avatar", type: "image", showInTable: false, placeholder: "https://\u2026" },
2199
+ { key: "authorBio", label: "Author bio", type: "textarea", span: 2, showInTable: false }
2200
+ ];
2201
+ return {
2202
+ name: "Post",
2203
+ namePlural: "Posts",
2204
+ icon: "Newspaper",
2205
+ fields,
2206
+ displayField: "title",
2207
+ subtitleField: "slug",
2208
+ facets: [{ field: "status", allLabel: "All" }],
2209
+ data: {
2210
+ table: BLOG_POSTS_TABLE,
2211
+ tenantScoped: true,
2212
+ searchColumns: ["title", "slug", "excerpt"]
2213
+ }
2214
+ };
2215
+ }
2216
+ function buildBlogCategoryEntity() {
2217
+ const fields = [
2218
+ { key: "name", label: "Name", type: "text", required: true, searchable: true, showInTable: true, sortable: true },
2219
+ { key: "slug", label: "Slug", type: "text", required: true, searchable: true, showInTable: true, hint: "URL identifier" },
2220
+ { key: "description", label: "Description", type: "textarea", span: 2, showInTable: false }
2221
+ ];
2222
+ return {
2223
+ name: "Category",
2224
+ namePlural: "Categories",
2225
+ icon: "Tags",
2226
+ fields,
2227
+ displayField: "name",
2228
+ data: {
2229
+ table: BLOG_CATEGORIES_TABLE,
2230
+ tenantScoped: true,
2231
+ searchColumns: ["name", "slug"]
2232
+ }
2233
+ };
2234
+ }
2235
+ function useEntityCrud(entity) {
2236
+ const provider2 = React8.useMemo(() => core.resolveDataProvider(entity), [entity]);
2237
+ const [items, setItems] = React8.useState(null);
2238
+ const [total, setTotal] = React8.useState(0);
2239
+ const fetch = React8.useCallback(async (query = {}) => {
2240
+ try {
2241
+ const res = await provider2.list(query);
2242
+ setItems(res.data);
2243
+ setTotal(res.total);
2244
+ } catch (err) {
2245
+ console.warn("[blog] list failed; showing empty. Apply the plugin-blog migration to this pool.", err);
2246
+ setItems([]);
2247
+ setTotal(0);
2248
+ }
2249
+ }, [provider2]);
2250
+ const getById = React8.useCallback(async (id) => {
2251
+ const res = await provider2.list({ filters: { id }, page: 1, pageSize: 1 });
2252
+ return res.data[0] ?? null;
2253
+ }, [provider2]);
2254
+ const create = React8.useCallback((data) => provider2.create(data), [provider2]);
2255
+ const update = React8.useCallback((id, data) => provider2.update(id, data), [provider2]);
2256
+ const remove = React8.useCallback((id) => provider2.remove(id), [provider2]);
2257
+ return { items, total, fetch, getById, create, update, remove };
2258
+ }
2259
+ function PostsList({ navigate }) {
2260
+ const entity = React8.useMemo(() => buildBlogPostEntity(), []);
2261
+ const { items, total, fetch } = useEntityCrud(entity);
2262
+ const [search, setSearch] = React8.useState("");
2263
+ const [statusFilter, setStatusFilter] = React8.useState();
2264
+ const [loadedOnce, setLoadedOnce] = React8.useState(false);
2265
+ React8.useEffect(() => {
2266
+ let active = true;
2267
+ Promise.resolve(
2268
+ fetch({ search: search || void 0, filters: statusFilter ? { status: statusFilter } : void 0 })
2269
+ ).finally(() => {
2270
+ if (active) setLoadedOnce(true);
2271
+ });
2272
+ return () => {
2273
+ active = false;
2274
+ };
2275
+ }, [search, statusFilter, fetch]);
2276
+ const facets = React8.useMemo(() => [{
2277
+ field: "status",
2278
+ allLabel: "All",
2279
+ options: [{ value: "draft", label: "Draft" }, { value: "published", label: "Published" }]
2280
+ }], []);
2281
+ return /* @__PURE__ */ jsxRuntime.jsx(
2282
+ saas.CrudListView,
2283
+ {
2284
+ entityDef: entity,
2285
+ items: loadedOnce ? items : null,
2286
+ total,
2287
+ search,
2288
+ onSearchChange: setSearch,
2289
+ searchPlaceholder: "Search posts\u2026",
2290
+ facets,
2291
+ activeFilters: { status: statusFilter },
2292
+ onFacetChange: (_field, value) => setStatusFilter(value),
2293
+ onNew: () => navigate("blog-new"),
2294
+ addLabel: "New post",
2295
+ onRowClick: (row) => navigate(`blog-post:${row.id}`)
2296
+ }
2297
+ );
2298
+ }
2299
+ function CategoriesList({ navigate }) {
2300
+ const entity = React8.useMemo(() => buildBlogCategoryEntity(), []);
2301
+ const { items, total, fetch } = useEntityCrud(entity);
2302
+ const [search, setSearch] = React8.useState("");
2303
+ const [loadedOnce, setLoadedOnce] = React8.useState(false);
2304
+ React8.useEffect(() => {
2305
+ let active = true;
2306
+ Promise.resolve(fetch({ search: search || void 0 })).finally(() => {
2307
+ if (active) setLoadedOnce(true);
2308
+ });
2309
+ return () => {
2310
+ active = false;
2311
+ };
2312
+ }, [search, fetch]);
2313
+ return /* @__PURE__ */ jsxRuntime.jsx(
2314
+ saas.CrudListView,
2315
+ {
2316
+ entityDef: entity,
2317
+ items: loadedOnce ? items : null,
2318
+ total,
2319
+ search,
2320
+ onSearchChange: setSearch,
2321
+ searchPlaceholder: "Search categories\u2026",
2322
+ onNew: () => navigate("blog-cat-new"),
2323
+ addLabel: "New category",
2324
+ onRowClick: (row) => navigate(`blog-cat:${row.id}`)
2325
+ }
2326
+ );
2327
+ }
2328
+ function BlogAdminView({ navigate }) {
2329
+ const { labels } = useMarketingConfig();
2330
+ const [lens, setLens] = React8.useState("posts");
2331
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4", children: [
2332
+ /* @__PURE__ */ jsxRuntime.jsx(
2333
+ ui.SegmentedControl,
2334
+ {
2335
+ "aria-label": "Blog section",
2336
+ options: [
2337
+ { value: "posts", label: labels.blog },
2338
+ { value: "categories", label: labels.blogCategories }
2339
+ ],
2340
+ value: lens,
2341
+ onChange: (v) => setLens(v)
2342
+ }
2343
+ ),
2344
+ lens === "posts" ? /* @__PURE__ */ jsxRuntime.jsx(PostsList, { navigate }) : /* @__PURE__ */ jsxRuntime.jsx(CategoriesList, { navigate })
2345
+ ] });
2346
+ }
2347
+ function BlogEntityForm({
2348
+ entity,
2349
+ editId,
2350
+ parentLabel,
2351
+ onDone
2352
+ }) {
2353
+ const { getById, create, update, remove } = useEntityCrud(entity);
2354
+ const isEdit = !!editId;
2355
+ const namePlural = entity.namePlural ?? entity.name + "s";
2356
+ const [initialData, setInitialData] = React8__default.default.useState(isEdit ? null : {});
2357
+ React8__default.default.useEffect(() => {
2358
+ if (!editId) return;
2359
+ let cancelled = false;
2360
+ (async () => {
2361
+ const item = await getById(editId);
2362
+ if (!cancelled) setInitialData(item ?? {});
2363
+ })();
2364
+ return () => {
2365
+ cancelled = true;
2366
+ };
2367
+ }, [editId, getById]);
2368
+ async function save(values) {
2369
+ try {
2370
+ if (isEdit) await update(editId, values);
2371
+ else await create(values);
2372
+ ui.toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`);
2373
+ onDone();
2374
+ } catch (err) {
2375
+ ui.toast.error(`Failed to save ${entity.name.toLowerCase()}`);
2376
+ throw err;
2377
+ }
2378
+ }
2379
+ async function del() {
2380
+ if (!editId) return;
2381
+ try {
2382
+ await remove(editId);
2383
+ ui.toast.success(`${entity.name} deleted`);
2384
+ onDone();
2385
+ } catch {
2386
+ ui.toast.error(`Failed to delete ${entity.name.toLowerCase()}`);
2387
+ }
2388
+ }
2389
+ const title = isEdit ? initialData?.[entity.displayField ?? "name"] ?? entity.name : `New ${entity.name.toLowerCase()}`;
2390
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4", children: [
2391
+ /* @__PURE__ */ jsxRuntime.jsx(
2392
+ ui.SubpageHeader,
2393
+ {
2394
+ title: String(title),
2395
+ onBack: onDone,
2396
+ parentLabel,
2397
+ actions: isEdit ? /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "ghost", className: "text-destructive hover:text-destructive", onClick: del, children: "Delete" }) : void 0
2398
+ }
2399
+ ),
2400
+ initialData !== null && /* @__PURE__ */ jsxRuntime.jsx(
2401
+ saas.CrudFormPage,
2402
+ {
2403
+ entityDef: entity,
2404
+ mode: isEdit ? "edit" : "create",
2405
+ initialData,
2406
+ namePlural,
2407
+ onCancel: onDone,
2408
+ onSubmit: save,
2409
+ hideBreadcrumb: true
2410
+ }
2411
+ )
2412
+ ] });
2413
+ }
2181
2414
  function buildNav(config, view, navigate) {
2182
2415
  const items = [
2183
2416
  { id: "overview", label: config.labels.overview, icon: "BarChart3", active: view === "overview", onClick: () => navigate("overview") }
@@ -2209,9 +2442,18 @@ function buildNav(config, view, navigate) {
2209
2442
  onClick: () => navigate("content")
2210
2443
  });
2211
2444
  }
2445
+ if (config.modules.blog) {
2446
+ items.push({
2447
+ id: "blog",
2448
+ label: config.labels.blog,
2449
+ icon: "Newspaper",
2450
+ active: view === "blog" || view === "blog-new" || view.startsWith("blog-post:") || view === "blog-cat-new" || view.startsWith("blog-cat:"),
2451
+ onClick: () => navigate("blog")
2452
+ });
2453
+ }
2212
2454
  return items;
2213
2455
  }
2214
- function MarketingPage({ config, provider, store, contentProvider, contentStore }) {
2456
+ function MarketingPage({ config, provider: provider2, store, contentProvider, contentStore }) {
2215
2457
  const t = core.useTranslation();
2216
2458
  const { view, direction, navigate } = saas.useModuleNavigation("/marketing", {
2217
2459
  overview: 0,
@@ -2220,15 +2462,22 @@ function MarketingPage({ config, provider, store, contentProvider, contentStore
2220
2462
  funnel: 0,
2221
2463
  "landing-pages": 0,
2222
2464
  content: 0,
2465
+ blog: 0,
2223
2466
  "channel-detail": 1,
2224
2467
  "content-post": 1,
2225
- "content-brief": 1
2468
+ "content-brief": 1,
2469
+ "blog-new": 1,
2470
+ "blog-post": 1,
2471
+ "blog-cat-new": 1,
2472
+ "blog-cat": 1
2226
2473
  }, "overview");
2227
2474
  React8__default.default.useEffect(() => {
2228
2475
  void store.getState().load();
2229
2476
  }, [store]);
2230
2477
  const isOverview = view === "overview";
2231
2478
  const nav = buildNav(config, view, navigate);
2479
+ const blogPostEntity = React8__default.default.useMemo(() => buildBlogPostEntity(), []);
2480
+ const blogCategoryEntity = React8__default.default.useMemo(() => buildBlogCategoryEntity(), []);
2232
2481
  const quickActions = React8__default.default.useMemo(() => [
2233
2482
  { id: "new-campaign", label: t("marketing.campaigns.new"), icon: "Megaphone", description: config.labels.campaigns, action: () => navigate("campaigns") }
2234
2483
  ], [t, config.labels.campaigns]);
@@ -2241,9 +2490,14 @@ function MarketingPage({ config, provider, store, contentProvider, contentStore
2241
2490
  { id: "content", render: () => /* @__PURE__ */ jsxRuntime.jsx(ContentView, { onOpenPost: (id) => navigate(`content-post:${id}`) }) },
2242
2491
  { id: "content-post", render: ({ id }) => /* @__PURE__ */ jsxRuntime.jsx(PostPage, { postId: id, onBack: () => navigate("content") }) },
2243
2492
  { id: "content-brief", render: () => /* @__PURE__ */ jsxRuntime.jsx(PlanBriefView, { onBack: () => navigate("content") }) },
2493
+ { id: "blog", render: () => /* @__PURE__ */ jsxRuntime.jsx(BlogAdminView, { navigate }) },
2494
+ { id: "blog-new", render: () => /* @__PURE__ */ jsxRuntime.jsx(BlogEntityForm, { entity: blogPostEntity, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
2495
+ { id: "blog-post", render: ({ id }) => /* @__PURE__ */ jsxRuntime.jsx(BlogEntityForm, { entity: blogPostEntity, editId: id, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
2496
+ { id: "blog-cat-new", render: () => /* @__PURE__ */ jsxRuntime.jsx(BlogEntityForm, { entity: blogCategoryEntity, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2497
+ { id: "blog-cat", render: ({ id }) => /* @__PURE__ */ jsxRuntime.jsx(BlogEntityForm, { entity: blogCategoryEntity, editId: id, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2244
2498
  { id: "overview", render: () => /* @__PURE__ */ jsxRuntime.jsx(OverviewView, {}) }
2245
2499
  ], "overview");
2246
- return /* @__PURE__ */ jsxRuntime.jsx(MarketingContextProvider, { config, provider, store, children: /* @__PURE__ */ jsxRuntime.jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsxRuntime.jsx(
2500
+ return /* @__PURE__ */ jsxRuntime.jsx(MarketingContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsxRuntime.jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsxRuntime.jsx(
2247
2501
  ui.ModulePage,
2248
2502
  {
2249
2503
  title: config.labels.pageTitle,
@@ -2518,7 +2772,7 @@ var AGENCY = {
2518
2772
  { id: "referral", label: "Referral", icon: "Users", kind: "referral" },
2519
2773
  { id: "direct", label: "Direct", icon: "MousePointerClick", kind: "direct" }
2520
2774
  ],
2521
- modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, broadcasts: false, journeys: false }
2775
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
2522
2776
  };
2523
2777
  var BEAUTY = {
2524
2778
  conversion: {
@@ -2536,7 +2790,7 @@ var BEAUTY = {
2536
2790
  { id: "whatsapp", label: "WhatsApp", icon: "MessageCircle", kind: "social" },
2537
2791
  { id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
2538
2792
  ],
2539
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, broadcasts: false, journeys: false }
2793
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, blog: false, broadcasts: false, journeys: false }
2540
2794
  };
2541
2795
  var RESTO = {
2542
2796
  conversion: {
@@ -2554,7 +2808,7 @@ var RESTO = {
2554
2808
  { id: "instagram", label: "Instagram", icon: "Instagram", kind: "social" },
2555
2809
  { id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
2556
2810
  ],
2557
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, broadcasts: false, journeys: false }
2811
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
2558
2812
  };
2559
2813
  var MARKETING_PRESETS = {
2560
2814
  agency: AGENCY,
@@ -3215,7 +3469,7 @@ function createSupabaseContentPlannerProvider() {
3215
3469
  }
3216
3470
  };
3217
3471
  }
3218
- function createMarketingStore(provider) {
3472
+ function createMarketingStore(provider2) {
3219
3473
  return vanilla.createStore((set, get) => ({
3220
3474
  range: "30d",
3221
3475
  overview: null,
@@ -3228,11 +3482,11 @@ function createMarketingStore(provider) {
3228
3482
  const range = get().range;
3229
3483
  set({ loading: true });
3230
3484
  const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
3231
- provider.overview({ range }),
3232
- provider.channelPerformance({ range }),
3233
- provider.listCampaigns({ range }),
3234
- provider.funnel({ range }),
3235
- provider.landingPages({ range })
3485
+ provider2.overview({ range }),
3486
+ provider2.channelPerformance({ range }),
3487
+ provider2.listCampaigns({ range }),
3488
+ provider2.funnel({ range }),
3489
+ provider2.landingPages({ range })
3236
3490
  ]);
3237
3491
  set({ overview, channels, campaigns, funnel, landingPages, loading: false });
3238
3492
  },
@@ -3241,21 +3495,21 @@ function createMarketingStore(provider) {
3241
3495
  await get().load();
3242
3496
  },
3243
3497
  async saveCampaign(input) {
3244
- const created = await provider.saveCampaign(input);
3498
+ const created = await provider2.saveCampaign(input);
3245
3499
  await get().load();
3246
3500
  return created;
3247
3501
  },
3248
3502
  async deleteCampaign(id) {
3249
- await provider.deleteCampaign(id);
3503
+ await provider2.deleteCampaign(id);
3250
3504
  await get().load();
3251
3505
  }
3252
3506
  }));
3253
3507
  }
3254
- function createContentPlannerStore(provider) {
3508
+ function createContentPlannerStore(provider2) {
3255
3509
  async function loadPlansFor(accountId) {
3256
- const plans = await provider.listPlans(accountId);
3510
+ const plans = await provider2.listPlans(accountId);
3257
3511
  const activePlan = plans.find((p) => p.status === "active") ?? plans[0] ?? null;
3258
- const posts = activePlan ? await provider.listPosts(activePlan.id) : [];
3512
+ const posts = activePlan ? await provider2.listPosts(activePlan.id) : [];
3259
3513
  return { plans, activePlan, posts };
3260
3514
  }
3261
3515
  return vanilla.createStore((set, get) => ({
@@ -3275,7 +3529,7 @@ function createContentPlannerStore(provider) {
3275
3529
  if (get().loading) return;
3276
3530
  set({ loading: true });
3277
3531
  try {
3278
- const accounts = await provider.listAccounts();
3532
+ const accounts = await provider2.listAccounts();
3279
3533
  const current = get().activeAccountId;
3280
3534
  const activeAccountId = (current && accounts.some((a) => a.id === current) ? current : accounts[0]?.id) ?? null;
3281
3535
  const { plans, activePlan, posts } = activeAccountId ? await loadPlansFor(activeAccountId) : { plans: [], activePlan: null, posts: [] };
@@ -3306,19 +3560,19 @@ function createContentPlannerStore(provider) {
3306
3560
  },
3307
3561
  async selectPlan(planId) {
3308
3562
  const plan = get().plans.find((p) => p.id === planId) ?? null;
3309
- const posts = plan ? await provider.listPosts(plan.id) : [];
3563
+ const posts = plan ? await provider2.listPosts(plan.id) : [];
3310
3564
  set({ activePlan: plan, posts });
3311
3565
  },
3312
3566
  async saveAccount(input) {
3313
- const account = await provider.saveAccount(input);
3314
- const accounts = await provider.listAccounts();
3567
+ const account = await provider2.saveAccount(input);
3568
+ const accounts = await provider2.listAccounts();
3315
3569
  set({ accounts });
3316
3570
  if (!get().activeAccountId) await get().setAccount(account.id);
3317
3571
  return account;
3318
3572
  },
3319
3573
  async deleteAccount(id) {
3320
- await provider.deleteAccount(id);
3321
- const accounts = await provider.listAccounts();
3574
+ await provider2.deleteAccount(id);
3575
+ const accounts = await provider2.listAccounts();
3322
3576
  const wasActive = get().activeAccountId === id;
3323
3577
  set({ accounts });
3324
3578
  if (wasActive) {
@@ -3331,30 +3585,30 @@ function createContentPlannerStore(provider) {
3331
3585
  }
3332
3586
  },
3333
3587
  async savePlan(input) {
3334
- const plan = await provider.savePlan(input);
3588
+ const plan = await provider2.savePlan(input);
3335
3589
  const accountId = get().activeAccountId;
3336
3590
  if (accountId) {
3337
- const plans = await provider.listPlans(accountId);
3591
+ const plans = await provider2.listPlans(accountId);
3338
3592
  const activePlan = plans.find((p) => p.id === plan.id) ?? get().activePlan;
3339
- const posts = activePlan ? await provider.listPosts(activePlan.id) : [];
3593
+ const posts = activePlan ? await provider2.listPosts(activePlan.id) : [];
3340
3594
  set({ plans, activePlan, posts });
3341
3595
  }
3342
3596
  return plan;
3343
3597
  },
3344
3598
  async savePost(input) {
3345
- const post = await provider.savePost(input);
3599
+ const post = await provider2.savePost(input);
3346
3600
  const planId = get().activePlan?.id;
3347
3601
  if (planId && post.planId === planId) {
3348
- const posts = await provider.listPosts(planId);
3602
+ const posts = await provider2.listPosts(planId);
3349
3603
  set({ posts });
3350
3604
  }
3351
3605
  return post;
3352
3606
  },
3353
3607
  async deletePost(id) {
3354
- await provider.deletePost(id);
3608
+ await provider2.deletePost(id);
3355
3609
  const planId = get().activePlan?.id;
3356
3610
  if (planId) {
3357
- const posts = await provider.listPosts(planId);
3611
+ const posts = await provider2.listPosts(planId);
3358
3612
  set({ posts });
3359
3613
  }
3360
3614
  }
@@ -3525,6 +3779,204 @@ CREATE POLICY plg_marketing_media_insert ON storage.objects FOR INSERT TO authen
3525
3779
  CREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'mkt-media');
3526
3780
  CREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');
3527
3781
  `;
3782
+ var MIGRATION_004_RANKLAYER = `-- Marketing Plugin: RankLayer integration (SEO content publishing).
3783
+ -- Control-plane state for the RankLayer connector rendered in Marketing \u2192
3784
+ -- Integra\xE7\xF5es. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
3785
+ -- (edge function + RankLayer API calls) lands via an external PR \u2014 see
3786
+ -- src/integrations/ranklayer/RANKLAYER.md.
3787
+
3788
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
3789
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3790
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
3791
+ api_key text,
3792
+ site_domain text,
3793
+ active boolean NOT NULL DEFAULT true,
3794
+ last_sync_at timestamptz,
3795
+ created_at timestamptz NOT NULL DEFAULT now(),
3796
+ updated_at timestamptz NOT NULL DEFAULT now()
3797
+ );
3798
+ ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
3799
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
3800
+ ON public.plg_marketing_ranklayer_integrations(tenant_id);
3801
+
3802
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
3803
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3804
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
3805
+ integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
3806
+ status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
3807
+ message text,
3808
+ fetched integer NOT NULL DEFAULT 0,
3809
+ written integer NOT NULL DEFAULT 0,
3810
+ created_at timestamptz NOT NULL DEFAULT now()
3811
+ );
3812
+ ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
3813
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
3814
+ ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
3815
+
3816
+ -- RLS: authenticated tenant CRUD
3817
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
3818
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
3819
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
3820
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
3821
+ 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()));
3822
+ 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()));
3823
+ 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()));
3824
+ 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()));
3825
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
3826
+
3827
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
3828
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
3829
+ 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()));
3830
+ 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()));
3831
+ GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
3832
+ `;
3833
+ var INTEGRATIONS = "plg_marketing_ranklayer_integrations";
3834
+ var SYNC_LOG = "plg_marketing_ranklayer_sync_log";
3835
+ function getClient2() {
3836
+ const supabase = core.getSupabaseClientOptional();
3837
+ if (!supabase) throw new Error("[ranklayer] Supabase not initialized");
3838
+ return supabase;
3839
+ }
3840
+ function requireTenantId2() {
3841
+ const tenantId = core.getActiveTenantId();
3842
+ if (!tenantId) throw new Error("[ranklayer] No active tenant");
3843
+ return tenantId;
3844
+ }
3845
+ function toIntegration(row) {
3846
+ return {
3847
+ id: row.id,
3848
+ apiKey: row.api_key ?? void 0,
3849
+ siteDomain: row.site_domain ?? void 0,
3850
+ active: row.active ?? true,
3851
+ lastSyncAt: row.last_sync_at ?? void 0
3852
+ };
3853
+ }
3854
+ function createRankLayerProvider() {
3855
+ return {
3856
+ async getIntegration() {
3857
+ const tenantId = core.getActiveTenantId();
3858
+ if (!tenantId) return null;
3859
+ const { data } = await getClient2().from(INTEGRATIONS).select("*").eq("tenant_id", tenantId).maybeSingle();
3860
+ return data ? toIntegration(data) : null;
3861
+ },
3862
+ async saveIntegration(values) {
3863
+ const tenantId = requireTenantId2();
3864
+ const payload = {
3865
+ tenant_id: tenantId,
3866
+ api_key: values.apiKey,
3867
+ site_domain: values.siteDomain ?? null,
3868
+ active: true,
3869
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
3870
+ };
3871
+ const { data, error } = await getClient2().from(INTEGRATIONS).upsert(payload, { onConflict: "tenant_id" }).select("*").single();
3872
+ if (error) throw error;
3873
+ return toIntegration(data);
3874
+ },
3875
+ async disconnect() {
3876
+ const tenantId = core.getActiveTenantId();
3877
+ if (!tenantId) return;
3878
+ await getClient2().from(INTEGRATIONS).delete().eq("tenant_id", tenantId);
3879
+ },
3880
+ // SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
3881
+ // flow can be exercised. Replace with a real credential check in the PR.
3882
+ async testConnection(values) {
3883
+ if (!values.apiKey || values.apiKey.trim().length < 8) {
3884
+ return { ok: false, message: "Informe uma API Key v\xE1lida da RankLayer." };
3885
+ }
3886
+ return { ok: true, message: "Chave aceita (valida\xE7\xE3o real pendente \u2014 integra\xE7\xE3o em prepara\xE7\xE3o)." };
3887
+ },
3888
+ async getSyncLog(integrationId) {
3889
+ const { data } = await getClient2().from(SYNC_LOG).select("*").eq("integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
3890
+ return (data ?? []).map((r) => ({
3891
+ id: r.id,
3892
+ createdAt: r.created_at,
3893
+ status: r.status,
3894
+ message: r.message ?? void 0,
3895
+ fetched: r.fetched ?? 0,
3896
+ written: r.written ?? 0
3897
+ }));
3898
+ }
3899
+ };
3900
+ }
3901
+ var provider = createRankLayerProvider();
3902
+ function RankLayerExtraPanel() {
3903
+ const [integration, setIntegration] = React8.useState(null);
3904
+ const [log, setLog] = React8.useState([]);
3905
+ const [loaded, setLoaded] = React8.useState(false);
3906
+ React8.useEffect(() => {
3907
+ let cancelled = false;
3908
+ (async () => {
3909
+ try {
3910
+ const found = await provider.getIntegration();
3911
+ if (cancelled) return;
3912
+ setIntegration(found);
3913
+ if (found) setLog(await provider.getSyncLog(found.id));
3914
+ } catch {
3915
+ } finally {
3916
+ if (!cancelled) setLoaded(true);
3917
+ }
3918
+ })();
3919
+ return () => {
3920
+ cancelled = true;
3921
+ };
3922
+ }, []);
3923
+ if (!loaded || !integration) return null;
3924
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4 border-t pt-4", children: [
3925
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-3 rounded-md border border-dashed p-3", children: [
3926
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Sparkles, { className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" }),
3927
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-1", children: [
3928
+ /* @__PURE__ */ jsxRuntime.jsx("h4", { className: "text-sm font-semibold", children: "Sincroniza\xE7\xE3o de conte\xFAdo \u2014 em breve" }),
3929
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-xs text-muted-foreground", children: [
3930
+ "A publica\xE7\xE3o autom\xE1tica de p\xE1ginas e artigos de SEO com a RankLayer est\xE1 em prepara\xE7\xE3o.",
3931
+ integration.siteDomain ? ` Dom\xEDnio configurado: ${integration.siteDomain}.` : "",
3932
+ " Assim que a integra\xE7\xE3o estiver ativa, o hist\xF3rico de sincroniza\xE7\xF5es aparece aqui."
3933
+ ] })
3934
+ ] })
3935
+ ] }),
3936
+ log.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
3937
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
3938
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.History, { className: "h-4 w-4 text-muted-foreground" }),
3939
+ /* @__PURE__ */ jsxRuntime.jsx("h4", { className: "text-sm font-semibold", children: "Hist\xF3rico" })
3940
+ ] }),
3941
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "divide-y rounded-md border text-sm", children: log.map((r) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [
3942
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-muted-foreground", children: new Date(r.createdAt).toLocaleString("pt-BR") }),
3943
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "ml-auto text-xs", children: [
3944
+ r.written,
3945
+ "/",
3946
+ r.fetched,
3947
+ " publicadas"
3948
+ ] }),
3949
+ r.status === "success" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.CheckCircle2, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.AlertCircle, { className: "h-3.5 w-3.5 text-warning" })
3950
+ ] }, r.id)) })
3951
+ ] })
3952
+ ] });
3953
+ }
3954
+ var ranklayerConnectorDef = {
3955
+ id: "ranklayer",
3956
+ hostPluginId: "marketing",
3957
+ name: "RankLayer",
3958
+ 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.",
3959
+ icon: "Search",
3960
+ authKind: "api-key",
3961
+ fields: [
3962
+ { key: "apiKey", label: "API Key", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" },
3963
+ { key: "siteDomain", label: "Dom\xEDnio do site", type: "text", placeholder: "exemplo.com.br" }
3964
+ ],
3965
+ async getStatus() {
3966
+ try {
3967
+ const found = await provider.getIntegration();
3968
+ return { connected: !!found?.apiKey, detail: found?.siteDomain };
3969
+ } catch {
3970
+ return { connected: false };
3971
+ }
3972
+ },
3973
+ testConnection: (values) => provider.testConnection({ apiKey: values.apiKey }),
3974
+ saveConnection: async (values) => {
3975
+ await provider.saveIntegration({ apiKey: values.apiKey, siteDomain: values.siteDomain });
3976
+ },
3977
+ disconnect: () => provider.disconnect(),
3978
+ ExtraPanel: RankLayerExtraPanel
3979
+ };
3528
3980
 
3529
3981
  // src/locales/en.ts
3530
3982
  var en = {
@@ -3884,6 +4336,8 @@ var DEFAULT_LABELS = {
3884
4336
  funnel: "Funnel",
3885
4337
  landingPages: "Landing pages",
3886
4338
  content: "Content",
4339
+ blog: "Blog",
4340
+ blogCategories: "Categorias",
3887
4341
  settings: "Settings"
3888
4342
  };
3889
4343
  function resolveConfig(options) {
@@ -3905,9 +4359,9 @@ function createSafeProvider(config, domain) {
3905
4359
  function createMarketingPlugin(options) {
3906
4360
  core.registerTranslations(marketingLocales);
3907
4361
  const { config, domain } = resolveConfig(options);
3908
- const provider = options?.dataProvider ?? createSafeProvider(config, domain);
3909
- const store = createMarketingStore(provider);
3910
- const dashboardWidgets = createMarketingDashboardWidgets({ config, provider, store });
4362
+ const provider2 = options?.dataProvider ?? createSafeProvider(config, domain);
4363
+ const store = createMarketingStore(provider2);
4364
+ const dashboardWidgets = createMarketingDashboardWidgets({ config, provider: provider2, store });
3911
4365
  const contentProvider = options?.contentPlanner?.dataProvider ?? core.createSafeDataProvider(
3912
4366
  () => createSupabaseContentPlannerProvider(),
3913
4367
  () => createMockContentPlannerProvider()
@@ -3915,8 +4369,8 @@ function createMarketingPlugin(options) {
3915
4369
  const contentStore = createContentPlannerStore(contentProvider);
3916
4370
  const PageComponent = () => React8__default.default.createElement(
3917
4371
  MarketingContextProvider,
3918
- { config, provider, store },
3919
- React8__default.default.createElement(MarketingPage, { config, provider, store, contentProvider, contentStore })
4372
+ { config, provider: provider2, store },
4373
+ React8__default.default.createElement(MarketingPage, { config, provider: provider2, store, contentProvider, contentStore })
3920
4374
  );
3921
4375
  PageComponent.displayName = "MarketingPage";
3922
4376
  const marketingRegistries = [
@@ -3925,12 +4379,14 @@ function createMarketingPlugin(options) {
3925
4379
  ];
3926
4380
  const SettingsComponent = () => React8__default.default.createElement(
3927
4381
  MarketingContextProvider,
3928
- { config, provider, store },
4382
+ { config, provider: provider2, store },
3929
4383
  React8__default.default.createElement(saas.PluginSettingsPanel, {
3930
4384
  title: config.labels.settings ?? config.labels.pageTitle,
3931
4385
  subtitle: config.labels.pageSubtitle,
3932
4386
  generalSettings: React8__default.default.createElement(SettingsView),
3933
4387
  registries: marketingRegistries,
4388
+ // Enables the "Integrações" tab (renders manifest connectors for this host).
4389
+ hostPluginId: "marketing",
3934
4390
  routeBase: "/settings/marketing"
3935
4391
  })
3936
4392
  );
@@ -3950,7 +4406,8 @@ function createMarketingPlugin(options) {
3950
4406
  declaredFeatures: [
3951
4407
  { id: "marketing", label: config.labels.pageTitle, group: "Engage" },
3952
4408
  ...config.modules.landingPages ? [{ id: "marketing.landing-pages", label: config.labels.landingPages, group: "Engage" }] : [],
3953
- ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : []
4409
+ ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : [],
4410
+ ...config.modules.blog ? [{ id: "marketing.blog", label: config.labels.blog, group: "Engage" }] : []
3954
4411
  ],
3955
4412
  navigation: [
3956
4413
  {
@@ -4004,8 +4461,17 @@ function createMarketingPlugin(options) {
4004
4461
  version: "1.2.0",
4005
4462
  sql: MIGRATION_003_RECORDING_OPS,
4006
4463
  description: "Recording-day checklist + media asset URL on posts; content-media storage bucket"
4464
+ },
4465
+ {
4466
+ id: "marketing-004-ranklayer",
4467
+ version: "1.3.0",
4468
+ sql: MIGRATION_004_RANKLAYER,
4469
+ description: "RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)"
4007
4470
  }
4008
4471
  ],
4472
+ // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
4473
+ // (control plane only); the real sync lands via an external PR (RANKLAYER.md).
4474
+ connectors: [ranklayerConnectorDef],
4009
4475
  aiTools: [
4010
4476
  {
4011
4477
  id: "marketing.channel-performance",