@fayz-ai/plugin-marketing 0.9.0-next.0 → 0.9.0

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/data/mock.d.ts.map +1 -1
  2. package/dist/data/supabase.d.ts +6 -0
  3. package/dist/data/supabase.d.ts.map +1 -1
  4. package/dist/data/tables.d.ts +15 -0
  5. package/dist/data/tables.d.ts.map +1 -1
  6. package/dist/data/types.d.ts +43 -2
  7. package/dist/data/types.d.ts.map +1 -1
  8. package/dist/index.d.ts +8 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +782 -104
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
  13. package/dist/locales/en.d.ts.map +1 -1
  14. package/dist/locales/pt-BR.d.ts.map +1 -1
  15. package/dist/migrations/index.d.ts +1 -0
  16. package/dist/migrations/index.d.ts.map +1 -1
  17. package/dist/registries.d.ts +3 -1
  18. package/dist/registries.d.ts.map +1 -1
  19. package/dist/store.d.ts +9 -2
  20. package/dist/store.d.ts.map +1 -1
  21. package/dist/types.d.ts +41 -5
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/views/LandingPageComposer.d.ts +8 -0
  24. package/dist/views/LandingPageComposer.d.ts.map +1 -0
  25. package/dist/views/LandingPagesView.d.ts.map +1 -1
  26. package/package.json +7 -7
  27. package/src/data/mock.ts +83 -8
  28. package/src/data/supabase.ts +215 -3
  29. package/src/data/tables.ts +17 -0
  30. package/src/data/types.ts +41 -1
  31. package/src/index.ts +12 -2
  32. package/src/integrations/ranklayer/connectorDef.tsx +4 -0
  33. package/src/locales/en.ts +24 -1
  34. package/src/locales/pt-BR.ts +24 -1
  35. package/src/migrations/008_landing_pages.sql +112 -0
  36. package/src/migrations/index.ts +116 -1
  37. package/src/registries.ts +3 -1
  38. package/src/store.ts +26 -3
  39. package/src/types.ts +44 -5
  40. package/src/views/LandingPageComposer.tsx +201 -0
  41. package/src/views/LandingPagesView.tsx +145 -22
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import React8, { useState, useEffect, useMemo, useCallback } from 'react';
1
+ import React9, { useState, useEffect, useMemo, useCallback } from 'react';
2
2
  import { getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, useTranslation, useDataChanged, resolveDataProvider } from '@fayz-ai/core';
3
3
  import { createPluginContext, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, PermissionGate, useLimitGuard, useOrganizationStore, CrudFormPage, invalidateLimit, CrudListView } from '@fayz-ai/saas';
