@fayz-ai/plugin-marketing 0.12.4 → 0.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import React9, { useState, useEffect, useMemo, useCallback } from 'react';
2
2
  import { connectionSettings, connectionStatus, settingsStatus, createConnectionStore, getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, countByTenant, useTranslation, useDataChanged, resolveDataProvider } from '@fayz-ai/core';
3
3
  import { createPluginContext, fayzConnectorOAuth, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, askAI, PageActions, useLimitGuard, useOrganizationStore, CrudFormPage, RightRailPage, invalidateLimit, CrudListView } from '@fayz-ai/admin';
4
- import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, PageToolbar, DataTable, SubpageHeader, RowActions, PageHeaderActions, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, DashboardCanvas, RangeControl, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, SheetBody, SegmentedControl, Badge, SheetFooter, SelectSeparator, Checkbox, toast, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
4
+ import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, PageToolbar, DataTable, SubpageHeader, RowActions, PageHeaderActions, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, DashboardCanvas, RangeControl, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, SegmentedControl, ModalFooter, SheetBody, Badge, SheetFooter, SelectSeparator, Checkbox, toast, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
5
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
- import { History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, WandSparkles, SlidersHorizontal, ExternalLink, Sparkles, Pencil, Plus, Layers, MousePointerClick, 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';
6
+ import { History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, WandSparkles, SlidersHorizontal, Workflow, ExternalLink, Sparkles, Pencil, Plus, Layers, MousePointerClick, NotebookPen, ArrowUpRight, ArrowDownRight, Send, Megaphone, Settings2, Columns3, CalendarDays, List, ChevronLeft, Loader2, ImagePlus, X, Bike, Utensils, DoorOpen, MessageCircle, Instagram, Mail, Globe, Search, Link2, AtSign, Pin, Twitter, Linkedin, Facebook, Youtube, Music2, Repeat2, Heart, BarChart2, Share2, ThumbsUp, MoreHorizontal, Bookmark, GripVertical, Image, Trophy } from 'lucide-react';
7
7
  import { createStore } from 'zustand/vanilla';
8
8
 
9
9
  // src/index.ts
@@ -249,41 +249,81 @@ function ChannelDetailView({ channelId, onBack }) {
249
249
  ] });
250
250
  }
251
251
  var STATUSES = ["draft", "active", "paused"];
252
- function CampaignComposer({ open, onClose }) {
252
+ var PURPOSES = ["promotion", "newsletter", "survey", "transactional"];
253
+ function CampaignComposer({ open, onClose, initialSegmentId }) {
253
254
  const t = useTranslation();
254
255
  const { channels } = useMarketingConfig();
256
+ const provider2 = useMarketingProvider();
255
257
  const saveCampaign = useMarketingStore((s) => s.saveCampaign);
258
+ const [kind, setKind] = React9.useState("ad_spend");
256
259
  const [name, setName] = React9.useState("");
257
260
  const [channelId, setChannelId] = React9.useState(channels[0]?.id ?? "");
258
- const [status, setStatus] = React9.useState("active");
261
+ const [status, setStatus] = React9.useState("draft");
259
262
  const [spend, setSpend] = React9.useState("");
263
+ const [purpose, setPurpose] = React9.useState("promotion");
264
+ const [segmentId, setSegmentId] = React9.useState("");
265
+ const [templateKey, setTemplateKey] = React9.useState("");
266
+ const [segments, setSegments] = React9.useState([]);
260
267
  const [saving, setSaving] = React9.useState(false);
261
268
  React9.useEffect(() => {
262
- if (open) {
263
- setName("");
264
- setChannelId(channels[0]?.id ?? "");
265
- setStatus("active");
266
- setSpend("");
267
- }
268
- }, [open, channels]);
269
+ if (!open) return;
270
+ setName("");
271
+ setChannelId(channels[0]?.id ?? "");
272
+ setSpend("");
273
+ setPurpose("promotion");
274
+ setTemplateKey("");
275
+ setStatus("draft");
276
+ setSegmentId(initialSegmentId ?? "");
277
+ setKind(initialSegmentId ? "outbound" : "ad_spend");
278
+ void provider2.listAudienceSegments().then(setSegments).catch(() => setSegments([]));
279
+ }, [open, channels, initialSegmentId, provider2]);
280
+ const isOutbound = kind === "outbound";
281
+ const canSave = name.trim().length > 0 && (isOutbound ? Boolean(segmentId && templateKey.trim()) : Boolean(channelId));
269
282
  async function handleSave() {
270
- if (!name.trim() || !channelId) return;
283
+ if (!canSave) return;
271
284
  setSaving(true);
272
- await saveCampaign({
273
- name: name.trim(),
274
- channelId,
275
- status,
276
- start: (/* @__PURE__ */ new Date()).toISOString(),
277
- spend: Number(spend) || 0
278
- });
279
- setSaving(false);
280
- onClose();
285
+ try {
286
+ await saveCampaign({
287
+ name: name.trim(),
288
+ // Um disparo também tem canal: é por ele que o relatório soma o que
289
+ // saiu por WhatsApp e o que saiu por e-mail.
290
+ channelId: channelId || channels[0]?.id || "email",
291
+ status,
292
+ kind,
293
+ purpose: isOutbound ? purpose : "promotion",
294
+ segmentId: isOutbound ? segmentId : void 0,
295
+ templateKey: isOutbound ? templateKey.trim() : void 0,
296
+ start: (/* @__PURE__ */ new Date()).toISOString(),
297
+ spend: isOutbound ? 0 : Number(spend) || 0
298
+ });
299
+ onClose();
300
+ } finally {
301
+ setSaving(false);
302
+ }
281
303
  }
