@fayz-ai/plugin-marketing 0.9.0-next.0 → 0.9.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +6 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +15 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/types.d.ts +43 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +782 -104
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/registries.d.ts +3 -1
- package/dist/registries.d.ts.map +1 -1
- package/dist/store.d.ts +9 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +41 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/views/LandingPageComposer.d.ts +8 -0
- package/dist/views/LandingPageComposer.d.ts.map +1 -0
- package/dist/views/LandingPagesView.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/data/mock.ts +83 -8
- package/src/data/supabase.ts +215 -3
- package/src/data/tables.ts +17 -0
- package/src/data/types.ts +41 -1
- package/src/index.ts +12 -2
- package/src/integrations/ranklayer/connectorDef.tsx +4 -0
- package/src/locales/en.ts +24 -1
- package/src/locales/pt-BR.ts +24 -1
- package/src/migrations/008_landing_pages.sql +112 -0
- package/src/migrations/index.ts +116 -1
- package/src/registries.ts +3 -1
- package/src/store.ts +26 -3
- package/src/types.ts +44 -5
- package/src/views/LandingPageComposer.tsx +201 -0
- package/src/views/LandingPagesView.tsx +145 -22
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
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,
|
|
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
|
|
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 =
|
|
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] =
|
|
218
|
-
|
|
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 =
|
|
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] =
|
|
261
|
-
const [channelId, setChannelId] =
|
|
262
|
-
const [status, setStatus] =
|
|
263
|
-
const [spend, setSpend] =
|
|
264
|
-
const [saving, setSaving] =
|
|
265
|
-
|
|
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] =
|
|
328
|
-
const columns =
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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-
|
|
442
|
-
/* @__PURE__ */ jsx(
|
|
443
|
-
/* @__PURE__ */ jsx(
|
|
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(
|
|
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.
|
|
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] =
|
|
562
|
-
const [saving, setSaving] =
|
|
563
|
-
|
|
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] =
|
|
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] =
|
|
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] =
|
|
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] =
|
|
865
|
-
const [handle, setHandle] =
|
|
866
|
-
const [platforms, setPlatforms] =
|
|
867
|
-
const [saving, setSaving] =
|
|
868
|
-
const [confirmDelete, setConfirmDelete] =
|
|
869
|
-
|
|
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] =
|
|
969
|
-
const [modal, setModal] =
|
|
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] =
|
|
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] =
|
|
1428
|
+
const [expandedDay, setExpandedDay] = React9.useState(null);
|
|
1141
1429
|
const MAX_VISIBLE = 3;
|
|
1142
|
-
const postsByDate =
|
|
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 =
|
|
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] =
|
|
1313
|
-
const [pendingDeleteId, setPendingDeleteId] =
|
|
1600
|
+
const [briefOpen, setBriefOpen] = React9.useState(false);
|
|
1601
|
+
const [pendingDeleteId, setPendingDeleteId] = React9.useState(null);
|
|
1314
1602
|
const tenantId = useOrganizationStore((state) => state.currentOrg?.id);
|
|
1315
|
-
|
|
1603
|
+
React9.useEffect(() => {
|
|
1316
1604
|
if (!loading && (!loaded || tenantId && tenantId !== loadedTenantId)) void load();
|
|
1317
1605
|
}, [tenantId, loadedTenantId, loaded, loading, load]);
|
|
1318
|
-
const postsByWeek =
|
|
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] =
|
|
1469
|
-
const [text, setText] =
|
|
1470
|
-
|
|
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] =
|
|
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 =
|
|
1898
|
-
const [busy, setBusy] =
|
|
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] =
|
|
1977
|
-
const [draft, setDraft] =
|
|
1978
|
-
const [saving, setSaving] =
|
|
1979
|
-
const [confirmDelete, setConfirmDelete] =
|
|
1980
|
-
|
|
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] =
|
|
2358
|
-
|
|
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
|
-
|
|
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 =
|
|
2488
|
-
const blogCategoryEntity =
|
|
2489
|
-
const quickActions =
|
|
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] =
|
|
2539
|
-
const [trackValue, setTrackValue] =
|
|
2540
|
-
const [autoSync, setAutoSync] =
|
|
2541
|
-
const [assisted, setAssisted] =
|
|
2542
|
-
const [tracked, setTracked] =
|
|
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: "
|
|
2980
|
-
{ id: "lp-2", name: "
|
|
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 {
|
|
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
|
-
|
|
3349
|
-
|
|
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.
|
|
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.
|
|
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 = () =>
|
|
5608
|
+
const PageComponent = () => React9.createElement(
|
|
4937
5609
|
MarketingContextProvider,
|
|
4938
5610
|
{ config, provider: provider2, store },
|
|
4939
|
-
|
|
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 = () =>
|
|
5618
|
+
const SettingsComponent = () => React9.createElement(
|
|
4947
5619
|
MarketingContextProvider,
|
|
4948
5620
|
{ config, provider: provider2, store },
|
|
4949
|
-
|
|
5621
|
+
React9.createElement(PluginSettingsPanel, {
|
|
4950
5622
|
title: config.labels.settings ?? config.labels.pageTitle,
|
|
4951
5623
|
subtitle: config.labels.pageSubtitle,
|
|
4952
|
-
generalSettings:
|
|
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
|