4
- import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, SegmentedControl, DashboardCanvas, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, SelectSeparator, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, Checkbox, toast, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
4
+ import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, SegmentedControl, DashboardCanvas, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, Badge, SelectSeparator, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, 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 { Sparkles, History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, MousePointerClick, Layers, NotebookPen, ArrowUpRight, ArrowDownRight, Settings2, Columns3, CalendarDays, List, ChevronLeft, Loader2, ImagePlus, X, Bike, Utensils, DoorOpen, MessageCircle, Instagram, Mail, Globe, Megaphone, Search, Link2, AtSign, Pin, Twitter, Linkedin, Facebook, Youtube, Music2, Repeat2, Heart, BarChart2, Share2, ThumbsUp, Send, MoreHorizontal, Bookmark, GripVertical, Image, Trophy } from 'lucide-react';
6
+ import { Sparkles, History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, ExternalLink, Pencil, 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';
7
7
  import { createStore } from 'zustand/vanilla';
8
8
 
9
9
  // src/index.ts
@@ -14,7 +14,7 @@ var useMarketingProvider = ctx.useProvider;
14
14
  var useMarketingStore = ctx.useStore;
15
15
  function useChannelLookup() {
16
16
  const { channels } = useMarketingConfig();
17
- return React8.useMemo(() => new Map(channels.map((c) => [c.id, c])), [channels]);
17
+ return React9.useMemo(() => new Map(channels.map((c) => [c.id, c])), [channels]);
18
18
  }
19
19
  function OverviewView() {
20
20
  const setRange = useMarketingStore((s) => s.setRange);
@@ -174,7 +174,7 @@ function ChannelsView({ onOpen }) {
174
174
  const { conversion, currency } = useMarketingConfig();
175
175
  const channels = useMarketingStore((s) => s.channels);
176
176
  const sorted = [...channels].sort((a, b) => b.conversions - a.conversions);
177
- const columns = React8.useMemo(() => [
177
+ const columns = React9.useMemo(() => [
178
178
  { accessorKey: "channelId", header: t("marketing.col.channel"), cell: ({ getValue }) => /* @__PURE__ */ jsx(ChannelCell, { channelId: getValue() }) },
179
179
  { accessorKey: "reach", header: t("marketing.col.reach"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
180
180
  { accessorKey: "leads", header: t("marketing.col.leads"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
@@ -214,13 +214,13 @@ function ChannelDetailView({ channelId, onBack }) {
214
214
  const channels = useMarketingStore((s) => s.channels);
215
215
  const lookup = useChannelLookup();
216
216
  const provider2 = useMarketingProvider();
217
- const [campaigns, setCampaigns] = React8.useState([]);
218
- React8.useEffect(() => {
217
+ const [campaigns, setCampaigns] = React9.useState([]);
218
+ React9.useEffect(() => {
219
219
  void provider2.listCampaigns({ range, channelId }).then(setCampaigns);
220
220
  }, [provider2, range, channelId]);
221
221
  const perf = channels.find((c) => c.channelId === channelId);
222
222
  const channel = lookup.get(channelId);
223
- const columns = React8.useMemo(() => [
223
+ const columns = React9.useMemo(() => [
224
224
  { accessorKey: "name", header: t("marketing.col.campaign"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: getValue() }) },
225
225
  { accessorKey: "conversions", header: conversion.labelPlural, cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
226
226
  { id: "cvr", accessorFn: (c) => c.reach > 0 ? c.conversions / c.reach * 100 : 0, header: t("marketing.col.cvr"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatPercent(getValue()) }) },
@@ -257,12 +257,12 @@ function CampaignComposer({ open, onClose }) {
257
257
  const t = useTranslation();
258
258
  const { channels } = useMarketingConfig();
259
259
  const saveCampaign = useMarketingStore((s) => s.saveCampaign);
260
- const [name, setName] = React8.useState("");
261
- const [channelId, setChannelId] = React8.useState(channels[0]?.id ?? "");
262
- const [status, setStatus] = React8.useState("active");
263
- const [spend, setSpend] = React8.useState("");
264
- const [saving, setSaving] = React8.useState(false);
265
- React8.useEffect(() => {
260
+ const [name, setName] = React9.useState("");
261
+ const [channelId, setChannelId] = React9.useState(channels[0]?.id ?? "");
262
+ const [status, setStatus] = React9.useState("active");
263
+ const [spend, setSpend] = React9.useState("");
264
+ const [saving, setSaving] = React9.useState(false);
265
+ React9.useEffect(() => {
266
266
  if (open) {
267
267
  setName("");
268
268
  setChannelId(channels[0]?.id ?? "");
@@ -324,8 +324,8 @@ function CampaignsView() {
324
324
  const { conversion, currency } = useMarketingConfig();
325
325
  const campaigns = useMarketingStore((s) => s.campaigns);
326
326
  const deleteCampaign = useMarketingStore((s) => s.deleteCampaign);
327
- const [composerOpen, setComposerOpen] = React8.useState(false);
328
- const columns = React8.useMemo(() => [
327
+ const [composerOpen, setComposerOpen] = React9.useState(false);
328
+ const columns = React9.useMemo(() => [
329
329
  { accessorKey: "name", header: t("marketing.col.campaign"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: getValue() }) },
330
330
  { accessorKey: "channelId", header: t("marketing.col.channel"), cell: ({ getValue }) => /* @__PURE__ */ jsx(ChannelCell, { channelId: getValue() }) },
331
331
  { accessorKey: "reach", header: t("marketing.col.reach"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
@@ -416,38 +416,326 @@ function FunnelView() {
416
416
  ] })
417
417
  ] });
418
418
  }
419
- var TYPE_STYLE = {
420
- Funnel: "bg-indigo-100 text-indigo-700",
421
- "Landing page": "bg-pink-100 text-pink-700",
422
- Website: "bg-sky-100 text-sky-700"
423
- };
419
+ function slugify(value) {
420
+ return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9:]+/g, "-").replace(/^-+|-+$/g, "");
421
+ }
422
+ function LandingPageComposer({
423
+ open,
424
+ page,
425
+ onClose
426
+ }) {
427
+ const t = useTranslation();
428
+ const sites = useMarketingStore((s) => s.sites);
429
+ const leadTags = useMarketingStore((s) => s.leadTags);
430
+ const saveLandingPage = useMarketingStore((s) => s.saveLandingPage);
431
+ const [siteId, setSiteId] = React9.useState("");
432
+ const [name, setName] = React9.useState("");
433
+ const [route, setRoute] = React9.useState("");
434
+ const [status, setStatus] = React9.useState("live");
435
+ const [prompt, setPrompt] = React9.useState("");
436
+ const [tags, setTags] = React9.useState([]);
437
+ const [newTag, setNewTag] = React9.useState("");
438
+ const [saving, setSaving] = React9.useState(false);
439
+ React9.useEffect(() => {
440
+ if (!open) return;
441
+ setSiteId(page?.siteId ?? sites[0]?.id ?? "");
442
+ setName(page?.name ?? "");
443
+ setRoute(page?.route ?? "");
444
+ setStatus(page?.status ?? "live");
445
+ setPrompt(page?.prompt ?? "");
446
+ setTags(page?.leadTags ?? []);
447
+ setNewTag("");
448
+ }, [open, page, sites]);
449
+ const site = sites.find((s) => s.id === siteId);
450
+ const previewPath = route.trim() ? route.trim().startsWith("/") ? route.trim() : `/${route.trim()}` : "";
451
+ const options = React9.useMemo(() => {
452
+ const byslug = new Map(leadTags.map((tag) => [tag.slug, tag.name]));
453
+ for (const slug of tags) if (!byslug.has(slug)) byslug.set(slug, slug);
454
+ return [...byslug].map(([slug, label]) => ({ slug, label }));
455
+ }, [leadTags, tags]);
456
+ function toggleTag(slug) {
457
+ setTags((current) => current.includes(slug) ? current.filter((s) => s !== slug) : [...current, slug]);
458
+ }
459
+ function addTag() {
460
+ const slug = slugify(newTag);
461
+ if (!slug) return;
462
+ setTags((current) => current.includes(slug) ? current : [...current, slug]);
463
+ setNewTag("");
464
+ }
465
+ async function handleSave() {
466
+ if (!siteId || !name.trim() || !route.trim()) return;
467
+ setSaving(true);
468
+ try {
469
+ await saveLandingPage({
470
+ id: page?.id,
471
+ siteId,
472
+ name: name.trim(),
473
+ route: route.trim(),
474
+ leadTags: tags,
475
+ status,
476
+ prompt: status === "planned" ? prompt.trim() || void 0 : void 0
477
+ });
478
+ onClose();
479
+ } finally {
480
+ setSaving(false);
481
+ }
482
+ }
483
+ return /* @__PURE__ */ jsx(Modal, { open, onOpenChange: (o) => {
484
+ if (!o) onClose();
485
+ }, children: /* @__PURE__ */ jsxs(ModalContent, { size: "md", children: [
486
+ /* @__PURE__ */ jsx(ModalHeader, { children: /* @__PURE__ */ jsx(ModalTitle, { children: page ? t("marketing.landing.edit") : t("marketing.landing.new") }) }),
487
+ /* @__PURE__ */ jsxs(ModalBody, { className: "space-y-4", children: [
488
+ /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
489
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.landing.site") }),
490
+ /* @__PURE__ */ jsxs(Select, { value: siteId, onValueChange: setSiteId, children: [
491
+ /* @__PURE__ */ jsx(SelectTrigger, { children: /* @__PURE__ */ jsx(SelectValue, { placeholder: t("marketing.landing.sitePlaceholder") }) }),
492
+ /* @__PURE__ */ jsx(SelectContent, { children: sites.map((s) => /* @__PURE__ */ jsx(SelectItem, { value: s.id, children: s.url ? `${s.name} \xB7 ${s.url.replace(/^https?:\/\//, "")}` : s.name }, s.id)) })
493
+ ] })
494
+ ] }),
495
+ /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
496
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.landing.name") }),
497
+ /* @__PURE__ */ jsx(Input, { value: name, onChange: (e) => setName(e.target.value), placeholder: t("marketing.landing.namePlaceholder") })
498
+ ] }),
499
+ /* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
500
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.landing.route") }),
501
+ /* @__PURE__ */ jsx(Input, { value: route, onChange: (e) => setRoute(e.target.value), placeholder: "/lp-capilar" }),
502
+ site?.url && previewPath ? /* @__PURE__ */ jsxs("span", { className: "block text-xs text-muted-foreground", children: [
503
+ site.url.replace(/\/+$/, ""),
504
+ previewPath
505
+ ] }) : null
506
+ ] }),
507
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
508
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.landing.status") }),
509
+ /* @__PURE__ */ jsx(
510
+ SegmentedControl,
511
+ {
512
+ value: status,
513
+ onChange: (v) => setStatus(v),
514
+ options: [
515
+ { value: "live", label: t("marketing.landing.statusLive") },
516
+ { value: "planned", label: t("marketing.landing.statusPlanned") }
517
+ ]
518
+ }
519
+ )
520
+ ] }),
521
+ status === "planned" ? /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
522
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
523
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.landing.prompt") }),
524
+ site?.editorUrl ? /* @__PURE__ */ jsxs(
525
+ "button",
526
+ {
527
+ type: "button",
528
+ onClick: () => window.open(site.editorUrl, "_blank", "noopener,noreferrer"),
529
+ className: "inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline",
530
+ children: [
531
+ /* @__PURE__ */ jsx(Sparkles, { className: "h-3.5 w-3.5" }),
532
+ " ",
533
+ t("marketing.landing.buildWithFayz")
534
+ ]
535
+ }
536
+ ) : null
537
+ ] }),
538
+ /* @__PURE__ */ jsx(
539
+ "textarea",
540
+ {
541
+ value: prompt,
542
+ onChange: (e) => setPrompt(e.target.value),
543
+ rows: 3,
544
+ placeholder: t("marketing.landing.promptPlaceholder"),
545
+ className: "w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
546
+ }
547
+ )
548
+ ] }) : null,
549
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
550
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.landing.leadTag") }),
551
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-1.5 rounded-md border border-border bg-background px-2 py-2 focus-within:ring-2 focus-within:ring-ring", children: [
552
+ options.map((opt) => {
553
+ const active = tags.includes(opt.slug);
554
+ return /* @__PURE__ */ jsx(
555
+ Badge,
556
+ {
557
+ variant: active ? "default" : "outline",
558
+ className: "cursor-pointer text-xs",
559
+ onClick: () => toggleTag(opt.slug),
560
+ children: opt.label
561
+ },
562
+ opt.slug
563
+ );
564
+ }),
565
+ /* @__PURE__ */ jsx(
566
+ "input",
567
+ {
568
+ value: newTag,
569
+ onChange: (e) => setNewTag(e.target.value),
570
+ onKeyDown: (e) => {
571
+ if (e.key === "Enter") {
572
+ e.preventDefault();
573
+ addTag();
574
+ }
575
+ },
576
+ onBlur: addTag,
577
+ placeholder: t("marketing.landing.leadTagPlaceholder"),
578
+ className: "min-w-[8rem] flex-1 border-0 bg-transparent px-1 text-xs text-foreground outline-none placeholder:text-muted-foreground"
579
+ }
580
+ )
581
+ ] }),
582
+ /* @__PURE__ */ jsx("span", { className: "block text-xs text-muted-foreground", children: t("marketing.landing.leadTagHint") })
583
+ ] })
584
+ ] }),
585
+ /* @__PURE__ */ jsxs(ModalFooter, { children: [
586
+ /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: onClose, children: t("marketing.composer.cancel") }),
587
+ /* @__PURE__ */ jsx(Button, { onClick: handleSave, disabled: saving || !siteId || !name.trim() || !route.trim(), children: page ? t("marketing.landing.save") : t("marketing.landing.create") })
588
+ ] })
589
+ ] }) });
590
+ }
424
591
  function LandingPagesView() {
425
592
  const t = useTranslation();
426
593
  const pages = useMarketingStore((s) => s.landingPages);
427
- const totalVisits = pages.reduce((s, p) => s + p.visits, 0);
428
- const totalConv = pages.reduce((s, p) => s + p.conversions, 0);
429
- const avgCvr = totalVisits > 0 ? totalConv / totalVisits * 100 : 0;
430
- const columns = React8.useMemo(() => [
431
- { accessorKey: "name", header: t("marketing.col.page"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: getValue() }) },
432
- { accessorKey: "type", header: t("marketing.col.type"), cell: ({ getValue }) => {
433
- const v = getValue();
434
- return /* @__PURE__ */ jsx("span", { className: `rounded-full px-2 py-0.5 text-xs font-medium ${TYPE_STYLE[v] ?? "bg-muted text-muted-foreground"}`, children: v });
435
- } },
436
- { accessorKey: "visits", header: t("marketing.col.visits"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
437
- { accessorKey: "conversions", header: t("marketing.col.conversions"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: formatNumber(getValue()) }) },
438
- { accessorKey: "cvr", header: t("marketing.col.cvr"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatPercent(getValue()) }) }
439
- ], [t]);
594
+ const deleteLandingPage = useMarketingStore((s) => s.deleteLandingPage);
595
+ const [editing, setEditing] = React9.useState(null);
596
+ const [composerOpen, setComposerOpen] = React9.useState(false);
597
+ const measured = pages.filter((p) => p.visits !== null);
598
+ const totalVisits = measured.reduce((s, p) => s + (p.visits ?? 0), 0);
599
+ const totalLeads = pages.reduce((s, p) => s + p.conversions, 0);
600
+ function openNew() {
601
+ setEditing(null);
602
+ setComposerOpen(true);
603
+ }
604
+ function openEdit(page) {
605
+ setEditing(page);
606
+ setComposerOpen(true);
607
+ }
608
+ const columns = React9.useMemo(() => [
609
+ {
610
+ accessorKey: "name",
611
+ header: t("marketing.col.page"),
612
+ cell: ({ row }) => /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
613
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1.5 truncate font-medium text-foreground", children: [
614
+ row.original.name,
615
+ row.original.status === "planned" ? /* @__PURE__ */ jsx("span", { className: "rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-800", children: t("marketing.landing.statusPlanned") }) : null
616
+ ] }),
617
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-xs text-muted-foreground", children: row.original.siteName })
618
+ ] })
619
+ },
620
+ {
621
+ accessorKey: "route",
622
+ header: t("marketing.col.route"),
623
+ cell: ({ row }) => /* @__PURE__ */ jsxs(
624
+ "a",
625
+ {
626
+ href: row.original.url,
627
+ target: "_blank",
628
+ rel: "noreferrer",
629
+ onClick: (e) => e.stopPropagation(),
630
+ className: "inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground hover:underline",
631
+ children: [
632
+ row.original.route,
633
+ /* @__PURE__ */ jsx(ExternalLink, { className: "h-3 w-3" })
634
+ ]
635
+ }
636
+ )
637
+ },
638
+ {
639
+ accessorKey: "leadTags",
640
+ header: t("marketing.col.leadTag"),
641
+ cell: ({ getValue }) => {
642
+ const tags = getValue() ?? [];
643
+ if (!tags.length) return /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" });
644
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: tags.map((tag) => /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground", children: tag }, tag)) });
645
+ }
646
+ },
647
+ {
648
+ accessorKey: "visits",
649
+ header: t("marketing.col.visits"),
650
+ cell: ({ getValue }) => {
651
+ const visits = getValue();
652
+ return visits === null ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", title: t("marketing.landing.visitsPending"), children: "\u2014" }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(visits) });
653
+ }
654
+ },
655
+ {
656
+ accessorKey: "conversions",
657
+ header: t("marketing.col.leads"),
658
+ cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: formatNumber(getValue()) })
659
+ },
660
+ {
661
+ accessorKey: "cvr",
662
+ header: t("marketing.col.cvr"),
663
+ cell: ({ getValue }) => {
664
+ const cvr = getValue();
665
+ return cvr === null ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatPercent(cvr) });
666
+ }
667
+ },
668
+ {
669
+ id: "actions",
670
+ header: "",
671
+ enableSorting: false,
672
+ cell: ({ row }) => /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-end gap-1", children: [
673
+ row.original.status === "planned" && row.original.editorUrl ? /* @__PURE__ */ jsx(
674
+ "button",
675
+ {
676
+ onClick: (e) => {
677
+ e.stopPropagation();
678
+ window.open(row.original.editorUrl, "_blank", "noopener,noreferrer");
679
+ },
680
+ className: "rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
681
+ title: t("marketing.landing.buildWithFayz"),
682
+ children: /* @__PURE__ */ jsx(Sparkles, { className: "h-4 w-4" })
683
+ }
684
+ ) : null,
685
+ /* @__PURE__ */ jsx(
686
+ "button",
687
+ {
688
+ onClick: (e) => {
689
+ e.stopPropagation();
690
+ openEdit(row.original);
691
+ },
692
+ className: "rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
693
+ title: t("marketing.landing.edit"),
694
+ children: /* @__PURE__ */ jsx(Pencil, { className: "h-4 w-4" })
695
+ }
696
+ ),
697
+ /* @__PURE__ */ jsx(
698
+ "button",
699
+ {
700
+ onClick: (e) => {
701
+ e.stopPropagation();
702
+ void deleteLandingPage(row.original.id);
703
+ },
704
+ className: "rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive",
705
+ title: t("marketing.landing.delete"),
706
+ children: /* @__PURE__ */ jsx(Trash2, { className: "h-4 w-4" })
707
+ }
708
+ )
709
+ ] })
710
+ }
711
+ ], [t, deleteLandingPage]);
440
712
  return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-6xl space-y-4", children: [