304
+ const selectedSegment = segments.find((s) => s.id === segmentId);
282
305
  return /* @__PURE__ */ jsx(Modal, { open, onOpenChange: (o) => {
283
306
  if (!o) onClose();
284
307
  }, children: /* @__PURE__ */ jsxs(ModalContent, { size: "md", children: [
285
308
  /* @__PURE__ */ jsx(ModalHeader, { children: /* @__PURE__ */ jsx(ModalTitle, { children: t("marketing.composer.title") }) }),
286
309
  /* @__PURE__ */ jsxs(ModalBody, { className: "space-y-4", children: [
310
+ /* @__PURE__ */ jsx(
311
+ SegmentedControl,
312
+ {
313
+ className: "w-full",
314
+ value: kind,
315
+ onChange: setKind,
316
+ "aria-label": t("marketing.composer.kind"),
317
+ options: [
318
+ { value: "ad_spend", label: t("marketing.composer.kindSpend") },
319
+ { value: "outbound", label: t("marketing.composer.kindOutbound") }
320
+ ]
321
+ }
322
+ ),
323
+ /* @__PURE__ */ jsxs("p", { className: "-mt-2 flex items-start gap-1.5 text-[11px] text-muted-foreground", children: [
324
+ isOutbound ? /* @__PURE__ */ jsx(Send, { className: "mt-0.5 h-3 w-3 shrink-0" }) : /* @__PURE__ */ jsx(Megaphone, { className: "mt-0.5 h-3 w-3 shrink-0" }),
325
+ isOutbound ? t("marketing.composer.kindOutboundHint") : t("marketing.composer.kindSpendHint")
326
+ ] }),
287
327
  /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
288
328
  /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.name") }),
289
329
  /* @__PURE__ */ jsx(Input, { value: name, onChange: (e) => setName(e.target.value), placeholder: t("marketing.composer.namePlaceholder") })
@@ -295,23 +335,46 @@ function CampaignComposer({ open, onClose }) {
295
335
  /* @__PURE__ */ jsx(SelectContent, { children: channels.map((c) => /* @__PURE__ */ jsx(SelectItem, { value: c.id, children: c.label }, c.id)) })
296
336
  ] })
297
337
  ] }),
298
- /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-3", children: [
338
+ isOutbound ? /* @__PURE__ */ jsxs(Fragment, { children: [
299
339
  /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
300
- /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.status") }),
301
- /* @__PURE__ */ jsxs(Select, { value: status, onValueChange: (v) => setStatus(v), children: [
302
- /* @__PURE__ */ jsx(SelectTrigger, { children: /* @__PURE__ */ jsx(SelectValue, {}) }),
303
- /* @__PURE__ */ jsx(SelectContent, { children: STATUSES.map((s) => /* @__PURE__ */ jsx(SelectItem, { value: s, className: "capitalize", children: t(`marketing.status.${s}`) }, s)) })
340
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.audience") }),
341
+ segments.length > 0 ? /* @__PURE__ */ jsxs(Select, { value: segmentId, onValueChange: setSegmentId, children: [
342
+ /* @__PURE__ */ jsx(SelectTrigger, { children: /* @__PURE__ */ jsx(SelectValue, { placeholder: t("marketing.composer.audiencePlaceholder") }) }),
343
+ /* @__PURE__ */ jsx(SelectContent, { children: segments.map((s) => /* @__PURE__ */ jsx(SelectItem, { value: s.id, children: s.name }, s.id)) })
344
+ ] }) : /* @__PURE__ */ jsx("p", { className: "rounded-lg border border-dashed px-3 py-2 text-xs text-muted-foreground", children: t("marketing.composer.noSegments") }),
345
+ selectedSegment?.description && /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [
346
+ /* @__PURE__ */ jsx(Users, { className: "h-3 w-3" }),
347
+ selectedSegment.description
304
348
  ] })
305
349
  ] }),
306
350
  /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
307
- /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.budget") }),
308
- /* @__PURE__ */ jsx(Input, { type: "number", min: 0, value: spend, onChange: (e) => setSpend(e.target.value), placeholder: "0" })
351
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.purpose") }),
352
+ /* @__PURE__ */ jsxs(Select, { value: purpose, onValueChange: (v) => setPurpose(v), children: [
353
+ /* @__PURE__ */ jsx(SelectTrigger, { children: /* @__PURE__ */ jsx(SelectValue, {}) }),
354
+ /* @__PURE__ */ jsx(SelectContent, { children: PURPOSES.map((p) => /* @__PURE__ */ jsx(SelectItem, { value: p, children: t(`marketing.purpose.${p}`) }, p)) })
355
+ ] }),
356
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: t(`marketing.purpose.${purpose}.hint`) })
357
+ ] }),
358
+ /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
359
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.template") }),
360
+ /* @__PURE__ */ jsx(Input, { value: templateKey, onChange: (e) => setTemplateKey(e.target.value), placeholder: "ex.: campanha_reativacao" }),
361
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: t("marketing.composer.templateHint") })
362
+ ] })
363
+ ] }) : /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
364
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.budget") }),
365
+ /* @__PURE__ */ jsx(Input, { type: "number", min: 0, value: spend, onChange: (e) => setSpend(e.target.value), placeholder: "0" })
366
+ ] }),
367
+ /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
368
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.status") }),
369
+ /* @__PURE__ */ jsxs(Select, { value: status, onValueChange: (v) => setStatus(v), children: [
370
+ /* @__PURE__ */ jsx(SelectTrigger, { children: /* @__PURE__ */ jsx(SelectValue, {}) }),
371
+ /* @__PURE__ */ jsx(SelectContent, { children: STATUSES.map((s) => /* @__PURE__ */ jsx(SelectItem, { value: s, className: "capitalize", children: t(`marketing.status.${s}`) }, s)) })
309
372
  ] })
310
373
  ] })
311
374
  ] }),
312
375
  /* @__PURE__ */ jsxs(ModalFooter, { children: [
313
376
  /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: onClose, children: t("marketing.composer.cancel") }),
314
- /* @__PURE__ */ jsx(Button, { onClick: handleSave, disabled: saving || !name.trim(), children: t("marketing.composer.create") })
377
+ /* @__PURE__ */ jsx(Button, { onClick: handleSave, disabled: saving || !canSave, children: t("marketing.composer.create") })
315
378
  ] })
316
379
  ] }) });
317
380
  }
@@ -431,6 +494,17 @@ function FunnelView() {
431
494
  ] })
432
495
  ] });
433
496
  }
497
+ function CadencesView({ surface: Surface }) {
498
+ const t = useTranslation();
499
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
500
+ /* @__PURE__ */ jsx(SubpageHeader, { title: t("marketing.cadences.title"), subtitle: t("marketing.cadences.subtitle") }),
501
+ Surface ? /* @__PURE__ */ jsx(Surface, {}) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-1 rounded-lg border border-dashed py-12 text-center", children: [
502
+ /* @__PURE__ */ jsx(Workflow, { className: "mb-2 h-9 w-9 text-muted-foreground/40", strokeWidth: 1.5 }),
503
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: t("marketing.cadences.missingTitle") }),
504
+ /* @__PURE__ */ jsx("p", { className: "max-w-md text-xs text-muted-foreground", children: t("marketing.cadences.missingHint") })
505
+ ] })
506
+ ] });
507
+ }
434
508
 
435
509
  // src/types.ts
436
510
  var CAPTURE_FIELDS = ["name", "phone", "email", "document", "notes"];
@@ -2713,11 +2787,12 @@ function CategoriesList({ navigate, navTabs }) {
2713
2787
  }
2714
2788
  function BlogAdminView({ navigate }) {
2715
2789
  const { labels } = useMarketingConfig();
2790
+ const t = useTranslation();
2716
2791
  const [lens, setLens] = useState("posts");
2717
2792
  const navTabs = useMemo(() => [
2718
- { id: "posts", label: labels.blog, onSelect: () => setLens("posts") },
2719
- { id: "categories", label: labels.blogCategories, onSelect: () => setLens("categories") }
2720
- ], [labels.blog, labels.blogCategories]);
2793
+ { id: "posts", label: labels.blog ?? t("marketing.nav.blog"), onSelect: () => setLens("posts") },
2794
+ { id: "categories", label: labels.blogCategories ?? t("marketing.nav.blogCategories"), onSelect: () => setLens("categories") }
2795
+ ], [labels.blog, labels.blogCategories, t]);
2721
2796
  return lens === "posts" ? /* @__PURE__ */ jsx(PostsList, { navigate, navTabs }) : /* @__PURE__ */ jsx(CategoriesList, { navigate, navTabs });
2722
2797
  }
2723
2798
  function BlogEntityForm({
@@ -2793,32 +2868,35 @@ function BlogEntityForm({
2793
2868
  )
2794
2869
  ] });
2795
2870
  }
2796
- function buildNav(config, view, navigate) {
2871
+ function buildNav(config, view, navigate, hasCadenceSurface, t) {
2797
2872
  const items = [
2798
- { id: "overview", label: config.labels.overview, icon: "BarChart3", active: view === "overview", onClick: () => navigate("overview") }
2873
+ { id: "overview", label: config.labels.overview ?? t("marketing.nav.overview"), icon: "BarChart3", active: view === "overview", onClick: () => navigate("overview") }
2799
2874
  ];
2800
2875
  if (config.modules.channels) {
2801
2876
  items.push({
2802
2877
  id: "channels",
2803
- label: config.labels.channels,
2878
+ label: config.labels.channels ?? t("marketing.nav.channels"),
2804
2879
  icon: "Radio",
2805
2880
  active: view === "channels" || view.startsWith("channel-detail:"),
2806
2881
  onClick: () => navigate("channels")
2807
2882
  });
2808
2883
  }
2809
2884
  if (config.modules.campaigns) {
2810
- items.push({ id: "campaigns", label: config.labels.campaigns, icon: "Megaphone", active: view === "campaigns", onClick: () => navigate("campaigns") });
2885
+ items.push({ id: "campaigns", label: config.labels.campaigns ?? t("marketing.nav.campaigns"), icon: "Megaphone", active: view === "campaigns", onClick: () => navigate("campaigns") });
2811
2886
  }
2812
2887
  if (config.modules.funnel) {
2813
- items.push({ id: "funnel", label: config.labels.funnel, icon: "Filter", active: view === "funnel", onClick: () => navigate("funnel") });
2888
+ items.push({ id: "funnel", label: config.labels.funnel ?? t("marketing.nav.funnel"), icon: "Filter", active: view === "funnel", onClick: () => navigate("funnel") });
2889
+ }
2890
+ if (config.modules.cadences && hasCadenceSurface) {
2891
+ items.push({ id: "cadences", label: config.labels.cadences ?? t("marketing.cadences.title"), icon: "Workflow", active: view === "cadences", onClick: () => navigate("cadences") });
2814
2892
  }
2815
2893
  if (config.modules.landingPages) {
2816
- items.push({ id: "landing-pages", label: config.labels.landingPages, icon: "LayoutTemplate", active: view === "landing-pages", onClick: () => navigate("landing-pages") });
2894
+ items.push({ id: "landing-pages", label: config.labels.landingPages ?? t("marketing.nav.landingPages"), icon: "LayoutTemplate", active: view === "landing-pages", onClick: () => navigate("landing-pages") });
2817
2895
  }
2818
2896
  if (config.modules.contentPlanner) {
2819
2897
  items.push({
2820
2898
  id: "content",
2821
- label: config.labels.content,
2899
+ label: config.labels.content ?? t("marketing.nav.content"),
2822
2900
  icon: "Clapperboard",
2823
2901
  active: view === "content" || view === "content-brief" || view.startsWith("content-post:"),
2824
2902
  onClick: () => navigate("content")
@@ -2827,7 +2905,7 @@ function buildNav(config, view, navigate) {
2827
2905
  if (config.modules.blog) {
2828
2906
  items.push({
2829
2907
  id: "blog",
2830
- label: config.labels.blog,
2908
+ label: config.labels.blog ?? t("marketing.nav.blog"),
2831
2909
  icon: "Newspaper",
2832
2910
  active: view === "blog" || view === "blog-new" || view.startsWith("blog-post:") || view === "blog-cat-new" || view.startsWith("blog-cat:"),
2833
2911
  onClick: () => navigate("blog")
@@ -2835,13 +2913,14 @@ function buildNav(config, view, navigate) {
2835
2913
  }
2836
2914
  return items;
2837
2915
  }
2838
- function MarketingPage({ config, provider: provider2, store: store3, contentProvider, contentStore }) {
2916
+ function MarketingPage({ config, provider: provider2, store: store3, contentProvider, contentStore, cadenceSurface }) {
2839
2917
  const t = useTranslation();
2840
2918
  const { view, direction, navigate } = useModuleNavigation("/marketing", {
2841
2919
  overview: 0,
2842
2920
  channels: 0,
2843
2921
  campaigns: 0,
2844
2922
  funnel: 0,
2923
+ cadences: 0,
2845
2924
  "landing-pages": 0,
2846
2925
  content: 0,
2847
2926
  blog: 0,
@@ -2860,7 +2939,7 @@ function MarketingPage({ config, provider: provider2, store: store3, contentProv
2860
2939
  void store3.getState().refresh();
2861
2940
  }, [store3]);
2862
2941
  const isOverview = view === "overview";
2863
- const nav = buildNav(config, view, navigate);
2942
+ const nav = buildNav(config, view, navigate, Boolean(cadenceSurface), t);
2864
2943
  const blogPostEntity = React9.useMemo(() => buildBlogPostEntity(), []);
2865
2944
  const blogCategoryEntity = React9.useMemo(() => buildBlogCategoryEntity(), []);
2866
2945
  const quickActions = React9.useMemo(() => [
@@ -2871,15 +2950,16 @@ function MarketingPage({ config, provider: provider2, store: store3, contentProv
2871
2950
  { id: "channel-detail", render: ({ id }) => /* @__PURE__ */ jsx(ChannelDetailView, { channelId: id, onBack: () => navigate("channels") }) },
2872
2951
  { id: "campaigns", render: () => /* @__PURE__ */ jsx(CampaignsView, {}) },
2873
2952
  { id: "funnel", render: () => /* @__PURE__ */ jsx(FunnelView, {}) },
2953
+ { id: "cadences", render: () => /* @__PURE__ */ jsx(CadencesView, { surface: cadenceSurface }) },
2874
2954
  { id: "landing-pages", render: () => /* @__PURE__ */ jsx(LandingPagesView, {}) },
2875
2955
  { id: "content", render: () => /* @__PURE__ */ jsx(ContentView, { onOpenPost: (id) => navigate(`content-post:${id}`) }) },
2876
2956
  { id: "content-post", render: ({ id }) => /* @__PURE__ */ jsx(PostPage, { postId: id, onBack: () => navigate("content") }) },
2877
2957
  { id: "content-brief", render: () => /* @__PURE__ */ jsx(PlanBriefView, { onBack: () => navigate("content") }) },
2878
2958
  { id: "blog", render: () => /* @__PURE__ */ jsx(BlogAdminView, { navigate }) },
2879
- { id: "blog-new", render: () => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogPostEntity, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
2880
- { id: "blog-post", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogPostEntity, editId: id, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
2881
- { id: "blog-cat-new", render: () => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2882
- { id: "blog-cat", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, editId: id, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2959
+ { id: "blog-new", render: () => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogPostEntity, parentLabel: config.labels.blog ?? t("marketing.nav.blog"), onDone: () => navigate("blog") }) },
2960
+ { id: "blog-post", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogPostEntity, editId: id, parentLabel: config.labels.blog ?? t("marketing.nav.blog"), onDone: () => navigate("blog") }) },
2961
+ { id: "blog-cat-new", render: () => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, parentLabel: config.labels.blogCategories ?? t("marketing.nav.blogCategories"), onDone: () => navigate("blog") }) },
2962
+ { id: "blog-cat", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, editId: id, parentLabel: config.labels.blogCategories ?? t("marketing.nav.blogCategories"), onDone: () => navigate("blog") }) },
2883
2963
  { id: "overview", render: () => /* @__PURE__ */ jsx(OverviewView, {}) }
2884
2964
  ], "overview");
2885
2965
  return /* @__PURE__ */ jsx(MarketingContextProvider, { config, provider: provider2, store: store3, children: /* @__PURE__ */ jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsx(
@@ -3259,7 +3339,7 @@ var AGENCY = {
3259
3339
  { id: "referral", label: "Referral", icon: "Users", kind: "referral" },
3260
3340
  { id: "direct", label: "Direct", icon: "MousePointerClick", kind: "direct" }
3261
3341
  ],
3262
- modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
3342
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, blog: false, cadences: true, broadcasts: false, journeys: false }
3263
3343
  };
3264
3344
  var BEAUTY = {
3265
3345
  conversion: {
@@ -3277,7 +3357,7 @@ var BEAUTY = {
3277
3357
  { id: "whatsapp", label: "WhatsApp", icon: "MessageCircle", kind: "social" },
3278
3358
  { id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
3279
3359
  ],
3280
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, blog: false, broadcasts: false, journeys: false }
3360
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, blog: false, cadences: true, broadcasts: false, journeys: false }
3281
3361
  };
3282
3362
  var RESTO = {
3283
3363
  conversion: {
@@ -3295,7 +3375,7 @@ var RESTO = {
3295
3375
  { id: "instagram", label: "Instagram", icon: "Instagram", kind: "social" },
3296
3376
  { id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
3297
3377
  ],
3298
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
3378
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, blog: false, cadences: true, broadcasts: false, journeys: false }
3299
3379
  };
3300
3380
  var MARKETING_PRESETS = {
3301
3381
  agency: AGENCY,
@@ -3401,6 +3481,8 @@ function seedCampaigns(cfg) {
3401
3481
  name,
3402
3482
  channelId: ch.id,
3403
3483
  status,
3484
+ kind: "ad_spend",
3485
+ purpose: "promotion",
3404
3486
  start,
3405
3487
  end: status === "ended" ? new Date(baseDate - 2 * 864e5).toISOString() : void 0,
3406
3488
  spend: draft ? 0 : Math.round(b.spend * share),
@@ -3536,7 +3618,11 @@ function createMockMarketingProvider(cfg) {
3536
3618
  status: input.status,
3537
3619
  start: input.start,
3538
3620
  end: input.end,
3539
- spend: input.spend
3621
+ spend: input.spend,
3622
+ kind: input.kind ?? existing.kind,
3623
+ purpose: input.purpose ?? existing.purpose,
3624
+ segmentId: input.segmentId,
3625
+ templateKey: input.templateKey
3540
3626
  });
3541
3627
  return existing;
3542
3628
  }
@@ -3548,6 +3634,10 @@ function createMockMarketingProvider(cfg) {
3548
3634
  name: input.name,
3549
3635
  channelId: input.channelId,
3550
3636
  status: input.status,
3637
+ kind: input.kind ?? "ad_spend",
3638
+ purpose: input.purpose ?? "promotion",
3639
+ segmentId: input.segmentId,
3640
+ templateKey: input.templateKey,
3551
3641
  start: input.start,
3552
3642
  end: input.end,
3553
3643
  spend: input.spend,
@@ -3560,6 +3650,11 @@ function createMockMarketingProvider(cfg) {
3560
3650
  campaigns.unshift(created);
3561
3651
  return created;
3562
3652
  },
3653
+ // Sem cluster não há recorte para apontar: a tela cai no público por regra
3654
+ // própria em vez de mostrar um seletor vazio.
3655
+ async listAudienceSegments() {
3656
+ return [];
3657
+ },
3563
3658
  async deleteCampaign(id) {
3564
3659
  const idx = campaigns.findIndex((c) => c.id === id);
3565
3660
  if (idx >= 0) campaigns.splice(idx, 1);
@@ -3841,6 +3936,10 @@ function createSupabaseMarketingProvider(options) {
3841
3936
  name: row.name,
3842
3937
  channelId: row.channel_key,
3843
3938
  status: row.status,
3939
+ kind: row.kind ?? "ad_spend",
3940
+ purpose: row.purpose ?? "promotion",
3941
+ segmentId: (row.audience_query ?? {}).segment_id ?? void 0,
3942
+ templateKey: row.template_key ?? void 0,
3844
3943
  start: row.starts_at ?? row.created_at,
3845
3944
  end: row.ends_at ?? void 0,
3846
3945
  spend: Number(row.spend ?? 0),
@@ -3935,14 +4034,23 @@ function createSupabaseMarketingProvider(options) {
3935
4034
  },
3936
4035
  async saveCampaign(input) {
3937
4036
  const tenantId = requireTenantId();
4037
+ const kind = input.kind ?? "ad_spend";
3938
4038
  const payload = {
3939
4039
  name: input.name,
3940
4040
  channel_key: input.channelId,
3941
4041
  status: input.status,
3942
4042
  starts_at: input.start || null,
3943
4043
  ends_at: input.end || null,
3944
- spend: input.spend ?? 0
4044
+ spend: input.spend ?? 0,
4045
+ kind,
4046
+ purpose: input.purpose ?? "promotion"
3945
4047
  };
4048
+ if (kind === "outbound") {
4049
+ payload.audience_query = input.segmentId ? { segment_id: input.segmentId } : {};
4050
+ payload.template_key = input.templateKey ?? null;
4051
+ payload.key = input.id ? void 0 : `camp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
4052
+ if (payload.key === void 0) delete payload.key;
4053
+ }
3946
4054
  if (input.id) {
3947
4055
  const { data: data2, error: error2 } = await getClient().from(T.campaigns).update(payload).eq("id", input.id).select("*").single();
3948
4056
  if (error2) throw error2;
@@ -3954,6 +4062,18 @@ function createSupabaseMarketingProvider(options) {
3954
4062
  cache.clear();
3955
4063
  return campaignFromRow(data);
3956
4064
  },
4065
+ /**
4066
+ * Os recortes do CRM. Leitura direta de `plg_crm_segments` — a mesma
4067
+ * licença que este plugin já usa para o catálogo de tags: quem é DONO da
4068
+ * tabela é o CRM, o Marketing só lê. Sem o CRM montado a tabela não
4069
+ * existe, e a resposta é uma lista vazia em vez de um erro na tela.
4070
+ */
4071
+ async listAudienceSegments() {
4072
+ const tenantId = requireTenantId();
4073
+ const { data, error } = await getClient().from("plg_crm_segments").select("id, name, description").eq("tenant_id", tenantId).eq("is_active", true).order("sort_order");
4074
+ if (error) return [];
4075
+ return (data ?? []).map((r) => ({ id: r.id, name: r.name, description: r.description ?? void 0 }));
4076
+ },
3957
4077
  async deleteCampaign(id) {
3958
4078
  const { error } = await getClient().from(T.campaigns).delete().eq("id", id);
3959
4079
  if (error) throw error;
@@ -6001,10 +6121,269 @@ DROP FUNCTION IF EXISTS public.crm_capture_page_load(text);
6001
6121
  DROP FUNCTION IF EXISTS public.crm_capture_window_check(text, timestamp with time zone);
6002
6122
  DROP FUNCTION IF EXISTS public.trg_crm_capture_page_key();
6003
6123
  `;
6124
+ var MIGRATION_004_A_CAMPANHA_APONTA_PARA_UM_RECORTE = `-- 004_a_campanha_aponta_para_um_recorte.sql
6125
+ --
6126
+ -- A CAMPANHA N\xC3O CONSEGUIA APONTAR PARA UM SEGMENTO, QUE \xC9 PARA O QUE O
6127
+ -- SEGMENTO FOI FEITO.
6128
+ --
6129
+ -- O coment\xE1rio da 004 do CRM diz textualmente por que \`plg_crm_segments\`
6130
+ -- existe: "a campanha aponta para ele em vez de carregar a pr\xF3pria c\xF3pia do
6131
+ -- filtro". S\xF3 que \`marketing_resolve_audience\` nunca aprendeu a ler um
6132
+ -- segmento \u2014 ela resolve \`audience_query = {source_view, where[]}\`, a c\xF3pia do
6133
+ -- filtro. Ent\xE3o havia duas verdades sobre quem \xE9 "cliente ativo": a da tela de
6134
+ -- segmentos e a da campanha, e elas divergiam no dia em que algu\xE9m corrigisse
6135
+ -- uma.
6136
+ --
6137
+ -- E O VOCABUL\xC1RIO DELA \xC9 MENOR, que \xE9 o problema mais grave dos dois. A
6138
+ -- campanha aceita seis operadores de compara\xE7\xE3o e mais nada: sem \`within_days\`,
6139
+ -- sem \`has_tag\`, sem \`is_null\`. Copiar a regra do segmento para dentro da
6140
+ -- campanha, al\xE9m de duplicar, QUEBRA \u2014 "comprou nos \xFAltimos 30 dias" n\xE3o tem
6141
+ -- como ser expresso, e "anivers\xE1rio este m\xEAs" tamb\xE9m n\xE3o.
6142
+ --
6143
+ -- \u2500\u2500 as duas decis\xF5es \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
6144
+ --
6145
+ -- 1. A CAMPANHA GUARDA UM PONTEIRO, N\xC3O UMA C\xD3PIA. \`{"segment_id": "..."}\` no
6146
+ -- \`audience_query\`. Editar o recorte muda para quem a campanha vai \u2014 que \xE9
6147
+ -- a expectativa de quem acabou de editar o recorte. Quem quiser a foto
6148
+ -- congelada usa um segmento do tipo \`list\`, que \xE9 exatamente o que ele \xE9.
6149
+ --
6150
+ -- 2. O CONJUNTO DE OPERADORES PASSA A SER O MESMO DO CRM, ponteiro ou n\xE3o.
6151
+ -- Uma campanha que aponta direto (sem segmento) ganha \`within_days\`,
6152
+ -- \`has_tag\` e companhia pela mesma tabela de CASE. Duas linguagens de
6153
+ -- filtro no mesmo produto \xE9 como se chega a duas respostas diferentes para
6154
+ -- a mesma pergunta.
6155
+ --
6156
+ -- O QUE N\xC3O MUDA, E \xC9 O MAIS IMPORTANTE: o filtro de consentimento continua na
6157
+ -- sa\xEDda. \`marketing_has_consent(tenant, pessoa, purpose)\` \xE9 a \xFAltima cl\xE1usula,
6158
+ -- sempre \u2014 um p\xFAblico resolvido por segmento n\xE3o atravessa o opt-out s\xF3 porque
6159
+ -- veio do CRM. A \`purpose\` da campanha \xE9 o que d\xE1 sentido a ele, e \xE9 por isso
6160
+ -- que "criar campanha com uma finalidade espec\xEDfica" \xE9 uma funcionalidade e
6161
+ -- n\xE3o um campo de texto: a finalidade DECIDE quem pode receber.
6162
+ --
6163
+ -- Sem depend\xEAncia dura de plugin: se \`plg_crm_segments\` n\xE3o existe neste app,
6164
+ -- um \`segment_id\` \xE9 recusado com mensagem clara em vez de estourar 42P01.
6165
+
6166
+ SET check_function_bodies = false;
6167
+
6168
+ CREATE OR REPLACE FUNCTION public.marketing_resolve_audience(p_campaign_id uuid)
6169
+ RETURNS TABLE(person_id uuid)
6170
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
6171
+ SET search_path TO ''
6172
+ AS $_$
6173
+ DECLARE
6174
+ c public.plg_marketing_campaigns%ROWTYPE;
6175
+ v_source text;
6176
+ v_rules jsonb;
6177
+ v_where text := '';
6178
+ v_clause jsonb;
6179
+ v_col text;
6180
+ v_op text;
6181
+ v_kind char;
6182
+ v_seg_id uuid;
6183
+ v_seg record;
6184
+ BEGIN
6185
+ SELECT * INTO c FROM public.plg_marketing_campaigns WHERE id = p_campaign_id;
6186
+ IF c.id IS NULL THEN
6187
+ RAISE EXCEPTION 'marketing_resolve_audience: campaign % not found', p_campaign_id USING ERRCODE = 'P0002';
6188
+ END IF;
6189
+
6190
+ -- \u2500\u2500 o ponteiro \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
6191
+ IF c.audience_query ? 'segment_id' THEN
6192
+ IF to_regclass('public.plg_crm_segments') IS NULL THEN
6193
+ RAISE EXCEPTION 'marketing_resolve_audience: campanha aponta para um segmento, mas o CRM n\xE3o est\xE1 instalado neste app'
6194
+ USING ERRCODE = '42P01';
6195
+ END IF;
6196
+ BEGIN
6197
+ v_seg_id := (c.audience_query ->> 'segment_id')::uuid;
6198
+ EXCEPTION WHEN OTHERS THEN
6199
+ RAISE EXCEPTION 'marketing_resolve_audience: segment_id inv\xE1lido' USING ERRCODE = '22023';
6200
+ END;
6201
+
6202
+ -- Mesmo tenant, sempre. Um p\xFAblico que atravessasse inquilino seria a pior
6203
+ -- falha poss\xEDvel deste arquivo.
6204
+ SELECT s.source_view, s.rules, s.kind INTO v_seg
6205
+ FROM public.plg_crm_segments s
6206
+ WHERE s.id = v_seg_id AND s.tenant_id = c.tenant_id;
6207
+ IF NOT FOUND THEN
6208
+ RAISE EXCEPTION 'marketing_resolve_audience: segmento % n\xE3o \xE9 deste tenant', v_seg_id USING ERRCODE = 'P0002';
6209
+ END IF;
6210
+
6211
+ -- A lista escolhida a dedo \xC9 a resposta \u2014 e mesmo ela passa pelo consentimento.
6212
+ IF v_seg.kind = 'list' THEN
6213
+ RETURN QUERY
6214
+ SELECT m.person_id FROM public.plg_crm_segment_members m
6215
+ WHERE m.segment_id = v_seg_id AND m.tenant_id = c.tenant_id
6216
+ AND public.marketing_has_consent(c.tenant_id, m.person_id, c.purpose);
6217
+ RETURN;
6218
+ END IF;
6219
+
6220
+ v_source := coalesce(v_seg.source_view, 'people');
6221
+ v_rules := coalesce(v_seg.rules -> 'where', '[]'::jsonb);
6222
+ ELSE
6223
+ v_source := coalesce(c.audience_query ->> 'source_view', 'people');
6224
+ v_rules := coalesce(c.audience_query -> 'where', '[]'::jsonb);
6225
+ END IF;
6226
+
6227
+ IF v_source !~ '^[a-z_][a-z0-9_]*$' THEN
6228
+ RAISE EXCEPTION 'marketing_resolve_audience: % is not a bare relation name', v_source USING ERRCODE = '22023';
6229
+ END IF;
6230
+ SELECT cl.relkind INTO v_kind FROM pg_catalog.pg_class cl
6231
+ JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
6232
+ WHERE n.nspname = 'public' AND cl.relname = v_source;
6233
+ IF v_kind IS NULL THEN
6234
+ RAISE EXCEPTION 'marketing_resolve_audience: public.% does not exist', v_source USING ERRCODE = '42P01';
6235
+ END IF;
6236
+
6237
+ v_where := format('tenant_id = %L', c.tenant_id);
6238
+
6239
+ -- Unidade da campanha estreita o p\xFAblico, quando a leitura tem a coluna.
6240
+ IF c.unit_id IS NOT NULL AND EXISTS (
6241
+ SELECT 1 FROM information_schema.columns
6242
+ WHERE table_schema = 'public' AND table_name = v_source AND column_name = 'unit_id'
6243
+ ) THEN
6244
+ v_where := v_where || format(' AND unit_id = %L', c.unit_id);
6245
+ END IF;
6246
+
6247
+ -- \u2500\u2500 o mesmo CASE do CRM, operador por operador \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
6248
+ FOR v_clause IN SELECT * FROM jsonb_array_elements(v_rules) LOOP
6249
+ v_col := v_clause ->> 'column';
6250
+ v_op := coalesce(v_clause ->> 'op', '=');
6251
+
6252
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
6253
+ WHERE table_schema = 'public' AND table_name = v_source AND column_name = v_col) THEN
6254
+ RAISE EXCEPTION 'marketing_resolve_audience: public.% has no column %', v_source, v_col USING ERRCODE = '42703';
6255
+ END IF;
6256
+
6257
+ v_where := v_where || CASE
6258
+ WHEN v_op IN ('=', '<>', '>', '<', '>=', '<=') THEN
6259
+ format(' AND %I %s %L', v_col,
6260
+ CASE v_op WHEN '<>' THEN '<>' WHEN '>' THEN '>' WHEN '<' THEN '<'
6261
+ WHEN '>=' THEN '>=' WHEN '<=' THEN '<=' ELSE '=' END,
6262
+ v_clause ->> 'value')
6263
+
6264
+ WHEN v_op = 'ilike' THEN
6265
+ format(' AND %I ILIKE %L', v_col, '%' || coalesce(v_clause ->> 'value', '') || '%')
6266
+
6267
+ WHEN v_op IN ('in', 'not_in') THEN (
6268
+ SELECT format(' AND %I %s (%s)', v_col,
6269
+ CASE WHEN v_op = 'not_in' THEN 'NOT IN' ELSE 'IN' END,
6270
+ coalesce(string_agg(quote_literal(e), ','), 'NULL'))
6271
+ FROM jsonb_array_elements_text(coalesce(v_clause -> 'value', '[]'::jsonb)) AS e)
6272
+
6273
+ WHEN v_op = 'is_null' THEN format(' AND %I IS NULL', v_col)
6274
+ WHEN v_op = 'not_null' THEN format(' AND %I IS NOT NULL', v_col)
6275
+
6276
+ WHEN v_op = 'has_tag' THEN
6277
+ format(' AND %L = ANY(%I)', v_clause ->> 'value', v_col)
6278
+
6279
+ WHEN v_op IN ('within_days', 'older_than_days') THEN (
6280
+ SELECT format(' AND %I %s now() - make_interval(days => %s)', v_col,
6281
+ CASE WHEN v_op = 'within_days' THEN '>=' ELSE '<' END,
6282
+ (coalesce(v_clause ->> 'value', '0'))::integer))
6283
+
6284
+ WHEN v_op = 'birthday_within_days' THEN (
6285
+ SELECT format(
6286
+ ' AND %I IS NOT NULL AND ((EXTRACT(doy FROM %I::date)::int - EXTRACT(doy FROM now())::int + 366) %% 366) <= %s',
6287
+ v_col, v_col, (coalesce(v_clause ->> 'value', '0'))::integer))
6288
+
6289
+ ELSE
6290
+ -- Operador desconhecido ALARGA o p\xFAblico se for ignorado \u2014 e um p\xFAblico
6291
+ -- alargado em sil\xEAncio \xE9 a mensagem indo para quem n\xE3o devia.
6292
+ NULL
6293
+ END;
6294
+
6295
+ IF v_where IS NULL THEN
6296
+ RAISE EXCEPTION 'marketing_resolve_audience: operador % n\xE3o \xE9 reconhecido', v_op USING ERRCODE = '22023';
6297
+ END IF;
6298
+ END LOOP;
6299
+
6300
+ -- O consentimento \xE9 a \xDALTIMA cl\xE1usula, e continua sendo, venha o p\xFAblico de
6301
+ -- onde vier.
6302
+ RETURN QUERY EXECUTE format(
6303
+ 'SELECT s.id FROM public.%I s WHERE %s AND public.marketing_has_consent(%L, s.id, %L)',
6304
+ v_source, v_where, c.tenant_id, c.purpose);
6305
+ END $_$;
6306
+
6307
+ COMMENT ON FUNCTION public.marketing_resolve_audience(uuid) IS
6308
+ 'Quem a campanha alcan\xE7a. Aponta para um segmento do CRM (segment_id) ou carrega a pr\xF3pria regra; mesmo conjunto de operadores do CRM; consentimento sempre na sa\xEDda (004).';
6309
+ `;
6310
+ var MIGRATION_005_A_ATRIBUICAO_DO_LEAD_ATRAVESSA_A_ORIGEM_ATE_O_CANAL = `-- ---------------------------------------------------------------------------
6311
+ -- 005_a_atribuicao_do_lead_atravessa_a_origem_ate_o_canal.sql \u2014 os tr\xEAs n\xEDveis
6312
+ -- de atribui\xE7\xE3o num lugar s\xF3, e o conserto de uma tela que contava zero.
6313
+ --
6314
+ -- \u2500\u2500 por que este arquivo est\xE1 no marketing e n\xE3o no CRM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6315
+ --
6316
+ -- A view junta tr\xEAs tabelas de duas casas: \`plg_crm_lead_sources\` (CRM) com
6317
+ -- \`plg_marketing_channels\` e \`plg_marketing_campaigns\` (aqui). Na ordem da
6318
+ -- chain o CRM roda ANTES do marketing, ent\xE3o do lado de l\xE1 as tabelas deste
6319
+ -- plugin ainda n\xE3o existem. Do lado de c\xE1 as do CRM j\xE1 existem.
6320
+ --
6321
+ -- A regra que isso ensina: quem cria uma view que atravessa plugins \xE9 o \xDALTIMO
6322
+ -- dos seus dependentes. \`crm/038\` tentou o contr\xE1rio e o \`db-from-zero\` pegou
6323
+ -- na primeira passada.
6324
+ --
6325
+ -- \u2500\u2500 as tr\xEAs perguntas \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
6326
+ --
6327
+ -- CANAL (medium) COMO a gente se encontrou. "Evento presencial"
6328
+ -- ORIGEM (source) ONDE, especificamente. "Sal\xE3o Abrasel"
6329
+ -- CAMPANHA QUAL esfor\xE7o, com custo. "ABRASEL 2026"
6330
+ --
6331
+ -- Salesforce separa \`LeadSource\` de \`Campaign\`; HubSpot separa "Original
6332
+ -- source" do drill-down; Odoo e Mautic usam o trio UTM. Nenhum junta os tr\xEAs
6333
+ -- num campo s\xF3: "quanto custou este lead" s\xF3 existe na campanha, e "que tipo
6334
+ -- de esfor\xE7o traz gente melhor" s\xF3 existe no canal.
6335
+ --
6336
+ -- \u2500\u2500 o defeito que isto conserta \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
6337
+ --
6338
+ -- \`v_marketing_attribution\` lia \`metadata.sourceId\` e chamava aquilo de
6339
+ -- \`channel_raw\` \u2014 a ORIGEM ocupando o lugar do CANAL. Com isso a tela de
6340
+ -- Canais contava zero para todos, porque nenhum \`channel_key\` jamais bate com
6341
+ -- um uuid de origem. Esta view atravessa a origem at\xE9 o canal, que \xE9 o salto
6342
+ -- que faltava. A antiga fica: outras telas dependem da forma dela.
6343
+ -- ---------------------------------------------------------------------------
6344
+
6345
+ DROP VIEW IF EXISTS public.v_marketing_lead_attribution;
6346
+ CREATE VIEW public.v_marketing_lead_attribution
6347
+ WITH (security_invoker = true) AS
6348
+ SELECT p.id AS lead_id,
6349
+ p.tenant_id,
6350
+ p.created_at,
6351
+ s.id AS source_id,
6352
+ s.name AS source_name,
6353
+ -- O canal vem da ORIGEM; quando ela n\xE3o declara, o lead n\xE3o \xE9
6354
+ -- atribu\xEDdo a canal nenhum. Nulo \xE9 honesto: for\xE7ar 'direct' faria a
6355
+ -- tela afirmar uma proced\xEAncia que ningu\xE9m mediu.
6356
+ s.channel_key,
6357
+ ch.label AS channel_label,
6358
+ ch.kind AS channel_kind,
6359
+ (p.metadata ->> 'campaignId') AS campaign_id,
6360
+ mc.name AS campaign_name,
6361
+ mc.spend AS campaign_spend
6362
+ FROM public.people p
6363
+ LEFT JOIN public.plg_crm_lead_sources s
6364
+ ON s.tenant_id = p.tenant_id
6365
+ AND s.id::text = (p.metadata ->> 'sourceId')
6366
+ LEFT JOIN public.plg_marketing_channels ch
6367
+ ON ch.tenant_id = p.tenant_id
6368
+ AND ch.channel_key = s.channel_key
6369
+ LEFT JOIN public.plg_marketing_campaigns mc
6370
+ ON mc.tenant_id = p.tenant_id
6371
+ AND mc.id::text = (p.metadata ->> 'campaignId')
6372
+ WHERE p.kind = 'lead'
6373
+ AND p.merged_into_id IS NULL
6374
+ AND p.tenant_id IN (SELECT public.user_tenant_ids());
6375
+
6376
+ GRANT SELECT ON public.v_marketing_lead_attribution TO authenticated;
6377
+
6378
+ COMMENT ON VIEW public.v_marketing_lead_attribution IS
6379
+ 'Cada lead com seus tr\xEAs n\xEDveis: canal (COMO), origem (ONDE) e campanha (QUAL esfor\xE7o, com custo). A origem resolve o canal \u2014 o lead n\xE3o o carrega (marketing/005).';
6380
+ `;
6004
6381
  var MIGRATIONS = [
6005
6382
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
6006
6383
  { id: "001_the_landing_page_names_the_site", sql: MIGRATION_001_THE_LANDING_PAGE_NAMES_THE_SITE },
6007
- { id: "003_a_porta_publica_e_a_pagina", sql: MIGRATION_003_A_PORTA_PUBLICA_E_A_PAGINA }
6384
+ { id: "003_a_porta_publica_e_a_pagina", sql: MIGRATION_003_A_PORTA_PUBLICA_E_A_PAGINA },
6385
+ { id: "004_a_campanha_aponta_para_um_recorte", sql: MIGRATION_004_A_CAMPANHA_APONTA_PARA_UM_RECORTE },
6386
+ { id: "005_a_atribuicao_do_lead_atravessa_a_origem_ate_o_canal", sql: MIGRATION_005_A_ATRIBUICAO_DO_LEAD_ATRAVESSA_A_ORIGEM_ATE_O_CANAL }
6008
6387
  ];
6009
6388
 
6010
6389
  // src/integrations/ranklayer/settings.ts
@@ -6224,6 +6603,37 @@ var ranklayerConnectorDef = {
6224
6603
 
6225
6604
  // src/locales/en.ts
6226
6605
  var en = {
6606
+ "marketing.composer.kind": "Campaign type",
6607
+ "marketing.composer.kindSpend": "Ad spend",
6608
+ "marketing.composer.kindOutbound": "Outbound",
6609
+ "marketing.composer.kindSpendHint": "Records what was spent on a channel. Sends nothing \u2014 it is what makes cost per acquisition exist.",
6610
+ "marketing.composer.kindOutboundHint": "Sends a message to a segment of people. Runs on its own and respects each recipient's opt-in.",
6611
+ "marketing.composer.audience": "Who it reaches",
6612
+ "marketing.composer.audiencePlaceholder": "Pick a segment",
6613
+ "marketing.composer.noSegments": "No active segment. Create one in Settings \u2192 CRM \u2192 Segments and it shows up here.",
6614
+ "marketing.composer.purpose": "Purpose",
6615
+ "marketing.composer.template": "Message template",
6616
+ "marketing.composer.templateHint": "The template key in Communication. It defines the text that goes out.",
6617
+ "marketing.purpose.promotion": "Promotion",
6618
+ "marketing.purpose.newsletter": "Newsletter",
6619
+ "marketing.purpose.survey": "Survey",
6620
+ "marketing.purpose.transactional": "Notice about something they already have",
6621
+ "marketing.purpose.promotion.hint": "Only reaches people who opted in to promotions.",
6622
+ "marketing.purpose.newsletter.hint": "Only reaches people who opted in to the newsletter.",
6623
+ "marketing.purpose.survey.hint": "Only reaches people who opted in to surveys.",
6624
+ "marketing.purpose.transactional.hint": "Reaches everyone: talking about something they bought is not marketing. Still respects the channel block.",
6625
+ "marketing.nav.blogCategories": "Categories",
6626
+ "marketing.nav.overview": "Overview",
6627
+ "marketing.nav.channels": "Channels",
6628
+ "marketing.nav.campaigns": "Campaigns",
6629
+ "marketing.nav.funnel": "Funnel",
6630
+ "marketing.nav.landingPages": "Landing pages",
6631
+ "marketing.nav.content": "Content",
6632
+ "marketing.nav.blog": "Blog",
6633
+ "marketing.cadences.title": "Cadences",
6634
+ "marketing.cadences.subtitle": "The sequence: what goes out on day 0, day 2 and day 5 \u2014 and what takes somebody out of it",
6635
+ "marketing.cadences.missingTitle": "Cadences are not mounted in this app",
6636
+ "marketing.cadences.missingHint": "The screen comes from the Automations plugin. Install it and pass `cadenceSurface` to the Marketing plugin to plan the sequence here.",
6227
6637
  // metrics
6228
6638
  "marketing.metric.conversionRate": "Conversion rate",
6229
6639
  "marketing.metric.cpa": "Cost per acquisition",
@@ -6469,6 +6879,37 @@ var en = {
6469
6879
 
6470
6880
  // src/locales/pt-BR.ts
6471
6881
  var ptBR = {
6882
+ "marketing.composer.kind": "Tipo de campanha",
6883
+ "marketing.composer.kindSpend": "Investimento",
6884
+ "marketing.composer.kindOutbound": "Disparo",
6885
+ "marketing.composer.kindSpendHint": "Registra quanto foi gasto num canal. N\xE3o envia nada \u2014 \xE9 o que faz o custo por aquisi\xE7\xE3o existir.",
6886
+ "marketing.composer.kindOutboundHint": "Manda uma mensagem para um recorte de gente. Roda sozinha e respeita o opt-in de quem for receber.",
6887
+ "marketing.composer.audience": "Para quem",
6888
+ "marketing.composer.audiencePlaceholder": "Escolha um segmento",
6889
+ "marketing.composer.noSegments": "Nenhum segmento ativo. Crie um em Configura\xE7\xF5es \u2192 CRM \u2192 Segmentos e ele aparece aqui.",
6890
+ "marketing.composer.purpose": "Finalidade",
6891
+ "marketing.composer.template": "Modelo de mensagem",
6892
+ "marketing.composer.templateHint": "A chave do modelo em Comunica\xE7\xE3o. \xC9 ele que define o texto que sai.",
6893
+ "marketing.purpose.promotion": "Promo\xE7\xE3o",
6894
+ "marketing.purpose.newsletter": "Novidades",
6895
+ "marketing.purpose.survey": "Pesquisa",
6896
+ "marketing.purpose.transactional": "Aviso sobre algo que a pessoa j\xE1 tem",
6897
+ "marketing.purpose.promotion.hint": "S\xF3 alcan\xE7a quem aceitou receber promo\xE7\xE3o.",
6898
+ "marketing.purpose.newsletter.hint": "S\xF3 alcan\xE7a quem aceitou receber novidades.",
6899
+ "marketing.purpose.survey.hint": "S\xF3 alcan\xE7a quem aceitou responder pesquisas.",
6900
+ "marketing.purpose.transactional.hint": "Alcan\xE7a todo mundo: falar de algo que a pessoa j\xE1 comprou n\xE3o \xE9 marketing. Ainda respeita o bloqueio do canal.",
6901
+ "marketing.nav.blogCategories": "Categorias",
6902
+ "marketing.nav.overview": "Vis\xE3o geral",
6903
+ "marketing.nav.channels": "Canais",
6904
+ "marketing.nav.campaigns": "Campanhas",
6905
+ "marketing.nav.funnel": "Funil",
6906
+ "marketing.nav.landingPages": "P\xE1ginas de captura",
6907
+ "marketing.nav.content": "Conte\xFAdo",
6908
+ "marketing.nav.blog": "Blog",
6909
+ "marketing.cadences.title": "Cad\xEAncias",
6910
+ "marketing.cadences.subtitle": "A r\xE9gua: o que sai no dia 0, no dia 2 e no dia 5 \u2014 e o que faz algu\xE9m sair dela",
6911
+ "marketing.cadences.missingTitle": "Cad\xEAncias n\xE3o est\xE3o montadas neste app",
6912
+ "marketing.cadences.missingHint": "A tela vem do plugin de Automa\xE7\xF5es. Instale-o e passe `cadenceSurface` ao plugin de Marketing para planejar a r\xE9gua por aqui.",
6472
6913
  // metrics
6473
6914
  "marketing.metric.conversionRate": "Taxa de convers\xE3o",
6474
6915
  "marketing.metric.cpa": "Custo por aquisi\xE7\xE3o",
@@ -6721,16 +7162,13 @@ var marketingLocales = {
6721
7162
  // src/index.ts
6722
7163
  var DEFAULT_LABELS = {
6723
7164
  pageTitle: "Marketing",
6724
- pageSubtitle: "Acquisition & conversion performance",
6725
- overview: "Overview",
6726
- channels: "Channels",
6727
- campaigns: "Campaigns",
6728
- funnel: "Funnel",
7165
+ pageSubtitle: "Acquisition & conversion performance"
7166
+ };
7167
+ var FEATURE_FALLBACK = {
6729
7168
  landingPages: "Landing pages",
6730
7169
  content: "Content",
6731
7170
  blog: "Blog",
6732
- blogCategories: "Categorias",
6733
- settings: "Settings"
7171
+ cadences: "Cadences"
6734
7172
  };
6735
7173
  function resolveConfig(options) {
6736
7174
  const domain = options?.domain ?? "agency";
@@ -6771,7 +7209,7 @@ function createMarketingPlugin(options) {
6771
7209
  const PageComponent = () => React9.createElement(
6772
7210
  MarketingContextProvider,
6773
7211
  { config, provider: provider2, store: store3 },
6774
- React9.createElement(MarketingPage, { config, provider: provider2, store: store3, contentProvider, contentStore })
7212
+ React9.createElement(MarketingPage, { config, provider: provider2, store: store3, contentProvider, contentStore, cadenceSurface: options?.cadenceSurface })
6775
7213
  );
6776
7214
  PageComponent.displayName = "MarketingPage";
6777
7215
  const marketingRegistries = [
@@ -6806,9 +7244,10 @@ function createMarketingPlugin(options) {
6806
7244
  dependencies: [],
6807
7245
  declaredFeatures: [
6808
7246
  { id: "marketing", label: config.labels.pageTitle, group: "Engage" },
6809
- ...config.modules.landingPages ? [{ id: "marketing.landing-pages", label: config.labels.landingPages, group: "Engage" }] : [],
6810
- ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : [],
6811
- ...config.modules.blog ? [{ id: "marketing.blog", label: config.labels.blog, group: "Engage" }] : []
7247
+ ...config.modules.landingPages ? [{ id: "marketing.landing-pages", label: config.labels.landingPages ?? FEATURE_FALLBACK.landingPages, group: "Engage" }] : [],
7248
+ ...config.modules.cadences && options?.cadenceSurface ? [{ id: "marketing.cadences", label: config.labels.cadences ?? FEATURE_FALLBACK.cadences, group: "Engage" }] : [],
7249
+ ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content ?? FEATURE_FALLBACK.content, group: "Engage" }] : [],
7250
+ ...config.modules.blog ? [{ id: "marketing.blog", label: config.labels.blog ?? FEATURE_FALLBACK.blog, group: "Engage" }] : []
6812
7251
  ],
6813
7252
  declaredLimits: [
6814
7253
  ...config.modules.campaigns ? [{ key: "campaigns_active", label: "Active campaigns", table: T.campaigns }] : [],