441
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
442
- /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("marketing.landing.subtitle") }),
443
- /* @__PURE__ */ jsx(RangeTabs, {})
713
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-end gap-2", children: [
714
+ /* @__PURE__ */ jsx(RangeTabs, {}),
715
+ /* @__PURE__ */ jsx(PermissionGate, { feature: "marketing", action: "create", children: /* @__PURE__ */ jsxs(Button, { onClick: openNew, children: [
716
+ /* @__PURE__ */ jsx(Plus, { className: "mr-1.5 h-4 w-4" }),
717
+ " ",
718
+ t("marketing.landing.new")
719
+ ] }) })
444
720
  ] }),
445
721
  /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-3 gap-4", children: [
446
- /* @__PURE__ */ jsx(KpiCard, { icon: MousePointerClick, label: t("marketing.metric.totalVisits"), value: formatNumber(totalVisits) }),
447
722
  /* @__PURE__ */ jsx(KpiCard, { icon: Layers, label: t("marketing.metric.pages"), value: String(pages.length) }),
448
- /* @__PURE__ */ jsx(KpiCard, { icon: Percent, label: t("marketing.metric.avgConversion"), value: formatPercent(avgCvr) })
723
+ /* @__PURE__ */ jsx(
724
+ KpiCard,
725
+ {
726
+ icon: MousePointerClick,
727
+ label: t("marketing.metric.totalVisits"),
728
+ value: measured.length > 0 ? formatNumber(totalVisits) : "\u2014"
729
+ }
730
+ ),
731
+ /* @__PURE__ */ jsx(KpiCard, { icon: Users, label: t("marketing.metric.leads"), value: formatNumber(totalLeads) })
449
732
  ] }),
450
- /* @__PURE__ */ jsx(DataTable, { columns, data: pages, emptyMessage: t("marketing.landing.subtitle") })
733
+ /* @__PURE__ */ jsx(DataTable, { columns, data: pages, emptyMessage: t("marketing.landing.empty") }),
734
+ measured.length < pages.length ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("marketing.landing.visitsPending") }) : null,
735
+ /* @__PURE__ */ jsx(LandingPageComposer, { open: composerOpen, page: editing, onClose: () => {
736
+ setComposerOpen(false);
737
+ setEditing(null);
738
+ } })
451
739
  ] });
452
740
  }
453
741
  var ctx2 = createPluginContext("ContentPlanner");
@@ -558,9 +846,9 @@ function Field({ label, children }) {
558
846
  function useBriefDraft() {
559
847
  const plan = useContentPlannerStore((s) => s.activePlan);
560
848
  const savePlan = useContentPlannerStore((s) => s.savePlan);
561
- const [draft, setDraft] = React8.useState(plan ? toDraft(plan) : null);
562
- const [saving, setSaving] = React8.useState(false);
563
- React8.useEffect(() => {
849
+ const [draft, setDraft] = React9.useState(plan ? toDraft(plan) : null);
850
+ const [saving, setSaving] = React9.useState(false);
851
+ React9.useEffect(() => {
564
852
  if (plan) setDraft(toDraft(plan));
565
853
  }, [plan?.id]);
566
854
  const patch = (partial) => setDraft((d) => d ? { ...d, ...partial } : d);
@@ -588,7 +876,7 @@ function useBriefDraft() {
588
876
  }
589
877
  function BriefFields({ draft, patch }) {
590
878
  const t = useTranslation();
591
- const [pillarInput, setPillarInput] = React8.useState("");
879
+ const [pillarInput, setPillarInput] = React9.useState("");
592
880
  const addPillar = () => {
593
881
  const value = pillarInput.trim();
594
882
  if (!value || draft.pillars.includes(value)) return;
@@ -744,7 +1032,7 @@ var ADD_ACCOUNT_VALUE = "__add-account__";
744
1032
  var POST_DRAG_MIME = "application/x-mkt-post";
745
1033
  function PostCard({ post, onOpen, onDelete }) {
746
1034
  const t = useTranslation();
747
- const [dragging, setDragging] = React8.useState(false);
1035
+ const [dragging, setDragging] = React9.useState(false);
748
1036
  return /* @__PURE__ */ jsxs(
749
1037
  "div",
750
1038
  {
@@ -799,7 +1087,7 @@ function PostCard({ post, onOpen, onDelete }) {
799
1087
  }
800
1088
  function WeekColumn({ week, rangeLabel, posts, onOpenPost, onNewPost, onDeletePost, onDropPost, disabled }) {
801
1089
  const t = useTranslation();
802
- const [isDropTarget, setIsDropTarget] = React8.useState(false);
1090
+ const [isDropTarget, setIsDropTarget] = React9.useState(false);
803
1091
  return /* @__PURE__ */ jsxs(
804
1092
  "div",
805
1093
  {
@@ -861,12 +1149,12 @@ function AccountModal({ open, account, onClose }) {
861
1149
  const saveAccount = useContentPlannerStore((s) => s.saveAccount);
862
1150
  const deleteAccount = useContentPlannerStore((s) => s.deleteAccount);
863
1151
  const isEdit = !!account;
864
- const [name, setName] = React8.useState("");
865
- const [handle, setHandle] = React8.useState("");
866
- const [platforms, setPlatforms] = React8.useState(["instagram"]);
867
- const [saving, setSaving] = React8.useState(false);
868
- const [confirmDelete, setConfirmDelete] = React8.useState(false);
869
- React8.useEffect(() => {
1152
+ const [name, setName] = React9.useState("");
1153
+ const [handle, setHandle] = React9.useState("");
1154
+ const [platforms, setPlatforms] = React9.useState(["instagram"]);
1155
+ const [saving, setSaving] = React9.useState(false);
1156
+ const [confirmDelete, setConfirmDelete] = React9.useState(false);
1157
+ React9.useEffect(() => {
870
1158
  if (open) {
871
1159
  setName(account?.name ?? "");
872
1160
  setHandle(account?.handle ?? "");
@@ -965,8 +1253,8 @@ function AccountSelector() {
965
1253
  const accounts = useContentPlannerStore((s) => s.accounts);
966
1254
  const activeAccountId = useContentPlannerStore((s) => s.activeAccountId);
967
1255
  const setAccount = useContentPlannerStore((s) => s.setAccount);
968
- const [open, setOpen] = React8.useState(false);
969
- const [modal, setModal] = React8.useState(null);
1256
+ const [open, setOpen] = React9.useState(false);
1257
+ const [modal, setModal] = React9.useState(null);
970
1258
  const activeAccount = accounts.find((a) => a.id === activeAccountId) ?? null;
971
1259
  if (!loaded) {
972
1260
  return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
@@ -1132,14 +1420,14 @@ function mondayOfCurrentWeek() {
1132
1420
  }
1133
1421
  function CalendarView({ posts, onOpenPost, onNewPostAt, disabled }) {
1134
1422
  const t = useTranslation();
1135
- const [cursor, setCursor] = React8.useState(() => {
1423
+ const [cursor, setCursor] = React9.useState(() => {
1136
1424
  const first = posts.find((p) => p.scheduledDate)?.scheduledDate;
1137
1425
  const base = first ? /* @__PURE__ */ new Date(`${first}T00:00:00`) : /* @__PURE__ */ new Date();
1138
1426
  return new Date(base.getFullYear(), base.getMonth(), 1);
1139
1427
  });
1140
- const [expandedDay, setExpandedDay] = React8.useState(null);
1428
+ const [expandedDay, setExpandedDay] = React9.useState(null);
1141
1429
  const MAX_VISIBLE = 3;
1142
- const postsByDate = React8.useMemo(() => {
1430
+ const postsByDate = React9.useMemo(() => {
1143
1431
  const map = /* @__PURE__ */ new Map();
1144
1432
  for (const post of posts) {
1145
1433
  if (!post.scheduledDate) continue;
@@ -1150,7 +1438,7 @@ function CalendarView({ posts, onOpenPost, onNewPostAt, disabled }) {
1150
1438
  return map;
1151
1439
  }, [posts]);
1152
1440
  const unscheduled = posts.filter((p) => !p.scheduledDate);
1153
- const days = React8.useMemo(() => {
1441
+ const days = React9.useMemo(() => {
1154
1442
  const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
1155
1443
  const start = new Date(firstOfMonth);
1156
1444
  start.setDate(start.getDate() - (firstOfMonth.getDay() + 6) % 7);
@@ -1309,13 +1597,13 @@ function ContentView({ onOpenPost }) {
1309
1597
  const setViewMode = useContentPlannerStore((s) => s.setViewMode);
1310
1598
  const loadedTenantId = useContentPlannerStore((s) => s.loadedTenantId);
1311
1599
  const guardContentPosts = useLimitGuard("content_posts_month");
1312
- const [briefOpen, setBriefOpen] = React8.useState(false);
1313
- const [pendingDeleteId, setPendingDeleteId] = React8.useState(null);
1600
+ const [briefOpen, setBriefOpen] = React9.useState(false);
1601
+ const [pendingDeleteId, setPendingDeleteId] = React9.useState(null);
1314
1602
  const tenantId = useOrganizationStore((state) => state.currentOrg?.id);
1315
- React8.useEffect(() => {
1603
+ React9.useEffect(() => {
1316
1604
  if (!loading && (!loaded || tenantId && tenantId !== loadedTenantId)) void load();
1317
1605
  }, [tenantId, loadedTenantId, loaded, loading, load]);
1318
- const postsByWeek = React8.useMemo(() => {
1606
+ const postsByWeek = React9.useMemo(() => {
1319
1607
  const map = /* @__PURE__ */ new Map();
1320
1608
  for (const post of posts) {
1321
1609
  const list = map.get(post.weekNumber) ?? [];
@@ -1465,9 +1753,9 @@ function newId() {
1465
1753
  function RecordingChecklist({ post }) {
1466
1754
  const t = useTranslation();
1467
1755
  const savePost = useContentPlannerStore((s) => s.savePost);
1468
- const [items, setItems] = React8.useState(post.checklist);
1469
- const [text, setText] = React8.useState("");
1470
- React8.useEffect(() => {
1756
+ const [items, setItems] = React9.useState(post.checklist);
1757
+ const [text, setText] = React9.useState("");
1758
+ React9.useEffect(() => {
1471
1759
  setItems(post.checklist);
1472
1760
  }, [post.id]);
1473
1761
  const persist = (next) => {
@@ -1710,7 +1998,7 @@ var FRAMES = {
1710
1998
  };
1711
1999
  function PostSocialPreview(props) {
1712
2000
  const platforms = props.platforms.length > 0 ? props.platforms : ["instagram"];
1713
- const [selected, setSelected] = React8.useState(platforms[0]);
2001
+ const [selected, setSelected] = React9.useState(platforms[0]);
1714
2002
  const active = platforms.includes(selected) ? selected : platforms[0];
1715
2003
  const Frame = FRAMES[active] ?? InstagramFrame;
1716
2004
  return /* @__PURE__ */ jsxs("div", { className: "space-y-3 rounded-md border border-border bg-muted/20 p-4", children: [
@@ -1894,8 +2182,8 @@ var POST_TEMPLATES = {
1894
2182
  };
1895
2183
  function MediaAsset({ post, onChanged }) {
1896
2184
  const t = useTranslation();
1897
- const inputRef = React8.useRef(null);
1898
- const [busy, setBusy] = React8.useState(false);
2185
+ const inputRef = React9.useRef(null);
2186
+ const [busy, setBusy] = React9.useState(false);
1899
2187
  const isVideo2 = post.mediaUrl ? /\.(mp4|webm|mov)($|\?)/i.test(post.mediaUrl) : false;
1900
2188
  const pick = () => inputRef.current?.click();
1901
2189
  const upload = async (file) => {
@@ -1973,11 +2261,11 @@ function PostPage({ postId, onBack }) {
1973
2261
  const deletePost = useContentPlannerStore((s) => s.deletePost);
1974
2262
  const accounts = useContentPlannerStore((s) => s.accounts);
1975
2263
  const activeAccountId = useContentPlannerStore((s) => s.activeAccountId);
1976
- const [post, setPost] = React8.useState(storePost ?? null);
1977
- const [draft, setDraft] = React8.useState(storePost ? toDraft2(storePost) : null);
1978
- const [saving, setSaving] = React8.useState(false);
1979
- const [confirmDelete, setConfirmDelete] = React8.useState(false);
1980
- React8.useEffect(() => {
2264
+ const [post, setPost] = React9.useState(storePost ?? null);
2265
+ const [draft, setDraft] = React9.useState(storePost ? toDraft2(storePost) : null);
2266
+ const [saving, setSaving] = React9.useState(false);
2267
+ const [confirmDelete, setConfirmDelete] = React9.useState(false);
2268
+ React9.useEffect(() => {
1981
2269
  if (post?.id === postId) return;
1982
2270
  if (storePost) {
1983
2271
  setPost(storePost);
@@ -2354,8 +2642,8 @@ function BlogEntityForm({
2354
2642
  const isPost = entity.data?.table === BLOG_POSTS_TABLE;
2355
2643
  const guardBlogPosts = useLimitGuard("blog_posts");
2356
2644
  const namePlural = entity.namePlural ?? entity.name + "s";
2357
- const [initialData, setInitialData] = React8.useState(isEdit ? null : {});
2358
- React8.useEffect(() => {
2645
+ const [initialData, setInitialData] = React9.useState(isEdit ? null : {});
2646
+ React9.useEffect(() => {
2359
2647
  if (!editId) return;
2360
2648
  let cancelled = false;
2361
2649
  (async () => {
@@ -2476,7 +2764,7 @@ function MarketingPage({ config, provider: provider2, store, contentProvider, co
2476
2764
  "blog-cat-new": 1,
2477
2765
  "blog-cat": 1
2478
2766
  }, "overview");
2479
- React8.useEffect(() => {
2767
+ React9.useEffect(() => {
2480
2768
  void store.getState().load();
2481
2769
  }, [store]);
2482
2770
  useDataChanged({ table: "plg_marketing_campaigns" }, () => {
@@ -2484,9 +2772,9 @@ function MarketingPage({ config, provider: provider2, store, contentProvider, co
2484
2772
  }, [store]);
2485
2773
  const isOverview = view === "overview";
2486
2774
  const nav = buildNav(config, view, navigate);
2487
- const blogPostEntity = React8.useMemo(() => buildBlogPostEntity(), []);
2488
- const blogCategoryEntity = React8.useMemo(() => buildBlogCategoryEntity(), []);
2489
- const quickActions = React8.useMemo(() => [
2775
+ const blogPostEntity = React9.useMemo(() => buildBlogPostEntity(), []);
2776
+ const blogCategoryEntity = React9.useMemo(() => buildBlogCategoryEntity(), []);
2777
+ const quickActions = React9.useMemo(() => [
2490
2778
  { id: "new-campaign", label: t("marketing.campaigns.new"), icon: "Megaphone", description: config.labels.campaigns, action: () => navigate("campaigns") }
2491
2779
  ], [t, config.labels.campaigns]);
2492
2780
  const renderView = createViewRouter([
@@ -2535,11 +2823,11 @@ var SOURCE_OPTIONS = [
2535
2823
  function SettingsView() {
2536
2824
  const t = useTranslation();
2537
2825
  const { conversion, channels } = useMarketingConfig();
2538
- const [source, setSource] = React8.useState(conversion.source);
2539
- const [trackValue, setTrackValue] = React8.useState(true);
2540
- const [autoSync, setAutoSync] = React8.useState(true);
2541
- const [assisted, setAssisted] = React8.useState(false);
2542
- const [tracked, setTracked] = React8.useState(
2826
+ const [source, setSource] = React9.useState(conversion.source);
2827
+ const [trackValue, setTrackValue] = React9.useState(true);
2828
+ const [autoSync, setAutoSync] = React9.useState(true);
2829
+ const [assisted, setAssisted] = React9.useState(false);
2830
+ const [tracked, setTracked] = React9.useState(
2543
2831
  () => Object.fromEntries(channels.map((c) => [c.id, true]))
2544
2832
  );
2545
2833
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
@@ -2603,12 +2891,21 @@ var T = {
2603
2891
  contentPlans: "plg_marketing_content_plans",
2604
2892
  contentPosts: "plg_marketing_content_posts",
2605
2893
  channels: "plg_marketing_channels",
2606
- campaigns: "plg_marketing_campaigns"
2894
+ campaigns: "plg_marketing_campaigns",
2895
+ landingPages: "plg_marketing_landing_pages"
2896
+ };
2897
+ var SITES_TABLE = "fayz_projects";
2898
+ var LEAD_TAGS_SOURCE = {
2899
+ table: "plg_crm_tags",
2900
+ valueField: "slug",
2901
+ labelField: "name"
2607
2902
  };
2608
2903
  var V = {
2609
2904
  channels: "v_marketing_channels",
2610
2905
  campaigns: "v_marketing_campaigns",
2611
- attribution: "v_marketing_attribution"
2906
+ attribution: "v_marketing_attribution",
2907
+ landingPages: "v_marketing_landing_pages",
2908
+ leadTagEvents: "v_marketing_lead_tag_events"
2612
2909
  };
2613
2910
 
2614
2911
  // src/registries.ts
@@ -2974,14 +3271,27 @@ function scaleCampaign(c, range) {
2974
3271
  value: scale(c.value, range)
2975
3272
  };
2976
3273
  }
3274
+ var MOCK_SITE = {
3275
+ id: "site-1",
3276
+ name: "Site principal",
3277
+ url: "https://exemplo.com.br",
3278
+ appId: "app-demo"
3279
+ };
3280
+ var MOCK_LEAD_TAGS = [
3281
+ { slug: "lp-consulta", name: "LP Consulta" },
3282
+ { slug: "lp-promo", name: "LP Promo" }
3283
+ ];
2977
3284
  function seedLandingPages() {
2978
3285
  return [
2979
- { id: "lp-1", name: "Free Consultation Funnel", type: "Funnel", visits: 3820, conversions: 474 },
2980
- { id: "lp-2", name: "Summer Promo Landing", type: "Landing page", visits: 1560, conversions: 126 },
2981
- { id: "lp-3", name: "Pricing Page", type: "Landing page", visits: 2240, conversions: 198 },
2982
- { id: "lp-4", name: "Company Website", type: "Website", visits: 9240, conversions: 296 }
3286
+ { id: "lp-1", siteId: MOCK_SITE.id, name: "Consulta gratuita", route: "/consulta", leadTags: ["lp-consulta"], status: "live", isActive: true, visits: 3820, conversions: 474 },
3287
+ { id: "lp-2", siteId: MOCK_SITE.id, name: "Promo da esta\xE7\xE3o", route: "/promo", leadTags: ["lp-promo"], status: "live", isActive: true, visits: 1560, conversions: 126 }
2983
3288
  ];
2984
3289
  }
3290
+ function normalizeRoute(route) {
3291
+ const trimmed = route.trim().replace(/\/+$/, "");
3292
+ if (!trimmed || trimmed === "/") return "/";
3293
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
3294
+ }
2985
3295
  function createMockMarketingProvider(cfg) {
2986
3296
  const base = channelBase30(cfg.channels, cfg.conversion);
2987
3297
  const campaigns = seedCampaigns(cfg);
@@ -3075,8 +3385,58 @@ function createMockMarketingProvider(cfg) {
3075
3385
  return landing.map((p) => {
3076
3386
  const visits = scale(p.visits, query.range);
3077
3387
  const conversions = scale(p.conversions, query.range);
3078
- return { ...p, visits, conversions, cvr: visits > 0 ? conversions / visits * 100 : 0 };
3388
+ return {
3389
+ ...p,
3390
+ siteName: MOCK_SITE.name,
3391
+ siteUrl: MOCK_SITE.url,
3392
+ appId: MOCK_SITE.appId,
3393
+ editorUrl: MOCK_SITE.editorUrl,
3394
+ url: `${MOCK_SITE.url}${p.route}`,
3395
+ visits,
3396
+ conversions,
3397
+ cvr: visits > 0 ? conversions / visits * 100 : null
3398
+ };
3079
3399
  });
3400
+ },
3401
+ async saveLandingPage(input) {
3402
+ const route = normalizeRoute(input.route);
3403
+ const existing = input.id ? landing.find((p) => p.id === input.id) : void 0;
3404
+ if (existing) {
3405
+ Object.assign(existing, {
3406
+ siteId: input.siteId,
3407
+ name: input.name,
3408
+ route,
3409
+ leadTags: input.leadTags ?? existing.leadTags,
3410
+ status: input.status ?? existing.status,
3411
+ prompt: input.prompt,
3412
+ isActive: input.isActive ?? existing.isActive
3413
+ });
3414
+ return existing;
3415
+ }
3416
+ const created = {
3417
+ id: `lp-${++counter2}`,
3418
+ siteId: input.siteId,
3419
+ name: input.name,
3420
+ route,
3421
+ leadTags: input.leadTags ?? [],
3422
+ status: input.status ?? "live",
3423
+ prompt: input.prompt,
3424
+ isActive: input.isActive ?? true,
3425
+ visits: 0,
3426
+ conversions: 0
3427
+ };
3428
+ landing.push(created);
3429
+ return created;
3430
+ },
3431
+ async deleteLandingPage(id) {
3432
+ const idx = landing.findIndex((p) => p.id === id);
3433
+ if (idx >= 0) landing.splice(idx, 1);
3434
+ },
3435
+ async listSites() {
3436
+ return [MOCK_SITE];
3437
+ },
3438
+ async listLeadTags() {
3439
+ return MOCK_LEAD_TAGS;
3080
3440
  }
3081
3441
  };
3082
3442
  }
@@ -3094,8 +3454,34 @@ function requireTenantId() {
3094
3454
  function normKey(value) {
3095
3455
  return (value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
3096
3456
  }
3457
+ var FAYZ_EDITOR_BASE = "https://fayz.ai/editor";
3458
+ function normalizeRoute2(route) {
3459
+ const trimmed = route.trim().replace(/\/+$/, "");
3460
+ if (!trimmed || trimmed === "/") return "/";
3461
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
3462
+ }
3463
+ function pageUrl(siteUrl, route) {
3464
+ const path = normalizeRoute2(route);
3465
+ if (!siteUrl) return path;
3466
+ const base = siteUrl.trim().replace(/\/+$/, "");
3467
+ const withScheme = /^https?:\/\//.test(base) ? base : `https://${base}`;
3468
+ return `${withScheme}${path === "/" ? "" : path}`;
3469
+ }
3470
+ function landingPageFromRow(row) {
3471
+ return {
3472
+ id: row.id,
3473
+ siteId: row.site_id,
3474
+ name: row.name,
3475
+ route: row.route,
3476
+ leadTags: row.lead_tags ?? [],
3477
+ status: row.status === "planned" ? "planned" : "live",
3478
+ prompt: row.prompt ?? void 0,
3479
+ isActive: row.is_active !== false
3480
+ };
3481
+ }
3097
3482
  function createSupabaseMarketingProvider(options) {
3098
3483
  const { channels, conversion, attributionBridge, sitesBridge } = options;
3484
+ const leadTagsSource = { ...LEAD_TAGS_SOURCE, ...options.leadTags };
3099
3485
  const channelByNorm = /* @__PURE__ */ new Map();
3100
3486
  for (const ch of channels) {
3101
3487
  channelByNorm.set(normKey(ch.id), ch.id);
@@ -3217,6 +3603,41 @@ function createSupabaseMarketingProvider(options) {
3217
3603
  });
3218
3604
  return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list;
3219
3605
  }
3606
+ function fetchLandingPages(tenantId) {
3607
+ return memo(`lp:${tenantId}`, async () => {
3608
+ const { data, error } = await getClient().from(V.landingPages).select("id, site_id, name, route, lead_tags, status, prompt, is_active, site_name, site_url, app_id, fayz_project_id, editor_url").eq("tenant_id", tenantId).order("name");
3609
+ if (error) return [];
3610
+ return data ?? [];
3611
+ });
3612
+ }
3613
+ function fetchLeadsByTag(tenantId, tags, since) {
3614
+ const key = `lptags:${tenantId}:${since.getTime()}:${tags.join(",")}`;
3615
+ return memo(key, async () => {
3616
+ const { data, error } = await getClient().from(V.leadTagEvents).select("tag, person_id").eq("tenant_id", tenantId).in("tag", tags).gte("occurred_at", since.toISOString());
3617
+ if (error) return /* @__PURE__ */ new Map();
3618
+ const byTag = /* @__PURE__ */ new Map();
3619
+ for (const row of data ?? []) {
3620
+ const set = byTag.get(row.tag) ?? /* @__PURE__ */ new Set();
3621
+ set.add(row.person_id);
3622
+ byTag.set(row.tag, set);
3623
+ }
3624
+ return byTag;
3625
+ });
3626
+ }
3627
+ async function fetchVisits(range, rows) {
3628
+ if (!sitesBridge) return /* @__PURE__ */ new Map();
3629
+ const result = await sitesBridge.visitsByRoute({
3630
+ range,
3631
+ pages: rows.map((r) => ({
3632
+ id: r.id,
3633
+ appId: r.app_id ?? void 0,
3634
+ fayzProjectId: r.fayz_project_id ?? void 0,
3635
+ siteUrl: r.site_url ?? void 0,
3636
+ route: r.route
3637
+ }))
3638
+ });
3639
+ return new Map(result.map((r) => [r.id, r.visits]));
3640
+ }
3220
3641
  function campaignFromRow(row) {
3221
3642
  return {
3222
3643
  id: row.id,
@@ -3345,8 +3766,82 @@ function createSupabaseMarketingProvider(options) {
3345
3766
  cache.clear();
3346
3767
  },
3347
3768
  async landingPages(query) {
3348
- if (sitesBridge) return sitesBridge.landingPages(query);
3349
- return [];
3769
+ const tenantId = requireTenantId();
3770
+ const { since } = windowFor(query.range);
3771
+ const rows = await fetchLandingPages(tenantId);
3772
+ if (!rows.length) return [];
3773
+ const tags = [...new Set(rows.flatMap((r) => r.lead_tags ?? []))];
3774
+ const [leadsByTag, visitsById] = await Promise.all([
3775
+ tags.length ? fetchLeadsByTag(tenantId, tags, since) : Promise.resolve(/* @__PURE__ */ new Map()),
3776
+ fetchVisits(query.range, rows)
3777
+ ]);
3778
+ return rows.map((r) => {
3779
+ const visits = visitsById.get(r.id) ?? null;
3780
+ const leads = /* @__PURE__ */ new Set();
3781
+ for (const tag of r.lead_tags ?? []) {
3782
+ for (const personId of leadsByTag.get(tag) ?? []) leads.add(personId);
3783
+ }
3784
+ const conversions = leads.size;
3785
+ return {
3786
+ ...landingPageFromRow(r),
3787
+ siteName: r.site_name,
3788
+ siteUrl: r.site_url ?? void 0,
3789
+ appId: r.app_id ?? void 0,
3790
+ editorUrl: r.editor_url ?? (r.fayz_project_id ? `${FAYZ_EDITOR_BASE}/${r.fayz_project_id}` : void 0),
3791
+ url: pageUrl(r.site_url, r.route),
3792
+ visits,
3793
+ conversions,
3794
+ cvr: visits && visits > 0 ? conversions / visits * 100 : null
3795
+ };
3796
+ });
3797
+ },
3798
+ async saveLandingPage(input) {
3799
+ const tenantId = requireTenantId();
3800
+ const payload = {
3801
+ tenant_id: tenantId,
3802
+ site_id: input.siteId,
3803
+ name: input.name,
3804
+ route: normalizeRoute2(input.route),
3805
+ lead_tags: input.leadTags ?? [],
3806
+ status: input.status ?? "live",
3807
+ prompt: input.prompt?.trim() || null,
3808
+ is_active: input.isActive ?? true,
3809
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
3810
+ };
3811
+ const query = input.id ? getClient().from(T.landingPages).update(payload).eq("id", input.id) : getClient().from(T.landingPages).insert(payload);
3812
+ const { data, error } = await query.select("*").single();
3813
+ if (error) throw error;
3814
+ cache.clear();
3815
+ return landingPageFromRow(data);
3816
+ },
3817
+ async deleteLandingPage(id) {
3818
+ const { error } = await getClient().from(T.landingPages).delete().eq("id", id);
3819
+ if (error) throw error;
3820
+ cache.clear();
3821
+ },
3822
+ async listSites() {
3823
+ const tenantId = requireTenantId();
3824
+ return memo(`sites:${tenantId}`, async () => {
3825
+ const { data, error } = await getClient().from(SITES_TABLE).select("id, name, preview_url, app_id, fayz_project_id, editor_url").eq("tenant_id", tenantId).order("is_default", { ascending: false }).order("name");
3826
+ if (error) return [];
3827
+ return (data ?? []).map((r) => ({
3828
+ id: r.id,
3829
+ name: r.name,
3830
+ url: r.preview_url ?? void 0,
3831
+ appId: r.app_id ?? void 0,
3832
+ fayzProjectId: r.fayz_project_id ?? void 0,
3833
+ editorUrl: r.editor_url ?? (r.fayz_project_id ? `${FAYZ_EDITOR_BASE}/${r.fayz_project_id}` : void 0)
3834
+ }));
3835
+ });
3836
+ },
3837
+ async listLeadTags() {
3838
+ const tenantId = requireTenantId();
3839
+ const { table, valueField, labelField } = leadTagsSource;
3840
+ return memo(`leadtags:${tenantId}`, async () => {
3841
+ const { data, error } = await getClient().from(table).select(`${valueField}, ${labelField}`).eq("tenant_id", tenantId).order(labelField);
3842
+ if (error) return [];
3843
+ return (data ?? []).map((r) => ({ slug: r[valueField], name: r[labelField] ?? r[valueField] })).filter((tag) => Boolean(tag.slug));
3844
+ });
3350
3845
  }
3351
3846
  };
3352
3847
  }
@@ -3777,19 +4272,23 @@ function createMarketingStore(provider2) {
3777
4272
  campaigns: [],
3778
4273
  funnel: [],
3779
4274
  landingPages: [],
4275
+ sites: [],
4276
+ leadTags: [],
3780
4277
  loading: false,
3781
4278
  async load() {
3782
4279
  const range = get().range;
3783
4280
  set({ loading: true });
3784
4281
  try {
3785
- const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
4282
+ const [overview, channels, campaigns, funnel, landingPages, sites, leadTags] = await Promise.all([
3786
4283
  provider2.overview({ range }),
3787
4284
  provider2.channelPerformance({ range }),
3788
4285
  provider2.listCampaigns({ range }),
3789
4286
  provider2.funnel({ range }),
3790
- provider2.landingPages({ range })
4287
+ provider2.landingPages({ range }),
4288
+ provider2.listSites(),
4289
+ provider2.listLeadTags()
3791
4290
  ]);
3792
- set({ overview, channels, campaigns, funnel, landingPages, loading: false });
4291
+ set({ overview, channels, campaigns, funnel, landingPages, sites, leadTags, loading: false });
3793
4292
  } catch (err) {
3794
4293
  set({ loading: false });
3795
4294
  throw err;
@@ -3811,6 +4310,17 @@ function createMarketingStore(provider2) {
3811
4310
  async deleteCampaign(id) {
3812
4311
  await provider2.deleteCampaign(id);
3813
4312
  await get().load();
4313
+ },
4314
+ async saveLandingPage(input) {
4315
+ const saved = await provider2.saveLandingPage(input);
4316
+ provider2.invalidateCache?.();
4317
+ await get().load();
4318
+ return saved;
4319
+ },
4320
+ async deleteLandingPage(id) {
4321
+ await provider2.deleteLandingPage(id);
4322
+ provider2.invalidateCache?.();
4323
+ await get().load();
3814
4324
  }
3815
4325
  }));
3816
4326
  }
@@ -4389,6 +4899,119 @@ REVOKE EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, js
4389
4899
  GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb)
4390
4900
  TO authenticated, service_role;
4391
4901
  `;
4902
+ var MIGRATION_008_LANDING_PAGES = `-- Marketing Plugin: landing pages.
4903
+ -- Prefix: plg_marketing_
4904
+ -- A landing page IS a site plus a route (espacorenovario.com.br + /lp-capilar).
4905
+ -- The SITE is not modelled here: it is the tenant's fayz project
4906
+ -- (public.fayz_projects, @fayz-ai/db 020) \u2014 the same rows Settings \u2192 Sites
4907
+ -- manages \u2014 so \`app_id\` / \`fayz_project_id\` / \`preview_url\` come from there.
4908
+ -- Visit analytics are read from the platform that hosts the deploy (app id +
4909
+ -- route + period); that source is not wired yet, so visits render as pending.
4910
+ -- \`lead_tags\` is the cross with the CRM: leads that arrive through the page
4911
+ -- carry those tag slugs in people.tags (v_marketing_lead_tag_events counts them).
4912
+ -- \`status\`='planned' + \`prompt\` is a page that does NOT exist on the site yet:
4913
+ -- the brief is written here and the page gets built with Fayz later.
4914
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
4915
+
4916
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_landing_pages (
4917
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4918
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4919
+ site_id uuid NOT NULL REFERENCES public.fayz_projects(id) ON DELETE CASCADE,
4920
+ name text NOT NULL,
4921
+ route text NOT NULL,
4922
+ lead_tags text[] NOT NULL DEFAULT '{}',
4923
+ status text NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'planned')),
4924
+ prompt text,
4925
+ is_active boolean NOT NULL DEFAULT true,
4926
+ created_at timestamptz NOT NULL DEFAULT now(),
4927
+ updated_at timestamptz NOT NULL DEFAULT now()
4928
+ );
4929
+
4930
+ -- Self-healing for pools that ran an earlier cut of this migration (single
4931
+ -- lead_tag column, no status/prompt). No-op on a fresh table. The views are
4932
+ -- rebuilt at the bottom; dropping them first is what lets the column go.
4933
+ DROP VIEW IF EXISTS public.v_marketing_landing_pages;
4934
+
4935
+ ALTER TABLE public.plg_marketing_landing_pages
4936
+ ADD COLUMN IF NOT EXISTS lead_tags text[] NOT NULL DEFAULT '{}',
4937
+ ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'live',
4938
+ ADD COLUMN IF NOT EXISTS prompt text;
4939
+
4940
+ DO $$
4941
+ BEGIN
4942
+ IF EXISTS (
4943
+ SELECT 1 FROM information_schema.columns
4944
+ WHERE table_schema = 'public' AND table_name = 'plg_marketing_landing_pages' AND column_name = 'lead_tag'
4945
+ ) THEN
4946
+ UPDATE public.plg_marketing_landing_pages
4947
+ SET lead_tags = ARRAY[lead_tag]
4948
+ WHERE lead_tag IS NOT NULL AND lead_tags = '{}';
4949
+ ALTER TABLE public.plg_marketing_landing_pages DROP COLUMN lead_tag;
4950
+ END IF;
4951
+ IF NOT EXISTS (
4952
+ SELECT 1 FROM pg_constraint
4953
+ WHERE conname = 'plg_marketing_landing_pages_status_check'
4954
+ ) THEN
4955
+ ALTER TABLE public.plg_marketing_landing_pages
4956
+ ADD CONSTRAINT plg_marketing_landing_pages_status_check CHECK (status IN ('live', 'planned'));
4957
+ END IF;
4958
+ END $$;
4959
+
4960
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_tenant ON public.plg_marketing_landing_pages(tenant_id);
4961
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_site ON public.plg_marketing_landing_pages(site_id);
4962
+ -- One row per route within a site: the route IS the page's identity.
4963
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_landing_pages_site_route
4964
+ ON public.plg_marketing_landing_pages(site_id, route);
4965
+
4966
+ DO $$
4967
+ DECLARE t text := 'plg_marketing_landing_pages';
4968
+ BEGIN
4969
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
4970
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
4971
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
4972
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
4973
+ END IF;
4974
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
4975
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
4976
+ END IF;
4977
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
4978
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
4979
+ END IF;
4980
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
4981
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
4982
+ END IF;
4983
+ END $$;
4984
+
4985
+ -- Read view: the page joined to its site, so the surface renders the full URL
4986
+ -- (and the analytics keys) without a second round trip.
4987
+ CREATE VIEW public.v_marketing_landing_pages WITH (security_invoker=true) AS
4988
+ SELECT lp.id, lp.tenant_id, lp.site_id, lp.name, lp.route, lp.lead_tags, lp.status, lp.prompt, lp.is_active,
4989
+ s.name AS site_name, s.preview_url AS site_url, s.app_id, s.fayz_project_id, s.editor_url,
4990
+ lp.created_at, lp.updated_at
4991
+ FROM public.plg_marketing_landing_pages lp
4992
+ JOIN public.fayz_projects s ON s.id = lp.site_id;
4993
+ GRANT SELECT ON public.v_marketing_landing_pages TO authenticated;
4994
+
4995
+ -- Lead events keyed by tag \u2014 the cross that turns "leads tagged lp-capilar"
4996
+ -- into that page's conversions. One row per (lead, tag); \`person_id\` is what
4997
+ -- keeps a lead carrying two of a page's tags from counting twice. Archived
4998
+ -- leads are OUT: plugin-crm archives by flipping people.is_active to false, and
4999
+ -- a page's number has to match what the funnel shows. Apps whose leads live
5000
+ -- elsewhere may CREATE OR REPLACE this view keeping the contract:
5001
+ -- (tenant_id uuid, tag text, person_id uuid, occurred_at timestamptz, value numeric)
5002
+ DROP VIEW IF EXISTS public.v_marketing_lead_tag_events;
5003
+ CREATE VIEW public.v_marketing_lead_tag_events WITH (security_invoker=true) AS
5004
+ SELECT p.tenant_id,
5005
+ tag,
5006
+ p.id AS person_id,
5007
+ p.created_at AS occurred_at,
5008
+ NULLIF(p.metadata->>'value', '')::numeric AS value
5009
+ FROM public.people p
5010
+ CROSS JOIN LATERAL unnest(COALESCE(p.tags, '{}'::text[])) AS tag
5011
+ WHERE p.kind = 'lead'
5012
+ AND COALESCE(p.is_active, true);
5013
+ GRANT SELECT ON public.v_marketing_lead_tag_events TO authenticated;
5014
+ `;
4392
5015
  var INTEGRATIONS = "plg_marketing_ranklayer_integrations";
4393
5016
  var SYNC_LOG = "plg_marketing_ranklayer_sync_log";
4394
5017
  function getClient3() {
@@ -4510,12 +5133,14 @@ function RankLayerExtraPanel() {
4510
5133
  ] })
4511
5134
  ] });
4512
5135
  }
5136
+ var RANKLAYER_MARK = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIj48cmVjdCB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHJ4PSI3IiBmaWxsPSIjMUYyNDMxIi8+PHBvbHlnb24gcG9pbnRzPSIxNiw1LjYgMjYuNCwxMS42IDE2LDE3LjYgNS42LDExLjYiIGZpbGw9IiNGRkZGRkYiIHN0cm9rZT0iI0ZGRkZGRiIgc3Ryb2tlLXdpZHRoPSIxLjI1IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PGcgb3BhY2l0eT0iMC41NSI+PHBvbHlnb24gcG9pbnRzPSI1LjYsMTYuNCAxNiwyMi40IDI2LjQsMTYuNCAyNi40LDE4LjIgMTYsMjQuMiA1LjYsMTguMiIgZmlsbD0iI0ZGRkZGRiIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PGcgb3BhY2l0eT0iMC4yOCI+PHBvbHlnb24gcG9pbnRzPSI1LjYsMjEuMiAxNiwyNy4yIDI2LjQsMjEuMiAyNi40LDIzIDE2LDI5IDUuNiwyMyIgZmlsbD0iI0ZGRkZGRiIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PC9zdmc+";
4513
5137
  var ranklayerConnectorDef = {
4514
5138
  id: "ranklayer",
4515
5139
  hostPluginId: "marketing",
4516
5140
  name: "RankLayer",
4517
5141
  description: "Publica\xE7\xE3o de conte\xFAdo e p\xE1ginas de SEO por servi\xE7o/bairro para ser encontrado no Google e em assistentes de IA.",
4518
5142
  icon: "Search",
5143
+ logoUrl: RANKLAYER_MARK,
4519
5144
  authKind: "api-key",
4520
5145
  fields: [
4521
5146
  { key: "apiKey", label: "API Key", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" },
@@ -4548,6 +5173,7 @@ var en = {
4548
5173
  "marketing.metric.cvr": "CVR",
4549
5174
  "marketing.metric.totalVisits": "Total visits",
4550
5175
  "marketing.metric.pages": "Pages",
5176
+ "marketing.metric.leads": "Leads",
4551
5177
  "marketing.metric.avgConversion": "Avg. conversion",
4552
5178
  // columns
4553
5179
  "marketing.col.campaign": "Campaign",
@@ -4562,6 +5188,8 @@ var en = {
4562
5188
  "marketing.col.conversions": "Conversions",
4563
5189
  "marketing.col.type": "Type",
4564
5190
  "marketing.col.page": "Page",
5191
+ "marketing.col.route": "Route",
5192
+ "marketing.col.leadTag": "Tag",
4565
5193
  // overview
4566
5194
  "marketing.overview.byChannelSuffix": "by channel",
4567
5195
  "marketing.overview.recent": "Recent campaigns",
@@ -4593,7 +5221,27 @@ var en = {
4593
5221
  "marketing.funnel.overall": "Overall conversion rate",
4594
5222
  "marketing.funnel.ofPrev": "of prev",
4595
5223
  // landing pages
4596
- "marketing.landing.subtitle": "Per-page visits \u2192 conversions performance",
5224
+ "marketing.landing.new": "New page",
5225
+ "marketing.landing.edit": "Edit page",
5226
+ "marketing.landing.delete": "Delete page",
5227
+ "marketing.landing.save": "Save",
5228
+ "marketing.landing.empty": "No pages yet.",
5229
+ "marketing.landing.site": "Site",
5230
+ "marketing.landing.sitePlaceholder": "Pick a site",
5231
+ "marketing.landing.name": "Name",
5232
+ "marketing.landing.namePlaceholder": "Hair consult LP",
5233
+ "marketing.landing.route": "Route",
5234
+ "marketing.landing.create": "Create page",
5235
+ "marketing.landing.status": "Does this page exist on the site yet?",
5236
+ "marketing.landing.statusLive": "Already live",
5237
+ "marketing.landing.statusPlanned": "Not yet",
5238
+ "marketing.landing.prompt": "Page prompt",
5239
+ "marketing.landing.promptPlaceholder": "Describe the page: offer, audience, sections, call to action\u2026",
5240
+ "marketing.landing.buildWithFayz": "Build page with Fayz",
5241
+ "marketing.landing.leadTag": "Lead tags",
5242
+ "marketing.landing.leadTagPlaceholder": "new tag\u2026",
5243
+ "marketing.landing.leadTagHint": "Leads from this page enter the CRM with these tags.",
5244
+ "marketing.landing.visitsPending": "Visits come from the platform hosting the site; that source is not connected yet.",
4597
5245
  // settings
4598
5246
  "marketing.settings.conversionModel": "Conversion model",
4599
5247
  "marketing.settings.conversionModelDesc": "What counts as a conversion in this workspace and where it's attributed from.",
@@ -4719,6 +5367,7 @@ var ptBR = {
4719
5367
  "marketing.metric.cvr": "CVR",
4720
5368
  "marketing.metric.totalVisits": "Total de visitas",
4721
5369
  "marketing.metric.pages": "P\xE1ginas",
5370
+ "marketing.metric.leads": "Leads",
4722
5371
  "marketing.metric.avgConversion": "Convers\xE3o m\xE9dia",
4723
5372
  // columns
4724
5373
  "marketing.col.campaign": "Campanha",
@@ -4733,6 +5382,8 @@ var ptBR = {
4733
5382
  "marketing.col.conversions": "Convers\xF5es",
4734
5383
  "marketing.col.type": "Tipo",
4735
5384
  "marketing.col.page": "P\xE1gina",
5385
+ "marketing.col.route": "Rota",
5386
+ "marketing.col.leadTag": "Tag",
4736
5387
  // overview
4737
5388
  "marketing.overview.byChannelSuffix": "por canal",
4738
5389
  "marketing.overview.recent": "Campanhas recentes",
@@ -4764,7 +5415,27 @@ var ptBR = {
4764
5415
  "marketing.funnel.overall": "Taxa de convers\xE3o geral",
4765
5416
  "marketing.funnel.ofPrev": "do anterior",
4766
5417
  // landing pages
4767
- "marketing.landing.subtitle": "Desempenho de visitas \u2192 convers\xF5es por p\xE1gina",
5418
+ "marketing.landing.new": "Nova p\xE1gina",
5419
+ "marketing.landing.edit": "Editar p\xE1gina",
5420
+ "marketing.landing.delete": "Excluir p\xE1gina",
5421
+ "marketing.landing.save": "Salvar",
5422
+ "marketing.landing.empty": "Nenhuma p\xE1gina cadastrada ainda.",
5423
+ "marketing.landing.site": "Site",
5424
+ "marketing.landing.sitePlaceholder": "Selecione o site",
5425
+ "marketing.landing.name": "Nome",
5426
+ "marketing.landing.namePlaceholder": "LP Capilar",
5427
+ "marketing.landing.route": "Rota",
5428
+ "marketing.landing.create": "Criar p\xE1gina",
5429
+ "marketing.landing.status": "Esta p\xE1gina j\xE1 existe no site?",
5430
+ "marketing.landing.statusLive": "J\xE1 existe",
5431
+ "marketing.landing.statusPlanned": "Ainda n\xE3o existe",
5432
+ "marketing.landing.prompt": "Prompt da p\xE1gina",
5433
+ "marketing.landing.promptPlaceholder": "Descreva a p\xE1gina: oferta, p\xFAblico, se\xE7\xF5es, chamada para a\xE7\xE3o\u2026",
5434
+ "marketing.landing.buildWithFayz": "Criar p\xE1gina com Fayz",
5435
+ "marketing.landing.leadTag": "Tags dos leads",
5436
+ "marketing.landing.leadTagPlaceholder": "nova tag\u2026",
5437
+ "marketing.landing.leadTagHint": "Os leads desta p\xE1gina entram no CRM com estas tags.",
5438
+ "marketing.landing.visitsPending": "As visitas v\xEAm da plataforma que hospeda o site; essa origem ainda n\xE3o est\xE1 conectada.",
4768
5439
  // settings
4769
5440
  "marketing.settings.conversionModel": "Modelo de convers\xE3o",
4770
5441
  "marketing.settings.conversionModelDesc": "O que conta como convers\xE3o neste workspace e de onde \xE9 atribu\xEDda.",
@@ -4917,7 +5588,8 @@ function createSafeProvider(config, domain, options) {
4917
5588
  channels: config.channels,
4918
5589
  conversion: config.conversion,
4919
5590
  attributionBridge: options?.attributionBridge,
4920
- sitesBridge: options?.sitesBridge
5591
+ sitesBridge: options?.sitesBridge,
5592
+ leadTags: options?.leadTags
4921
5593
  }),
4922
5594
  () => createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain })
4923
5595
  );
@@ -4933,23 +5605,23 @@ function createMarketingPlugin(options) {
4933
5605
  () => createMockContentPlannerProvider()
4934
5606
  );
4935
5607
  const contentStore = createContentPlannerStore(contentProvider);
4936
- const PageComponent = () => React8.createElement(
5608
+ const PageComponent = () => React9.createElement(
4937
5609
  MarketingContextProvider,
4938
5610
  { config, provider: provider2, store },
4939
- React8.createElement(MarketingPage, { config, provider: provider2, store, contentProvider, contentStore })
5611
+ React9.createElement(MarketingPage, { config, provider: provider2, store, contentProvider, contentStore })
4940
5612
  );
4941
5613
  PageComponent.displayName = "MarketingPage";
4942
5614
  const marketingRegistries = [
4943
5615
  ...buildMarketingRegistries(config.channels),
4944
5616
  ...options?.settingsRegistries ?? []
4945
5617
  ];
4946
- const SettingsComponent = () => React8.createElement(
5618
+ const SettingsComponent = () => React9.createElement(
4947
5619
  MarketingContextProvider,
4948
5620
  { config, provider: provider2, store },
4949
- React8.createElement(PluginSettingsPanel, {
5621
+ React9.createElement(PluginSettingsPanel, {
4950
5622
  title: config.labels.settings ?? config.labels.pageTitle,
4951
5623
  subtitle: config.labels.pageSubtitle,
4952
- generalSettings: React8.createElement(SettingsView),
5624
+ generalSettings: React9.createElement(SettingsView),
4953
5625
  registries: marketingRegistries,
4954
5626
  // Enables the "Integrações" tab (renders manifest connectors for this host).
4955
5627
  hostPluginId: "marketing",
@@ -5139,6 +5811,12 @@ function createMarketingPlugin(options) {
5139
5811
  version: "1.4.0",
5140
5812
  sql: MIGRATION_007_AGENT_RPCS,
5141
5813
  description: "agent_marketing_create_campaign \u2014 guarded, audited server-plane campaign create"
5814
+ },
5815
+ {
5816
+ id: "marketing-008-landing-pages",
5817
+ version: "1.5.0",
5818
+ sql: MIGRATION_008_LANDING_PAGES,
5819
+ description: "Sites + landing pages (site + route + lead tag) with tenant RLS, v_marketing_landing_pages and v_marketing_lead_tag_events"
5142
5820
  }
5143
5821
  ],
5144
5822
  // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold