@fayz-ai/plugin-marketing 0.1.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/MarketingContext.d.ts +35 -0
- package/dist/MarketingContext.d.ts.map +1 -0
- package/dist/MarketingPage.d.ts +11 -0
- package/dist/MarketingPage.d.ts.map +1 -0
- package/dist/components/MarketingBits.d.ts +29 -0
- package/dist/components/MarketingBits.d.ts.map +1 -0
- package/dist/components/icons.d.ts +6 -0
- package/dist/components/icons.d.ts.map +1 -0
- package/dist/data/mock.d.ts +10 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/types.d.ts +32 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/index.cjs +1354 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1346 -0
- package/dist/index.js.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/presets.d.ts +20 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/store.d.ts +18 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/CampaignComposer.d.ts +6 -0
- package/dist/views/CampaignComposer.d.ts.map +1 -0
- package/dist/views/CampaignsView.d.ts +3 -0
- package/dist/views/CampaignsView.d.ts.map +1 -0
- package/dist/views/ChannelDetailView.d.ts +6 -0
- package/dist/views/ChannelDetailView.d.ts.map +1 -0
- package/dist/views/ChannelsView.d.ts +5 -0
- package/dist/views/ChannelsView.d.ts.map +1 -0
- package/dist/views/FunnelView.d.ts +3 -0
- package/dist/views/FunnelView.d.ts.map +1 -0
- package/dist/views/LandingPagesView.d.ts +3 -0
- package/dist/views/LandingPagesView.d.ts.map +1 -0
- package/dist/views/OverviewView.d.ts +9 -0
- package/dist/views/OverviewView.d.ts.map +1 -0
- package/dist/views/SettingsView.d.ts +3 -0
- package/dist/views/SettingsView.d.ts.map +1 -0
- package/dist/views/dashboardWidgets.d.ts +11 -0
- package/dist/views/dashboardWidgets.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/MarketingContext.tsx +39 -0
- package/src/MarketingPage.tsx +94 -0
- package/src/components/MarketingBits.tsx +131 -0
- package/src/components/icons.tsx +17 -0
- package/src/data/mock.ts +233 -0
- package/src/data/types.ts +55 -0
- package/src/format.ts +40 -0
- package/src/index.ts +225 -0
- package/src/locales/en.ts +74 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +74 -0
- package/src/presets.ts +115 -0
- package/src/store.ts +66 -0
- package/src/types.ts +114 -0
- package/src/views/CampaignComposer.tsx +89 -0
- package/src/views/CampaignsView.tsx +56 -0
- package/src/views/ChannelDetailView.tsx +59 -0
- package/src/views/ChannelsView.tsx +46 -0
- package/src/views/FunnelView.tsx +56 -0
- package/src/views/LandingPagesView.tsx +49 -0
- package/src/views/OverviewView.tsx +24 -0
- package/src/views/SettingsView.tsx +72 -0
- package/src/views/dashboardWidgets.tsx +173 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
import React4, { useEffect } from 'react';
|
|
2
|
+
import { registerTranslations, getSupabaseClientOptional, useTranslation } from '@fayz-ai/core';
|
|
3
|
+
import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, DashboardCanvas, SegmentedControl, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, ModalFooter, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
|
|
4
|
+
import { createPluginContext, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow } from '@fayz-ai/saas';
|
|
5
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
6
|
+
import { ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, MousePointerClick, Layers, ArrowUpRight, ArrowDownRight, Bike, Utensils, DoorOpen, MessageCircle, Instagram, Mail, Globe, Megaphone, Search, Link2, Trophy } from 'lucide-react';
|
|
7
|
+
import { createStore } from 'zustand/vanilla';
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
var ctx = createPluginContext("MarketingPage");
|
|
11
|
+
var MarketingContextProvider = ctx.ContextProvider;
|
|
12
|
+
var useMarketingConfig = ctx.useConfig;
|
|
13
|
+
var useMarketingProvider = ctx.useProvider;
|
|
14
|
+
var useMarketingStore = ctx.useStore;
|
|
15
|
+
function useChannelLookup() {
|
|
16
|
+
const { channels } = useMarketingConfig();
|
|
17
|
+
return React4.useMemo(() => new Map(channels.map((c) => [c.id, c])), [channels]);
|
|
18
|
+
}
|
|
19
|
+
function OverviewView() {
|
|
20
|
+
const setRange = useMarketingStore((s) => s.setRange);
|
|
21
|
+
const range = useMarketingStore((s) => s.range);
|
|
22
|
+
return /* @__PURE__ */ jsx(
|
|
23
|
+
DashboardCanvas,
|
|
24
|
+
{
|
|
25
|
+
surface: "plugin-home",
|
|
26
|
+
domain: "marketing",
|
|
27
|
+
showHeader: false,
|
|
28
|
+
className: "mx-auto max-w-6xl space-y-6",
|
|
29
|
+
range: { default: range, onChange: (r) => void setRange(r) }
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/format.ts
|
|
35
|
+
var DEFAULT_CURRENCY = { code: "USD", locale: "en-US", symbol: "$" };
|
|
36
|
+
function formatCurrency(value, currency) {
|
|
37
|
+
try {
|
|
38
|
+
return new Intl.NumberFormat(currency.locale, {
|
|
39
|
+
style: "currency",
|
|
40
|
+
currency: currency.code,
|
|
41
|
+
maximumFractionDigits: value >= 1e3 ? 0 : 2
|
|
42
|
+
}).format(value);
|
|
43
|
+
} catch {
|
|
44
|
+
return `${currency.symbol}${Math.round(value).toLocaleString()}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function formatNumber(value) {
|
|
48
|
+
return Math.round(value).toLocaleString();
|
|
49
|
+
}
|
|
50
|
+
function formatPercent(value, digits = 1) {
|
|
51
|
+
return `${value.toFixed(digits)}%`;
|
|
52
|
+
}
|
|
53
|
+
function formatCompact(value) {
|
|
54
|
+
if (value >= 1e3) return `${(value / 1e3).toFixed(value >= 1e4 ? 0 : 1)}k`;
|
|
55
|
+
return String(Math.round(value));
|
|
56
|
+
}
|
|
57
|
+
function trendDelta(current, previous) {
|
|
58
|
+
if (previous === 0) return { pct: current > 0 ? 100 : 0, dir: current > 0 ? "up" : "neutral" };
|
|
59
|
+
const pct = (current - previous) / previous * 100;
|
|
60
|
+
return { pct: Math.abs(pct), dir: pct > 0.5 ? "up" : pct < -0.5 ? "down" : "neutral" };
|
|
61
|
+
}
|
|
62
|
+
var ICONS = {
|
|
63
|
+
Search,
|
|
64
|
+
Megaphone,
|
|
65
|
+
Globe,
|
|
66
|
+
Mail,
|
|
67
|
+
Users,
|
|
68
|
+
MousePointerClick,
|
|
69
|
+
Instagram,
|
|
70
|
+
MessageCircle,
|
|
71
|
+
DoorOpen,
|
|
72
|
+
Utensils,
|
|
73
|
+
Bike
|
|
74
|
+
};
|
|
75
|
+
function ChannelIcon({ name, className }) {
|
|
76
|
+
const Icon = ICONS[name] ?? Link2;
|
|
77
|
+
return /* @__PURE__ */ jsx(Icon, { className });
|
|
78
|
+
}
|
|
79
|
+
function KpiCard({
|
|
80
|
+
icon: Icon,
|
|
81
|
+
label,
|
|
82
|
+
value,
|
|
83
|
+
sub,
|
|
84
|
+
current,
|
|
85
|
+
previous,
|
|
86
|
+
invertTrend
|
|
87
|
+
}) {
|
|
88
|
+
const trend = current != null && previous != null ? trendDelta(current, previous) : null;
|
|
89
|
+
const good = trend ? invertTrend ? trend.dir === "down" : trend.dir === "up" : false;
|
|
90
|
+
const bad = trend ? invertTrend ? trend.dir === "up" : trend.dir === "down" : false;
|
|
91
|
+
return /* @__PURE__ */ jsxs("div", { className: "rounded-card border border-border bg-card p-4", children: [
|
|
92
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [
|
|
93
|
+
/* @__PURE__ */ jsx(Icon, { className: "h-4 w-4" }),
|
|
94
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs font-medium", children: label })
|
|
95
|
+
] }),
|
|
96
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-2 flex items-end justify-between gap-2", children: [
|
|
97
|
+
/* @__PURE__ */ jsx("p", { className: "text-2xl font-semibold text-foreground", children: value }),
|
|
98
|
+
trend && trend.dir !== "neutral" && /* @__PURE__ */ jsxs("span", { className: cn(
|
|
99
|
+
"mb-0.5 inline-flex items-center gap-0.5 text-xs font-medium",
|
|
100
|
+
good && "text-emerald-600",
|
|
101
|
+
bad && "text-rose-600",
|
|
102
|
+
!good && !bad && "text-muted-foreground"
|
|
103
|
+
), children: [
|
|
104
|
+
trend.dir === "up" ? /* @__PURE__ */ jsx(ArrowUpRight, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(ArrowDownRight, { className: "h-3 w-3" }),
|
|
105
|
+
trend.pct.toFixed(0),
|
|
106
|
+
"%"
|
|
107
|
+
] })
|
|
108
|
+
] }),
|
|
109
|
+
sub && /* @__PURE__ */ jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: sub })
|
|
110
|
+
] });
|
|
111
|
+
}
|
|
112
|
+
var RANGES = ["7d", "30d", "90d"];
|
|
113
|
+
function RangeTabs() {
|
|
114
|
+
const range = useMarketingStore((s) => s.range);
|
|
115
|
+
const setRange = useMarketingStore((s) => s.setRange);
|
|
116
|
+
return /* @__PURE__ */ jsx(
|
|
117
|
+
SegmentedControl,
|
|
118
|
+
{
|
|
119
|
+
options: RANGES,
|
|
120
|
+
value: range,
|
|
121
|
+
onChange: (r) => void setRange(r),
|
|
122
|
+
"aria-label": "Time range"
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
var STATUS_STYLE = {
|
|
127
|
+
active: "bg-emerald-100 text-emerald-700",
|
|
128
|
+
paused: "bg-amber-100 text-amber-700",
|
|
129
|
+
ended: "bg-muted text-muted-foreground",
|
|
130
|
+
draft: "bg-slate-100 text-slate-600"
|
|
131
|
+
};
|
|
132
|
+
function StatusBadge({ status }) {
|
|
133
|
+
const t = useTranslation();
|
|
134
|
+
return /* @__PURE__ */ jsx("span", { className: cn("rounded-full px-2 py-0.5 text-xs font-medium capitalize", STATUS_STYLE[status]), children: t(`marketing.status.${status}`) });
|
|
135
|
+
}
|
|
136
|
+
function ChannelCell({ channelId }) {
|
|
137
|
+
const lookup = useChannelLookup();
|
|
138
|
+
const ch = lookup.get(channelId);
|
|
139
|
+
return /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1.5 text-foreground", children: [
|
|
140
|
+
/* @__PURE__ */ jsx(ChannelIcon, { name: ch?.icon ?? "Link2", className: "h-3.5 w-3.5 text-muted-foreground" }),
|
|
141
|
+
ch?.label ?? channelId
|
|
142
|
+
] });
|
|
143
|
+
}
|
|
144
|
+
function ProportionBar({ value, max, color, label, right }) {
|
|
145
|
+
const pct = max > 0 ? Math.max(value / max * 100, 2) : 0;
|
|
146
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
|
|
147
|
+
/* @__PURE__ */ jsx("div", { className: "w-32 shrink-0 text-sm text-muted-foreground", children: label }),
|
|
148
|
+
/* @__PURE__ */ jsx("div", { className: "h-7 flex-1 overflow-hidden rounded-md bg-muted/40", children: /* @__PURE__ */ jsx(
|
|
149
|
+
"div",
|
|
150
|
+
{
|
|
151
|
+
className: "flex h-full items-center rounded-md px-2 text-xs font-semibold",
|
|
152
|
+
style: { width: `${pct}%`, backgroundColor: color + "26", color },
|
|
153
|
+
children: right
|
|
154
|
+
}
|
|
155
|
+
) })
|
|
156
|
+
] });
|
|
157
|
+
}
|
|
158
|
+
function ChannelsView({ onOpen }) {
|
|
159
|
+
const t = useTranslation();
|
|
160
|
+
const { conversion, currency } = useMarketingConfig();
|
|
161
|
+
const channels = useMarketingStore((s) => s.channels);
|
|
162
|
+
const sorted = [...channels].sort((a, b) => b.conversions - a.conversions);
|
|
163
|
+
const columns = React4.useMemo(() => [
|
|
164
|
+
{ accessorKey: "channelId", header: t("marketing.col.channel"), cell: ({ getValue }) => /* @__PURE__ */ jsx(ChannelCell, { channelId: getValue() }) },
|
|
165
|
+
{ accessorKey: "reach", header: t("marketing.col.reach"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
|
|
166
|
+
{ accessorKey: "leads", header: t("marketing.col.leads"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
|
|
167
|
+
{ accessorKey: "conversions", header: conversion.labelPlural, cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: formatNumber(getValue()) }) },
|
|
168
|
+
{ accessorKey: "cvr", header: t("marketing.col.cvr"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatPercent(getValue()) }) },
|
|
169
|
+
{ accessorKey: "spend", header: t("marketing.col.spend"), cell: ({ getValue }) => {
|
|
170
|
+
const v = getValue();
|
|
171
|
+
return /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: v > 0 ? formatCurrency(v, currency) : "\u2014" });
|
|
172
|
+
} },
|
|
173
|
+
{ accessorKey: "cpa", header: t("marketing.col.cpa"), cell: ({ getValue }) => {
|
|
174
|
+
const v = getValue();
|
|
175
|
+
return /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: v > 0 ? formatCurrency(v, currency) : "\u2014" });
|
|
176
|
+
} },
|
|
177
|
+
{ accessorKey: "value", header: conversion.valueLabel, cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatCurrency(getValue(), currency) }) },
|
|
178
|
+
{ id: "open", header: "", enableSorting: false, cell: () => /* @__PURE__ */ jsx(ChevronRight, { className: "h-4 w-4 text-muted-foreground" }) }
|
|
179
|
+
], [t, conversion.labelPlural, conversion.valueLabel, currency]);
|
|
180
|
+
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-6xl space-y-4", children: [
|
|
181
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
182
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("marketing.channels.subtitle") }),
|
|
183
|
+
/* @__PURE__ */ jsx(RangeTabs, {})
|
|
184
|
+
] }),
|
|
185
|
+
/* @__PURE__ */ jsx(
|
|
186
|
+
DataTable,
|
|
187
|
+
{
|
|
188
|
+
columns,
|
|
189
|
+
data: sorted,
|
|
190
|
+
onRowClick: (row) => onOpen(row.channelId),
|
|
191
|
+
emptyMessage: t("marketing.channels.subtitle")
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
] });
|
|
195
|
+
}
|
|
196
|
+
function ChannelDetailView({ channelId, onBack }) {
|
|
197
|
+
const t = useTranslation();
|
|
198
|
+
const { conversion, currency, labels } = useMarketingConfig();
|
|
199
|
+
const range = useMarketingStore((s) => s.range);
|
|
200
|
+
const channels = useMarketingStore((s) => s.channels);
|
|
201
|
+
const lookup = useChannelLookup();
|
|
202
|
+
const provider = useMarketingProvider();
|
|
203
|
+
const [campaigns, setCampaigns] = React4.useState([]);
|
|
204
|
+
React4.useEffect(() => {
|
|
205
|
+
void provider.listCampaigns({ range, channelId }).then(setCampaigns);
|
|
206
|
+
}, [provider, range, channelId]);
|
|
207
|
+
const perf = channels.find((c) => c.channelId === channelId);
|
|
208
|
+
const channel = lookup.get(channelId);
|
|
209
|
+
const columns = React4.useMemo(() => [
|
|
210
|
+
{ accessorKey: "name", header: t("marketing.col.campaign"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: getValue() }) },
|
|
211
|
+
{ accessorKey: "conversions", header: conversion.labelPlural, cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
|
|
212
|
+
{ 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()) }) },
|
|
213
|
+
{ accessorKey: "spend", header: t("marketing.col.spend"), cell: ({ getValue }) => {
|
|
214
|
+
const v = getValue();
|
|
215
|
+
return /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: v > 0 ? formatCurrency(v, currency) : "\u2014" });
|
|
216
|
+
} },
|
|
217
|
+
{ accessorKey: "status", header: t("marketing.col.status"), cell: ({ getValue }) => /* @__PURE__ */ jsx(StatusBadge, { status: getValue() }) }
|
|
218
|
+
], [t, conversion.labelPlural, currency]);
|
|
219
|
+
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-6xl space-y-6", children: [
|
|
220
|
+
/* @__PURE__ */ jsx(
|
|
221
|
+
SubpageHeader,
|
|
222
|
+
{
|
|
223
|
+
title: channel?.label ?? channelId,
|
|
224
|
+
subtitle: channel?.kind,
|
|
225
|
+
onBack,
|
|
226
|
+
parentLabel: labels.channels
|
|
227
|
+
}
|
|
228
|
+
),
|
|
229
|
+
perf && /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-4 lg:grid-cols-4", children: [
|
|
230
|
+
/* @__PURE__ */ jsx(KpiCard, { icon: Users, label: t("marketing.metric.reach"), value: formatNumber(perf.reach) }),
|
|
231
|
+
/* @__PURE__ */ jsx(KpiCard, { icon: Target, label: conversion.labelPlural, value: formatNumber(perf.conversions) }),
|
|
232
|
+
/* @__PURE__ */ jsx(KpiCard, { icon: Percent, label: t("marketing.metric.cvr"), value: formatPercent(perf.cvr) }),
|
|
233
|
+
/* @__PURE__ */ jsx(KpiCard, { icon: DollarSign, label: t("marketing.col.cpa"), value: perf.cpa > 0 ? formatCurrency(perf.cpa, currency) : "\u2014", sub: perf.spend > 0 ? `${formatCurrency(perf.spend, currency)} ${t("marketing.metric.spend")}` : "\u2014" })
|
|
234
|
+
] }),
|
|
235
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
236
|
+
/* @__PURE__ */ jsx("h2", { className: "text-sm font-semibold text-foreground", children: t("marketing.channels.campaignsOn") }),
|
|
237
|
+
/* @__PURE__ */ jsx(DataTable, { columns, data: campaigns, emptyMessage: t("marketing.channels.noCampaigns") })
|
|
238
|
+
] })
|
|
239
|
+
] });
|
|
240
|
+
}
|
|
241
|
+
var STATUSES = ["draft", "active", "paused"];
|
|
242
|
+
function CampaignComposer({ open, onClose }) {
|
|
243
|
+
const t = useTranslation();
|
|
244
|
+
const { channels } = useMarketingConfig();
|
|
245
|
+
const saveCampaign = useMarketingStore((s) => s.saveCampaign);
|
|
246
|
+
const [name, setName] = React4.useState("");
|
|
247
|
+
const [channelId, setChannelId] = React4.useState(channels[0]?.id ?? "");
|
|
248
|
+
const [status, setStatus] = React4.useState("active");
|
|
249
|
+
const [spend, setSpend] = React4.useState("");
|
|
250
|
+
const [saving, setSaving] = React4.useState(false);
|
|
251
|
+
React4.useEffect(() => {
|
|
252
|
+
if (open) {
|
|
253
|
+
setName("");
|
|
254
|
+
setChannelId(channels[0]?.id ?? "");
|
|
255
|
+
setStatus("active");
|
|
256
|
+
setSpend("");
|
|
257
|
+
}
|
|
258
|
+
}, [open, channels]);
|
|
259
|
+
async function handleSave() {
|
|
260
|
+
if (!name.trim() || !channelId) return;
|
|
261
|
+
setSaving(true);
|
|
262
|
+
await saveCampaign({
|
|
263
|
+
name: name.trim(),
|
|
264
|
+
channelId,
|
|
265
|
+
status,
|
|
266
|
+
start: (/* @__PURE__ */ new Date()).toISOString(),
|
|
267
|
+
spend: Number(spend) || 0
|
|
268
|
+
});
|
|
269
|
+
setSaving(false);
|
|
270
|
+
onClose();
|
|
271
|
+
}
|
|
272
|
+
return /* @__PURE__ */ jsx(Modal, { open, onOpenChange: (o) => {
|
|
273
|
+
if (!o) onClose();
|
|
274
|
+
}, children: /* @__PURE__ */ jsxs(ModalContent, { size: "md", children: [
|
|
275
|
+
/* @__PURE__ */ jsx(ModalHeader, { children: /* @__PURE__ */ jsx(ModalTitle, { children: t("marketing.composer.title") }) }),
|
|
276
|
+
/* @__PURE__ */ jsxs(ModalBody, { className: "space-y-4", children: [
|
|
277
|
+
/* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
|
|
278
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.name") }),
|
|
279
|
+
/* @__PURE__ */ jsx(Input, { value: name, onChange: (e) => setName(e.target.value), placeholder: t("marketing.composer.namePlaceholder") })
|
|
280
|
+
] }),
|
|
281
|
+
/* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
|
|
282
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.channel") }),
|
|
283
|
+
/* @__PURE__ */ jsxs(Select, { value: channelId, onValueChange: setChannelId, children: [
|
|
284
|
+
/* @__PURE__ */ jsx(SelectTrigger, { children: /* @__PURE__ */ jsx(SelectValue, { placeholder: t("marketing.composer.channelPlaceholder") }) }),
|
|
285
|
+
/* @__PURE__ */ jsx(SelectContent, { children: channels.map((c) => /* @__PURE__ */ jsx(SelectItem, { value: c.id, children: c.label }, c.id)) })
|
|
286
|
+
] })
|
|
287
|
+
] }),
|
|
288
|
+
/* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-3", children: [
|
|
289
|
+
/* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
|
|
290
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.status") }),
|
|
291
|
+
/* @__PURE__ */ jsxs(Select, { value: status, onValueChange: (v) => setStatus(v), children: [
|
|
292
|
+
/* @__PURE__ */ jsx(SelectTrigger, { children: /* @__PURE__ */ jsx(SelectValue, {}) }),
|
|
293
|
+
/* @__PURE__ */ jsx(SelectContent, { children: STATUSES.map((s) => /* @__PURE__ */ jsx(SelectItem, { value: s, className: "capitalize", children: t(`marketing.status.${s}`) }, s)) })
|
|
294
|
+
] })
|
|
295
|
+
] }),
|
|
296
|
+
/* @__PURE__ */ jsxs("label", { className: "block space-y-1.5", children: [
|
|
297
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: t("marketing.composer.budget") }),
|
|
298
|
+
/* @__PURE__ */ jsx(Input, { type: "number", min: 0, value: spend, onChange: (e) => setSpend(e.target.value), placeholder: "0" })
|
|
299
|
+
] })
|
|
300
|
+
] })
|
|
301
|
+
] }),
|
|
302
|
+
/* @__PURE__ */ jsxs(ModalFooter, { children: [
|
|
303
|
+
/* @__PURE__ */ jsx(Button, { variant: "outline", onClick: onClose, children: t("marketing.composer.cancel") }),
|
|
304
|
+
/* @__PURE__ */ jsx(Button, { onClick: handleSave, disabled: saving || !name.trim(), children: t("marketing.composer.create") })
|
|
305
|
+
] })
|
|
306
|
+
] }) });
|
|
307
|
+
}
|
|
308
|
+
function CampaignsView() {
|
|
309
|
+
const t = useTranslation();
|
|
310
|
+
const { conversion, currency } = useMarketingConfig();
|
|
311
|
+
const campaigns = useMarketingStore((s) => s.campaigns);
|
|
312
|
+
const deleteCampaign = useMarketingStore((s) => s.deleteCampaign);
|
|
313
|
+
const [composerOpen, setComposerOpen] = React4.useState(false);
|
|
314
|
+
const columns = React4.useMemo(() => [
|
|
315
|
+
{ accessorKey: "name", header: t("marketing.col.campaign"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: getValue() }) },
|
|
316
|
+
{ accessorKey: "channelId", header: t("marketing.col.channel"), cell: ({ getValue }) => /* @__PURE__ */ jsx(ChannelCell, { channelId: getValue() }) },
|
|
317
|
+
{ accessorKey: "reach", header: t("marketing.col.reach"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
|
|
318
|
+
{ accessorKey: "conversions", header: conversion.labelPlural, cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: formatNumber(getValue()) }) },
|
|
319
|
+
{ 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()) }) },
|
|
320
|
+
{ accessorKey: "spend", header: t("marketing.col.spend"), cell: ({ getValue }) => {
|
|
321
|
+
const v = getValue();
|
|
322
|
+
return /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: v > 0 ? formatCurrency(v, currency) : "\u2014" });
|
|
323
|
+
} },
|
|
324
|
+
{ accessorKey: "status", header: t("marketing.col.status"), cell: ({ getValue }) => /* @__PURE__ */ jsx(StatusBadge, { status: getValue() }) },
|
|
325
|
+
{
|
|
326
|
+
id: "actions",
|
|
327
|
+
header: "",
|
|
328
|
+
enableSorting: false,
|
|
329
|
+
cell: ({ row }) => /* @__PURE__ */ jsx(
|
|
330
|
+
"button",
|
|
331
|
+
{
|
|
332
|
+
onClick: (e) => {
|
|
333
|
+
e.stopPropagation();
|
|
334
|
+
void deleteCampaign(row.original.id);
|
|
335
|
+
},
|
|
336
|
+
className: "rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive",
|
|
337
|
+
title: t("marketing.campaigns.delete"),
|
|
338
|
+
children: /* @__PURE__ */ jsx(Trash2, { className: "h-4 w-4" })
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
], [t, conversion.labelPlural, currency, deleteCampaign]);
|
|
343
|
+
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-6xl space-y-4", children: [
|
|
344
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
345
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("marketing.campaigns.subtitle") }),
|
|
346
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
347
|
+
/* @__PURE__ */ jsx(RangeTabs, {}),
|
|
348
|
+
/* @__PURE__ */ jsxs(Button, { onClick: () => setComposerOpen(true), children: [
|
|
349
|
+
/* @__PURE__ */ jsx(Plus, { className: "mr-1.5 h-4 w-4" }),
|
|
350
|
+
" ",
|
|
351
|
+
t("marketing.campaigns.new")
|
|
352
|
+
] })
|
|
353
|
+
] })
|
|
354
|
+
] }),
|
|
355
|
+
/* @__PURE__ */ jsx(DataTable, { columns, data: campaigns, emptyMessage: t("marketing.campaigns.empty") }),
|
|
356
|
+
/* @__PURE__ */ jsx(CampaignComposer, { open: composerOpen, onClose: () => setComposerOpen(false) })
|
|
357
|
+
] });
|
|
358
|
+
}
|
|
359
|
+
function FunnelView() {
|
|
360
|
+
const t = useTranslation();
|
|
361
|
+
const { conversion } = useMarketingConfig();
|
|
362
|
+
const funnel = useMarketingStore((s) => s.funnel);
|
|
363
|
+
const max = Math.max(...funnel.map((s) => s.count), 1);
|
|
364
|
+
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-4xl space-y-4", children: [
|
|
365
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
366
|
+
/* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", children: [
|
|
367
|
+
t("marketing.funnel.subtitlePrefix"),
|
|
368
|
+
" ",
|
|
369
|
+
conversion.label.toLowerCase()
|
|
370
|
+
] }),
|
|
371
|
+
/* @__PURE__ */ jsx(RangeTabs, {})
|
|
372
|
+
] }),
|
|
373
|
+
/* @__PURE__ */ jsxs("div", { className: "rounded-card border border-border bg-card p-5", children: [
|
|
374
|
+
/* @__PURE__ */ jsx("div", { className: "space-y-3", children: funnel.map((stage, i) => {
|
|
375
|
+
const pct = max > 0 ? Math.max(stage.count / max * 100, 4) : 0;
|
|
376
|
+
const prev = funnel[i - 1];
|
|
377
|
+
const stepRate = prev && prev.count > 0 ? stage.count / prev.count * 100 : null;
|
|
378
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
379
|
+
/* @__PURE__ */ jsxs("div", { className: "mb-1 flex items-center justify-between text-sm", children: [
|
|
380
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: stage.label }),
|
|
381
|
+
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
382
|
+
formatNumber(stage.count),
|
|
383
|
+
stepRate != null && /* @__PURE__ */ jsxs("span", { className: "ml-2 text-xs", children: [
|
|
384
|
+
"(",
|
|
385
|
+
formatPercent(stepRate, 0),
|
|
386
|
+
" ",
|
|
387
|
+
t("marketing.funnel.ofPrev"),
|
|
388
|
+
")"
|
|
389
|
+
] })
|
|
390
|
+
] })
|
|
391
|
+
] }),
|
|
392
|
+
/* @__PURE__ */ jsx("div", { className: "h-9 overflow-hidden rounded-md bg-muted/40", children: /* @__PURE__ */ jsx("div", { className: "h-full rounded-md", style: { width: `${pct}%`, backgroundColor: stage.color + "33", borderLeft: `3px solid ${stage.color}` } }) })
|
|
393
|
+
] }, stage.id);
|
|
394
|
+
}) }),
|
|
395
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-5 border-t border-border pt-4 text-sm", children: [
|
|
396
|
+
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
397
|
+
t("marketing.funnel.overall"),
|
|
398
|
+
": "
|
|
399
|
+
] }),
|
|
400
|
+
/* @__PURE__ */ jsx("span", { className: "font-semibold text-foreground", children: funnel.length > 1 && funnel[0].count > 0 ? formatPercent(funnel[funnel.length - 1].count / funnel[0].count * 100) : "\u2014" })
|
|
401
|
+
] })
|
|
402
|
+
] })
|
|
403
|
+
] });
|
|
404
|
+
}
|
|
405
|
+
var TYPE_STYLE = {
|
|
406
|
+
Funnel: "bg-indigo-100 text-indigo-700",
|
|
407
|
+
"Landing page": "bg-pink-100 text-pink-700",
|
|
408
|
+
Website: "bg-sky-100 text-sky-700"
|
|
409
|
+
};
|
|
410
|
+
function LandingPagesView() {
|
|
411
|
+
const t = useTranslation();
|
|
412
|
+
const pages = useMarketingStore((s) => s.landingPages);
|
|
413
|
+
const totalVisits = pages.reduce((s, p) => s + p.visits, 0);
|
|
414
|
+
const totalConv = pages.reduce((s, p) => s + p.conversions, 0);
|
|
415
|
+
const avgCvr = totalVisits > 0 ? totalConv / totalVisits * 100 : 0;
|
|
416
|
+
const columns = React4.useMemo(() => [
|
|
417
|
+
{ accessorKey: "name", header: t("marketing.col.page"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: getValue() }) },
|
|
418
|
+
{ accessorKey: "type", header: t("marketing.col.type"), cell: ({ getValue }) => {
|
|
419
|
+
const v = getValue();
|
|
420
|
+
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 });
|
|
421
|
+
} },
|
|
422
|
+
{ accessorKey: "visits", header: t("marketing.col.visits"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatNumber(getValue()) }) },
|
|
423
|
+
{ accessorKey: "conversions", header: t("marketing.col.conversions"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: formatNumber(getValue()) }) },
|
|
424
|
+
{ accessorKey: "cvr", header: t("marketing.col.cvr"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatPercent(getValue()) }) }
|
|
425
|
+
], [t]);
|
|
426
|
+
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-6xl space-y-4", children: [
|
|
427
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
428
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("marketing.landing.subtitle") }),
|
|
429
|
+
/* @__PURE__ */ jsx(RangeTabs, {})
|
|
430
|
+
] }),
|
|
431
|
+
/* @__PURE__ */ jsxs("div", { className: "grid grid-cols-3 gap-4", children: [
|
|
432
|
+
/* @__PURE__ */ jsx(KpiCard, { icon: MousePointerClick, label: t("marketing.metric.totalVisits"), value: formatNumber(totalVisits) }),
|
|
433
|
+
/* @__PURE__ */ jsx(KpiCard, { icon: Layers, label: t("marketing.metric.pages"), value: String(pages.length) }),
|
|
434
|
+
/* @__PURE__ */ jsx(KpiCard, { icon: Percent, label: t("marketing.metric.avgConversion"), value: formatPercent(avgCvr) })
|
|
435
|
+
] }),
|
|
436
|
+
/* @__PURE__ */ jsx(DataTable, { columns, data: pages, emptyMessage: t("marketing.landing.subtitle") })
|
|
437
|
+
] });
|
|
438
|
+
}
|
|
439
|
+
function buildNav(config, view, navigate) {
|
|
440
|
+
const items = [
|
|
441
|
+
{ id: "overview", label: config.labels.overview, icon: "BarChart3", active: view === "overview", onClick: () => navigate("overview") }
|
|
442
|
+
];
|
|
443
|
+
if (config.modules.channels) {
|
|
444
|
+
items.push({
|
|
445
|
+
id: "channels",
|
|
446
|
+
label: config.labels.channels,
|
|
447
|
+
icon: "Radio",
|
|
448
|
+
active: view === "channels" || view.startsWith("channel-detail:"),
|
|
449
|
+
onClick: () => navigate("channels")
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (config.modules.campaigns) {
|
|
453
|
+
items.push({ id: "campaigns", label: config.labels.campaigns, icon: "Megaphone", active: view === "campaigns", onClick: () => navigate("campaigns") });
|
|
454
|
+
}
|
|
455
|
+
if (config.modules.funnel) {
|
|
456
|
+
items.push({ id: "funnel", label: config.labels.funnel, icon: "Filter", active: view === "funnel", onClick: () => navigate("funnel") });
|
|
457
|
+
}
|
|
458
|
+
if (config.modules.landingPages) {
|
|
459
|
+
items.push({ id: "landing-pages", label: config.labels.landingPages, icon: "LayoutTemplate", active: view === "landing-pages", onClick: () => navigate("landing-pages") });
|
|
460
|
+
}
|
|
461
|
+
return items;
|
|
462
|
+
}
|
|
463
|
+
function MarketingPage({ config, provider, store }) {
|
|
464
|
+
const t = useTranslation();
|
|
465
|
+
const { view, direction, navigate } = useModuleNavigation("/marketing", {
|
|
466
|
+
overview: 0,
|
|
467
|
+
channels: 0,
|
|
468
|
+
campaigns: 0,
|
|
469
|
+
funnel: 0,
|
|
470
|
+
"landing-pages": 0,
|
|
471
|
+
"channel-detail": 1
|
|
472
|
+
}, "overview");
|
|
473
|
+
React4.useEffect(() => {
|
|
474
|
+
void store.getState().load();
|
|
475
|
+
}, [store]);
|
|
476
|
+
const isOverview = view === "overview";
|
|
477
|
+
const nav = buildNav(config, view, navigate);
|
|
478
|
+
const quickActions = React4.useMemo(() => [
|
|
479
|
+
{ id: "new-campaign", label: t("marketing.campaigns.new"), icon: "Megaphone", description: config.labels.campaigns, action: () => navigate("campaigns") }
|
|
480
|
+
], [t, config.labels.campaigns]);
|
|
481
|
+
const renderView = createViewRouter([
|
|
482
|
+
{ id: "channels", render: () => /* @__PURE__ */ jsx(ChannelsView, { onOpen: (id) => navigate(`channel-detail:${id}`) }) },
|
|
483
|
+
{ id: "channel-detail", render: ({ id }) => /* @__PURE__ */ jsx(ChannelDetailView, { channelId: id, onBack: () => navigate("channels") }) },
|
|
484
|
+
{ id: "campaigns", render: () => /* @__PURE__ */ jsx(CampaignsView, {}) },
|
|
485
|
+
{ id: "funnel", render: () => /* @__PURE__ */ jsx(FunnelView, {}) },
|
|
486
|
+
{ id: "landing-pages", render: () => /* @__PURE__ */ jsx(LandingPagesView, {}) },
|
|
487
|
+
{ id: "overview", render: () => /* @__PURE__ */ jsx(OverviewView, {}) }
|
|
488
|
+
], "overview");
|
|
489
|
+
return /* @__PURE__ */ jsx(MarketingContextProvider, { config, provider, store, children: /* @__PURE__ */ jsx(
|
|
490
|
+
ModulePage,
|
|
491
|
+
{
|
|
492
|
+
title: config.labels.pageTitle,
|
|
493
|
+
subtitle: config.labels.pageSubtitle,
|
|
494
|
+
nav,
|
|
495
|
+
showHeader: isOverview,
|
|
496
|
+
viewKey: view,
|
|
497
|
+
direction,
|
|
498
|
+
headerAction: /* @__PURE__ */ jsx(
|
|
499
|
+
ModuleActionBar,
|
|
500
|
+
{
|
|
501
|
+
quickActions,
|
|
502
|
+
settingsPath: "/settings/marketing",
|
|
503
|
+
settingsLabel: config.labels.pageTitle
|
|
504
|
+
}
|
|
505
|
+
),
|
|
506
|
+
children: renderView(view)
|
|
507
|
+
}
|
|
508
|
+
) });
|
|
509
|
+
}
|
|
510
|
+
var SOURCE_OPTIONS = [
|
|
511
|
+
{ value: "crm", label: "CRM" },
|
|
512
|
+
{ value: "agenda", label: "Agenda" },
|
|
513
|
+
{ value: "orders", label: "Orders" },
|
|
514
|
+
{ value: "custom", label: "Custom" }
|
|
515
|
+
];
|
|
516
|
+
function SettingsView() {
|
|
517
|
+
const t = useTranslation();
|
|
518
|
+
const { conversion, channels } = useMarketingConfig();
|
|
519
|
+
const [source, setSource] = React4.useState(conversion.source);
|
|
520
|
+
const [trackValue, setTrackValue] = React4.useState(true);
|
|
521
|
+
const [autoSync, setAutoSync] = React4.useState(true);
|
|
522
|
+
const [assisted, setAssisted] = React4.useState(false);
|
|
523
|
+
const [tracked, setTracked] = React4.useState(
|
|
524
|
+
() => Object.fromEntries(channels.map((c) => [c.id, true]))
|
|
525
|
+
);
|
|
526
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
527
|
+
/* @__PURE__ */ jsxs(SettingsGroup, { title: t("marketing.settings.conversionModel"), description: t("marketing.settings.conversionModelDesc"), children: [
|
|
528
|
+
/* @__PURE__ */ jsx(
|
|
529
|
+
SelectRow,
|
|
530
|
+
{
|
|
531
|
+
label: t("marketing.settings.attributedFrom"),
|
|
532
|
+
description: conversion.label,
|
|
533
|
+
value: source,
|
|
534
|
+
options: SOURCE_OPTIONS,
|
|
535
|
+
onChange: (v) => setSource(v)
|
|
536
|
+
}
|
|
537
|
+
),
|
|
538
|
+
/* @__PURE__ */ jsx(
|
|
539
|
+
ToggleRow,
|
|
540
|
+
{
|
|
541
|
+
label: t("marketing.settings.trackValue"),
|
|
542
|
+
description: t("marketing.settings.trackValueDesc"),
|
|
543
|
+
checked: trackValue,
|
|
544
|
+
onChange: setTrackValue
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
] }),
|
|
548
|
+
/* @__PURE__ */ jsx(SettingsGroup, { title: t("marketing.settings.channels"), description: t("marketing.settings.channelsDesc"), children: channels.map((c) => /* @__PURE__ */ jsx(
|
|
549
|
+
ToggleRow,
|
|
550
|
+
{
|
|
551
|
+
label: c.label,
|
|
552
|
+
description: t("marketing.settings.channelTrackDesc"),
|
|
553
|
+
checked: tracked[c.id] ?? true,
|
|
554
|
+
onChange: (v) => setTracked((prev) => ({ ...prev, [c.id]: v }))
|
|
555
|
+
},
|
|
556
|
+
c.id
|
|
557
|
+
)) }),
|
|
558
|
+
/* @__PURE__ */ jsxs(SettingsGroup, { title: t("marketing.settings.attribution"), description: t("marketing.settings.attributionDesc"), children: [
|
|
559
|
+
/* @__PURE__ */ jsx(
|
|
560
|
+
ToggleRow,
|
|
561
|
+
{
|
|
562
|
+
label: t("marketing.settings.autoSync"),
|
|
563
|
+
description: t("marketing.settings.autoSyncDesc"),
|
|
564
|
+
checked: autoSync,
|
|
565
|
+
onChange: setAutoSync
|
|
566
|
+
}
|
|
567
|
+
),
|
|
568
|
+
/* @__PURE__ */ jsx(
|
|
569
|
+
ToggleRow,
|
|
570
|
+
{
|
|
571
|
+
label: t("marketing.settings.assisted"),
|
|
572
|
+
description: t("marketing.settings.assistedDesc"),
|
|
573
|
+
checked: assisted,
|
|
574
|
+
onChange: setAssisted
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
] })
|
|
578
|
+
] });
|
|
579
|
+
}
|
|
580
|
+
function useEnsureMarketingData() {
|
|
581
|
+
const overview = useMarketingStore((s) => s.overview);
|
|
582
|
+
const loading = useMarketingStore((s) => s.loading);
|
|
583
|
+
const load = useMarketingStore((s) => s.load);
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
if (!overview && !loading) void load();
|
|
586
|
+
}, []);
|
|
587
|
+
}
|
|
588
|
+
function ConversionsKpi() {
|
|
589
|
+
const { conversion } = useMarketingConfig();
|
|
590
|
+
const overview = useMarketingStore((s) => s.overview);
|
|
591
|
+
useEnsureMarketingData();
|
|
592
|
+
return /* @__PURE__ */ jsx(
|
|
593
|
+
KpiCard$1,
|
|
594
|
+
{
|
|
595
|
+
icon: Target,
|
|
596
|
+
label: conversion.labelPlural,
|
|
597
|
+
value: formatNumber(overview?.conversions ?? 0),
|
|
598
|
+
current: overview?.conversions,
|
|
599
|
+
previous: overview?.conversionsPrev
|
|
600
|
+
}
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
function ConversionRateKpi() {
|
|
604
|
+
const t = useTranslation();
|
|
605
|
+
const overview = useMarketingStore((s) => s.overview);
|
|
606
|
+
useEnsureMarketingData();
|
|
607
|
+
return /* @__PURE__ */ jsx(
|
|
608
|
+
KpiCard$1,
|
|
609
|
+
{
|
|
610
|
+
icon: Percent,
|
|
611
|
+
label: t("marketing.metric.conversionRate"),
|
|
612
|
+
value: formatPercent(overview?.cvr ?? 0),
|
|
613
|
+
current: overview?.cvr,
|
|
614
|
+
previous: overview?.cvrPrev
|
|
615
|
+
}
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
function CpaKpi() {
|
|
619
|
+
const t = useTranslation();
|
|
620
|
+
const { currency } = useMarketingConfig();
|
|
621
|
+
const overview = useMarketingStore((s) => s.overview);
|
|
622
|
+
useEnsureMarketingData();
|
|
623
|
+
return /* @__PURE__ */ jsx(
|
|
624
|
+
KpiCard$1,
|
|
625
|
+
{
|
|
626
|
+
icon: DollarSign,
|
|
627
|
+
label: t("marketing.metric.cpa"),
|
|
628
|
+
value: overview && overview.cpa > 0 ? formatCurrency(overview.cpa, currency) : "\u2014",
|
|
629
|
+
sub: `${formatCurrency(overview?.spend ?? 0, currency)} ${t("marketing.metric.spend")}`
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
function TopChannelKpi() {
|
|
634
|
+
const t = useTranslation();
|
|
635
|
+
const { conversion, currency } = useMarketingConfig();
|
|
636
|
+
const overview = useMarketingStore((s) => s.overview);
|
|
637
|
+
const lookup = useChannelLookup();
|
|
638
|
+
useEnsureMarketingData();
|
|
639
|
+
return /* @__PURE__ */ jsx(
|
|
640
|
+
KpiCard$1,
|
|
641
|
+
{
|
|
642
|
+
icon: Trophy,
|
|
643
|
+
label: t("marketing.metric.topChannel"),
|
|
644
|
+
value: overview?.topChannelId ? lookup.get(overview.topChannelId)?.label ?? "\u2014" : "\u2014",
|
|
645
|
+
sub: `${conversion.valueLabel}: ${formatCurrency(overview?.value ?? 0, currency)}`
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
function ChannelMixPanel() {
|
|
650
|
+
const t = useTranslation();
|
|
651
|
+
const { conversion } = useMarketingConfig();
|
|
652
|
+
const overview = useMarketingStore((s) => s.overview);
|
|
653
|
+
useEnsureMarketingData();
|
|
654
|
+
const mix = overview?.channelMix ?? [];
|
|
655
|
+
const maxMix = Math.max(...mix.map((m) => m.conversions), 1);
|
|
656
|
+
return /* @__PURE__ */ jsxs(Card, { children: [
|
|
657
|
+
/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsxs(CardTitle, { children: [
|
|
658
|
+
conversion.labelPlural,
|
|
659
|
+
" ",
|
|
660
|
+
t("marketing.overview.byChannelSuffix")
|
|
661
|
+
] }) }),
|
|
662
|
+
/* @__PURE__ */ jsx(CardContent, { className: "space-y-2.5", children: mix.map((m) => /* @__PURE__ */ jsx(
|
|
663
|
+
ProportionBar,
|
|
664
|
+
{
|
|
665
|
+
value: m.conversions,
|
|
666
|
+
max: maxMix,
|
|
667
|
+
color: "#6366f1",
|
|
668
|
+
label: /* @__PURE__ */ jsx(ChannelCell, { channelId: m.channelId }),
|
|
669
|
+
right: /* @__PURE__ */ jsx("span", { children: formatNumber(m.conversions) })
|
|
670
|
+
},
|
|
671
|
+
m.channelId
|
|
672
|
+
)) })
|
|
673
|
+
] });
|
|
674
|
+
}
|
|
675
|
+
function CampaignsTable() {
|
|
676
|
+
const t = useTranslation();
|
|
677
|
+
const { conversion } = useMarketingConfig();
|
|
678
|
+
const campaigns = useMarketingStore((s) => s.campaigns);
|
|
679
|
+
useEnsureMarketingData();
|
|
680
|
+
const columns = [
|
|
681
|
+
{ accessorKey: "name", header: t("marketing.col.campaign"), cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: getValue() }) },
|
|
682
|
+
{ accessorKey: "channelId", header: t("marketing.col.channel"), cell: ({ getValue }) => /* @__PURE__ */ jsx(ChannelCell, { channelId: getValue() }) },
|
|
683
|
+
{ accessorKey: "conversions", header: conversion.labelPlural, cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: formatCompact(getValue()) }) },
|
|
684
|
+
{ 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()) }) },
|
|
685
|
+
{ accessorKey: "status", header: t("marketing.col.status"), cell: ({ getValue }) => /* @__PURE__ */ jsx(StatusBadge, { status: getValue() }) }
|
|
686
|
+
];
|
|
687
|
+
const recent = campaigns.filter((c) => c.status !== "draft").slice(0, 5);
|
|
688
|
+
return /* @__PURE__ */ jsx(TableWidget, { title: t("marketing.overview.recent"), icon: "Megaphone", columns, data: recent, emptyMessage: t("marketing.campaigns.empty") });
|
|
689
|
+
}
|
|
690
|
+
function createMarketingDashboardWidgets(ctx2) {
|
|
691
|
+
const withCtx = (Inner) => {
|
|
692
|
+
const Wrapped = () => /* @__PURE__ */ jsx(MarketingContextProvider, { config: ctx2.config, provider: ctx2.provider, store: ctx2.store, children: /* @__PURE__ */ jsx(Inner, {}) });
|
|
693
|
+
Wrapped.displayName = `MarketingWidget(${Inner.displayName ?? Inner.name})`;
|
|
694
|
+
return Wrapped;
|
|
695
|
+
};
|
|
696
|
+
return [
|
|
697
|
+
// Conversions is marketing's headline KPI on the global home; the rest are
|
|
698
|
+
// home-hidden by default (shown on the marketing plugin-home, addable via Customize).
|
|
699
|
+
defineKpiWidget({ id: "marketing.kpi.conversions", title: "marketing.metric.conversions", domain: "marketing", defaultOrder: 0, component: withCtx(ConversionsKpi) }),
|
|
700
|
+
defineKpiWidget({ id: "marketing.kpi.conversion-rate", title: "marketing.metric.conversionRate", domain: "marketing", defaultOrder: 1, defaultVisible: false, component: withCtx(ConversionRateKpi) }),
|
|
701
|
+
defineKpiWidget({ id: "marketing.kpi.cpa", title: "marketing.metric.cpa", domain: "marketing", defaultOrder: 2, defaultVisible: false, component: withCtx(CpaKpi) }),
|
|
702
|
+
defineKpiWidget({ id: "marketing.kpi.top-channel", title: "marketing.metric.topChannel", domain: "marketing", defaultOrder: 3, defaultVisible: false, component: withCtx(TopChannelKpi) }),
|
|
703
|
+
// Channel-mix + campaigns default to the marketing plugin-home only; the
|
|
704
|
+
// global home stays a clean cross-domain KPI overview.
|
|
705
|
+
defineCustomWidget({ id: "marketing.panel.channel-mix", title: "marketing.overview.byChannelSuffix", domain: "marketing", span: 4, defaultOrder: 10, surfaces: ["plugin-home"], component: withCtx(ChannelMixPanel) }),
|
|
706
|
+
defineTableWidget({ id: "marketing.table.recent-campaigns", title: "marketing.overview.recent", domain: "marketing", span: 4, defaultOrder: 20, surfaces: ["plugin-home"], component: withCtx(CampaignsTable) })
|
|
707
|
+
];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/presets.ts
|
|
711
|
+
var AGENCY = {
|
|
712
|
+
conversion: {
|
|
713
|
+
id: "won-deal",
|
|
714
|
+
label: "Won deal",
|
|
715
|
+
labelPlural: "Won deals",
|
|
716
|
+
valueLabel: "Pipeline value",
|
|
717
|
+
source: "crm",
|
|
718
|
+
avgValue: 3200
|
|
719
|
+
},
|
|
720
|
+
channels: [
|
|
721
|
+
{ id: "paid-search", label: "Paid Search", icon: "Search", kind: "paid" },
|
|
722
|
+
{ id: "paid-social", label: "Paid Social", icon: "Megaphone", kind: "paid" },
|
|
723
|
+
{ id: "organic", label: "Organic / SEO", icon: "Globe", kind: "organic" },
|
|
724
|
+
{ id: "email", label: "Email", icon: "Mail", kind: "outbound" },
|
|
725
|
+
{ id: "referral", label: "Referral", icon: "Users", kind: "referral" },
|
|
726
|
+
{ id: "direct", label: "Direct", icon: "MousePointerClick", kind: "direct" }
|
|
727
|
+
],
|
|
728
|
+
modules: { channels: true, campaigns: true, funnel: true, landingPages: true, broadcasts: false, journeys: false }
|
|
729
|
+
};
|
|
730
|
+
var BEAUTY = {
|
|
731
|
+
conversion: {
|
|
732
|
+
id: "booking",
|
|
733
|
+
label: "Booking",
|
|
734
|
+
labelPlural: "Bookings",
|
|
735
|
+
valueLabel: "Booking revenue",
|
|
736
|
+
source: "agenda",
|
|
737
|
+
avgValue: 85
|
|
738
|
+
},
|
|
739
|
+
channels: [
|
|
740
|
+
{ id: "instagram", label: "Instagram", icon: "Instagram", kind: "social" },
|
|
741
|
+
{ id: "google", label: "Google", icon: "Globe", kind: "organic" },
|
|
742
|
+
{ id: "referral", label: "Referral", icon: "Users", kind: "referral" },
|
|
743
|
+
{ id: "whatsapp", label: "WhatsApp", icon: "MessageCircle", kind: "social" },
|
|
744
|
+
{ id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
|
|
745
|
+
],
|
|
746
|
+
modules: { channels: true, campaigns: true, funnel: true, landingPages: false, broadcasts: false, journeys: false }
|
|
747
|
+
};
|
|
748
|
+
var RESTO = {
|
|
749
|
+
conversion: {
|
|
750
|
+
id: "order",
|
|
751
|
+
label: "Order",
|
|
752
|
+
labelPlural: "Orders",
|
|
753
|
+
valueLabel: "Revenue",
|
|
754
|
+
source: "orders",
|
|
755
|
+
avgValue: 42
|
|
756
|
+
},
|
|
757
|
+
channels: [
|
|
758
|
+
{ id: "ifood", label: "iFood", icon: "Utensils", kind: "paid" },
|
|
759
|
+
{ id: "delivery", label: "Delivery apps", icon: "Bike", kind: "paid" },
|
|
760
|
+
{ id: "google", label: "Google", icon: "Globe", kind: "organic" },
|
|
761
|
+
{ id: "instagram", label: "Instagram", icon: "Instagram", kind: "social" },
|
|
762
|
+
{ id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
|
|
763
|
+
],
|
|
764
|
+
modules: { channels: true, campaigns: true, funnel: true, landingPages: false, broadcasts: false, journeys: false }
|
|
765
|
+
};
|
|
766
|
+
var MARKETING_PRESETS = {
|
|
767
|
+
agency: AGENCY,
|
|
768
|
+
beauty: BEAUTY,
|
|
769
|
+
resto: RESTO
|
|
770
|
+
};
|
|
771
|
+
var CAMPAIGN_NAME_SEEDS = {
|
|
772
|
+
agency: [
|
|
773
|
+
"Q2 Lead-gen \u2014 Search",
|
|
774
|
+
"LinkedIn Retargeting",
|
|
775
|
+
"Webinar Funnel",
|
|
776
|
+
"Newsletter Nurture",
|
|
777
|
+
"Partner Referral Push"
|
|
778
|
+
],
|
|
779
|
+
beauty: [
|
|
780
|
+
"Summer Glow Promo",
|
|
781
|
+
"New Client \u2014 Instagram",
|
|
782
|
+
"Birthday Offer Blast",
|
|
783
|
+
"Win-back 90 days",
|
|
784
|
+
"Google Reviews Boost"
|
|
785
|
+
],
|
|
786
|
+
resto: [
|
|
787
|
+
"iFood Weekend Combo",
|
|
788
|
+
"Happy Hour Promo",
|
|
789
|
+
"New Menu Launch",
|
|
790
|
+
"Loyalty Double Points",
|
|
791
|
+
"Instagram Stories Drop"
|
|
792
|
+
]
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// src/data/mock.ts
|
|
796
|
+
var RANGE_FACTOR = { "7d": 0.25, "30d": 1, "90d": 3.05 };
|
|
797
|
+
var BASE_REACH = [4200, 3100, 2600, 1900, 1500, 1200, 1e3];
|
|
798
|
+
var KIND_LEAD_RATE = {
|
|
799
|
+
paid: 0.18,
|
|
800
|
+
social: 0.22,
|
|
801
|
+
organic: 0.14,
|
|
802
|
+
referral: 0.3,
|
|
803
|
+
direct: 0.1,
|
|
804
|
+
outbound: 0.16
|
|
805
|
+
};
|
|
806
|
+
var KIND_CONV_RATE = {
|
|
807
|
+
paid: 0.28,
|
|
808
|
+
social: 0.25,
|
|
809
|
+
organic: 0.32,
|
|
810
|
+
referral: 0.45,
|
|
811
|
+
direct: 0.22,
|
|
812
|
+
outbound: 0.24
|
|
813
|
+
};
|
|
814
|
+
var KIND_SPEND30 = {
|
|
815
|
+
paid: 2800,
|
|
816
|
+
social: 900,
|
|
817
|
+
outbound: 600,
|
|
818
|
+
organic: 0,
|
|
819
|
+
referral: 0,
|
|
820
|
+
direct: 0
|
|
821
|
+
};
|
|
822
|
+
function channelBase30(channels, conversion) {
|
|
823
|
+
return channels.map((ch, i) => {
|
|
824
|
+
const reach = BASE_REACH[i % BASE_REACH.length];
|
|
825
|
+
const leads = Math.round(reach * KIND_LEAD_RATE[ch.kind]);
|
|
826
|
+
const conversions = Math.round(leads * KIND_CONV_RATE[ch.kind]);
|
|
827
|
+
const spend = KIND_SPEND30[ch.kind];
|
|
828
|
+
const value = Math.round(conversions * conversion.avgValue);
|
|
829
|
+
return { channelId: ch.id, kind: ch.kind, reach, leads, conversions, spend, value };
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
function scale(n, range) {
|
|
833
|
+
return Math.round(n * RANGE_FACTOR[range]);
|
|
834
|
+
}
|
|
835
|
+
function toPerformance(base, range) {
|
|
836
|
+
const reach = scale(base.reach, range);
|
|
837
|
+
const leads = scale(base.leads, range);
|
|
838
|
+
const conversions = scale(base.conversions, range);
|
|
839
|
+
const spend = scale(base.spend, range);
|
|
840
|
+
const value = scale(base.value, range);
|
|
841
|
+
return {
|
|
842
|
+
channelId: base.channelId,
|
|
843
|
+
reach,
|
|
844
|
+
leads,
|
|
845
|
+
conversions,
|
|
846
|
+
spend,
|
|
847
|
+
value,
|
|
848
|
+
cvr: reach > 0 ? conversions / reach * 100 : 0,
|
|
849
|
+
cpa: conversions > 0 && spend > 0 ? spend / conversions : 0
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
function seedCampaigns(cfg) {
|
|
853
|
+
const names = CAMPAIGN_NAME_SEEDS[cfg.domain];
|
|
854
|
+
const base = channelBase30(cfg.channels, cfg.conversion);
|
|
855
|
+
const statuses = ["active", "active", "paused", "ended", "draft"];
|
|
856
|
+
const startDays = [12, 26, 40, 8, 3];
|
|
857
|
+
const baseDate = (/* @__PURE__ */ new Date("2026-06-16T00:00:00Z")).getTime();
|
|
858
|
+
return names.map((name, i) => {
|
|
859
|
+
const ch = cfg.channels[i % cfg.channels.length];
|
|
860
|
+
const b = base[i % base.length];
|
|
861
|
+
const status = statuses[i % statuses.length];
|
|
862
|
+
const draft = status === "draft";
|
|
863
|
+
const start = new Date(baseDate - startDays[i % startDays.length] * 864e5).toISOString();
|
|
864
|
+
const share = 0.55 + i % 3 * 0.12;
|
|
865
|
+
return {
|
|
866
|
+
id: `cmp-${i + 1}`,
|
|
867
|
+
name,
|
|
868
|
+
channelId: ch.id,
|
|
869
|
+
status,
|
|
870
|
+
start,
|
|
871
|
+
end: status === "ended" ? new Date(baseDate - 2 * 864e5).toISOString() : void 0,
|
|
872
|
+
spend: draft ? 0 : Math.round(b.spend * share),
|
|
873
|
+
reach: draft ? 0 : Math.round(b.reach * share),
|
|
874
|
+
leads: draft ? 0 : Math.round(b.leads * share),
|
|
875
|
+
conversions: draft ? 0 : Math.round(b.conversions * share),
|
|
876
|
+
value: draft ? 0 : Math.round(b.value * share)
|
|
877
|
+
};
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
function scaleCampaign(c, range) {
|
|
881
|
+
if (c.status === "draft") return c;
|
|
882
|
+
return {
|
|
883
|
+
...c,
|
|
884
|
+
spend: scale(c.spend, range),
|
|
885
|
+
reach: scale(c.reach, range),
|
|
886
|
+
leads: scale(c.leads, range),
|
|
887
|
+
conversions: scale(c.conversions, range),
|
|
888
|
+
value: scale(c.value, range)
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function seedLandingPages() {
|
|
892
|
+
return [
|
|
893
|
+
{ id: "lp-1", name: "Free Consultation Funnel", type: "Funnel", visits: 3820, conversions: 474 },
|
|
894
|
+
{ id: "lp-2", name: "Summer Promo Landing", type: "Landing page", visits: 1560, conversions: 126 },
|
|
895
|
+
{ id: "lp-3", name: "Pricing Page", type: "Landing page", visits: 2240, conversions: 198 },
|
|
896
|
+
{ id: "lp-4", name: "Company Website", type: "Website", visits: 9240, conversions: 296 }
|
|
897
|
+
];
|
|
898
|
+
}
|
|
899
|
+
function createMockMarketingProvider(cfg) {
|
|
900
|
+
const base = channelBase30(cfg.channels, cfg.conversion);
|
|
901
|
+
const campaigns = seedCampaigns(cfg);
|
|
902
|
+
const landing = seedLandingPages();
|
|
903
|
+
let counter = 100;
|
|
904
|
+
async function channelPerformance(query) {
|
|
905
|
+
const list = base.map((b) => toPerformance(b, query.range));
|
|
906
|
+
return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list;
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
channelPerformance,
|
|
910
|
+
async overview(query) {
|
|
911
|
+
const perf = await channelPerformance(query);
|
|
912
|
+
const reach = perf.reduce((s, c) => s + c.reach, 0);
|
|
913
|
+
const conversions = perf.reduce((s, c) => s + c.conversions, 0);
|
|
914
|
+
const spend = perf.reduce((s, c) => s + c.spend, 0);
|
|
915
|
+
const value = perf.reduce((s, c) => s + c.value, 0);
|
|
916
|
+
const cvr = reach > 0 ? conversions / reach * 100 : 0;
|
|
917
|
+
const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0];
|
|
918
|
+
return {
|
|
919
|
+
conversions,
|
|
920
|
+
conversionsPrev: Math.round(conversions * 0.86),
|
|
921
|
+
cvr,
|
|
922
|
+
cvrPrev: cvr * 0.94,
|
|
923
|
+
spend,
|
|
924
|
+
cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
|
|
925
|
+
value,
|
|
926
|
+
topChannelId: top?.channelId ?? null,
|
|
927
|
+
channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions }))
|
|
928
|
+
};
|
|
929
|
+
},
|
|
930
|
+
async funnel(query) {
|
|
931
|
+
const perf = await channelPerformance(query);
|
|
932
|
+
const reach = perf.reduce((s, c) => s + c.reach, 0);
|
|
933
|
+
const leads = perf.reduce((s, c) => s + c.leads, 0);
|
|
934
|
+
const conversions = perf.reduce((s, c) => s + c.conversions, 0);
|
|
935
|
+
const qualified = Math.round(leads * 0.62);
|
|
936
|
+
return [
|
|
937
|
+
{ id: "reach", label: "Reach / visits", count: reach, color: "#6366f1" },
|
|
938
|
+
{ id: "leads", label: "Leads", count: leads, color: "#0ea5e9" },
|
|
939
|
+
{ id: "qualified", label: "Qualified", count: qualified, color: "#14b8a6" },
|
|
940
|
+
{ id: "converted", label: cfg.conversion.labelPlural, count: conversions, color: "#22c55e" }
|
|
941
|
+
];
|
|
942
|
+
},
|
|
943
|
+
async listCampaigns(query) {
|
|
944
|
+
const list = query.channelId ? campaigns.filter((c) => c.channelId === query.channelId) : campaigns;
|
|
945
|
+
return list.map((c) => scaleCampaign(c, query.range));
|
|
946
|
+
},
|
|
947
|
+
async getCampaign(id) {
|
|
948
|
+
return campaigns.find((c) => c.id === id) ?? null;
|
|
949
|
+
},
|
|
950
|
+
async saveCampaign(input) {
|
|
951
|
+
if (input.id) {
|
|
952
|
+
const existing = campaigns.find((c) => c.id === input.id);
|
|
953
|
+
if (existing) {
|
|
954
|
+
Object.assign(existing, {
|
|
955
|
+
name: input.name,
|
|
956
|
+
channelId: input.channelId,
|
|
957
|
+
status: input.status,
|
|
958
|
+
start: input.start,
|
|
959
|
+
end: input.end,
|
|
960
|
+
spend: input.spend
|
|
961
|
+
});
|
|
962
|
+
return existing;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const b = base.find((x) => x.channelId === input.channelId) ?? base[0];
|
|
966
|
+
const active = input.status === "active";
|
|
967
|
+
const created = {
|
|
968
|
+
id: `cmp-${++counter}`,
|
|
969
|
+
name: input.name,
|
|
970
|
+
channelId: input.channelId,
|
|
971
|
+
status: input.status,
|
|
972
|
+
start: input.start,
|
|
973
|
+
end: input.end,
|
|
974
|
+
spend: input.spend,
|
|
975
|
+
// New campaigns ramp from a small slice of their channel's volume.
|
|
976
|
+
reach: active ? Math.round(b.reach * 0.15) : 0,
|
|
977
|
+
leads: active ? Math.round(b.leads * 0.15) : 0,
|
|
978
|
+
conversions: active ? Math.round(b.conversions * 0.15) : 0,
|
|
979
|
+
value: active ? Math.round(b.value * 0.15) : 0
|
|
980
|
+
};
|
|
981
|
+
campaigns.unshift(created);
|
|
982
|
+
return created;
|
|
983
|
+
},
|
|
984
|
+
async deleteCampaign(id) {
|
|
985
|
+
const idx = campaigns.findIndex((c) => c.id === id);
|
|
986
|
+
if (idx >= 0) campaigns.splice(idx, 1);
|
|
987
|
+
},
|
|
988
|
+
async landingPages(query) {
|
|
989
|
+
return landing.map((p) => {
|
|
990
|
+
const visits = scale(p.visits, query.range);
|
|
991
|
+
const conversions = scale(p.conversions, query.range);
|
|
992
|
+
return { ...p, visits, conversions, cvr: visits > 0 ? conversions / visits * 100 : 0 };
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function createMarketingStore(provider) {
|
|
998
|
+
return createStore((set, get) => ({
|
|
999
|
+
range: "30d",
|
|
1000
|
+
overview: null,
|
|
1001
|
+
channels: [],
|
|
1002
|
+
campaigns: [],
|
|
1003
|
+
funnel: [],
|
|
1004
|
+
landingPages: [],
|
|
1005
|
+
loading: false,
|
|
1006
|
+
async load() {
|
|
1007
|
+
const range = get().range;
|
|
1008
|
+
set({ loading: true });
|
|
1009
|
+
const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
|
|
1010
|
+
provider.overview({ range }),
|
|
1011
|
+
provider.channelPerformance({ range }),
|
|
1012
|
+
provider.listCampaigns({ range }),
|
|
1013
|
+
provider.funnel({ range }),
|
|
1014
|
+
provider.landingPages({ range })
|
|
1015
|
+
]);
|
|
1016
|
+
set({ overview, channels, campaigns, funnel, landingPages, loading: false });
|
|
1017
|
+
},
|
|
1018
|
+
async setRange(range) {
|
|
1019
|
+
set({ range });
|
|
1020
|
+
await get().load();
|
|
1021
|
+
},
|
|
1022
|
+
async saveCampaign(input) {
|
|
1023
|
+
const created = await provider.saveCampaign(input);
|
|
1024
|
+
await get().load();
|
|
1025
|
+
return created;
|
|
1026
|
+
},
|
|
1027
|
+
async deleteCampaign(id) {
|
|
1028
|
+
await provider.deleteCampaign(id);
|
|
1029
|
+
await get().load();
|
|
1030
|
+
}
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// src/locales/en.ts
|
|
1035
|
+
var en = {
|
|
1036
|
+
// metrics
|
|
1037
|
+
"marketing.metric.conversionRate": "Conversion rate",
|
|
1038
|
+
"marketing.metric.cpa": "Cost per acquisition",
|
|
1039
|
+
"marketing.metric.topChannel": "Top channel",
|
|
1040
|
+
"marketing.metric.spend": "spend",
|
|
1041
|
+
"marketing.metric.reach": "Reach",
|
|
1042
|
+
"marketing.metric.cvr": "CVR",
|
|
1043
|
+
"marketing.metric.totalVisits": "Total visits",
|
|
1044
|
+
"marketing.metric.pages": "Pages",
|
|
1045
|
+
"marketing.metric.avgConversion": "Avg. conversion",
|
|
1046
|
+
// columns
|
|
1047
|
+
"marketing.col.campaign": "Campaign",
|
|
1048
|
+
"marketing.col.channel": "Channel",
|
|
1049
|
+
"marketing.col.reach": "Reach",
|
|
1050
|
+
"marketing.col.leads": "Leads",
|
|
1051
|
+
"marketing.col.cvr": "CVR",
|
|
1052
|
+
"marketing.col.spend": "Spend",
|
|
1053
|
+
"marketing.col.cpa": "CPA",
|
|
1054
|
+
"marketing.col.status": "Status",
|
|
1055
|
+
"marketing.col.visits": "Visits",
|
|
1056
|
+
"marketing.col.conversions": "Conversions",
|
|
1057
|
+
"marketing.col.type": "Type",
|
|
1058
|
+
"marketing.col.page": "Page",
|
|
1059
|
+
// overview
|
|
1060
|
+
"marketing.overview.byChannelSuffix": "by channel",
|
|
1061
|
+
"marketing.overview.recent": "Recent campaigns",
|
|
1062
|
+
// channels
|
|
1063
|
+
"marketing.channels.subtitle": "Acquisition performance by channel",
|
|
1064
|
+
"marketing.channels.campaignsOn": "Campaigns on this channel",
|
|
1065
|
+
"marketing.channels.noCampaigns": "No campaigns yet on this channel.",
|
|
1066
|
+
// campaigns
|
|
1067
|
+
"marketing.campaigns.subtitle": "All acquisition campaigns and their conversion performance",
|
|
1068
|
+
"marketing.campaigns.new": "New campaign",
|
|
1069
|
+
"marketing.campaigns.empty": "No campaigns yet \u2014 create your first one.",
|
|
1070
|
+
"marketing.campaigns.delete": "Delete campaign",
|
|
1071
|
+
// composer
|
|
1072
|
+
"marketing.composer.title": "New campaign",
|
|
1073
|
+
"marketing.composer.name": "Campaign name",
|
|
1074
|
+
"marketing.composer.namePlaceholder": "e.g. Spring Promo \u2014 Paid Social",
|
|
1075
|
+
"marketing.composer.channel": "Channel",
|
|
1076
|
+
"marketing.composer.channelPlaceholder": "Select a channel",
|
|
1077
|
+
"marketing.composer.status": "Status",
|
|
1078
|
+
"marketing.composer.budget": "Budget / spend",
|
|
1079
|
+
"marketing.composer.cancel": "Cancel",
|
|
1080
|
+
"marketing.composer.create": "Create campaign",
|
|
1081
|
+
"marketing.status.draft": "draft",
|
|
1082
|
+
"marketing.status.active": "active",
|
|
1083
|
+
"marketing.status.paused": "paused",
|
|
1084
|
+
"marketing.status.ended": "ended",
|
|
1085
|
+
// funnel
|
|
1086
|
+
"marketing.funnel.subtitlePrefix": "Attribution funnel \u2014 reach to",
|
|
1087
|
+
"marketing.funnel.overall": "Overall conversion rate",
|
|
1088
|
+
"marketing.funnel.ofPrev": "of prev",
|
|
1089
|
+
// landing pages
|
|
1090
|
+
"marketing.landing.subtitle": "Per-page visits \u2192 conversions performance",
|
|
1091
|
+
// settings
|
|
1092
|
+
"marketing.settings.conversionModel": "Conversion model",
|
|
1093
|
+
"marketing.settings.conversionModelDesc": "What counts as a conversion in this workspace and where it's attributed from.",
|
|
1094
|
+
"marketing.settings.conversion": "Conversion",
|
|
1095
|
+
"marketing.settings.valueMetric": "Value metric",
|
|
1096
|
+
"marketing.settings.attributedFrom": "Attributed from",
|
|
1097
|
+
"marketing.settings.channels": "Acquisition channels",
|
|
1098
|
+
"marketing.settings.channelsDesc": "Sources tracked for this workspace.",
|
|
1099
|
+
"marketing.settings.trackValue": "Track monetary value",
|
|
1100
|
+
"marketing.settings.trackValueDesc": "Attribute revenue to channels and campaigns.",
|
|
1101
|
+
"marketing.settings.attribution": "Attribution",
|
|
1102
|
+
"marketing.settings.attributionDesc": "How conversions are matched to channels.",
|
|
1103
|
+
"marketing.settings.autoSync": "Auto-sync conversions",
|
|
1104
|
+
"marketing.settings.autoSyncDesc": "Pull conversions automatically from the source.",
|
|
1105
|
+
"marketing.settings.assisted": "Count assisted conversions",
|
|
1106
|
+
"marketing.settings.assistedDesc": "Credit channels that contributed to a conversion.",
|
|
1107
|
+
"marketing.settings.channelTrackDesc": "Track this acquisition source."
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
// src/locales/pt-BR.ts
|
|
1111
|
+
var ptBR = {
|
|
1112
|
+
// metrics
|
|
1113
|
+
"marketing.metric.conversionRate": "Taxa de convers\xE3o",
|
|
1114
|
+
"marketing.metric.cpa": "Custo por aquisi\xE7\xE3o",
|
|
1115
|
+
"marketing.metric.topChannel": "Principal canal",
|
|
1116
|
+
"marketing.metric.spend": "investido",
|
|
1117
|
+
"marketing.metric.reach": "Alcance",
|
|
1118
|
+
"marketing.metric.cvr": "CVR",
|
|
1119
|
+
"marketing.metric.totalVisits": "Total de visitas",
|
|
1120
|
+
"marketing.metric.pages": "P\xE1ginas",
|
|
1121
|
+
"marketing.metric.avgConversion": "Convers\xE3o m\xE9dia",
|
|
1122
|
+
// columns
|
|
1123
|
+
"marketing.col.campaign": "Campanha",
|
|
1124
|
+
"marketing.col.channel": "Canal",
|
|
1125
|
+
"marketing.col.reach": "Alcance",
|
|
1126
|
+
"marketing.col.leads": "Leads",
|
|
1127
|
+
"marketing.col.cvr": "CVR",
|
|
1128
|
+
"marketing.col.spend": "Investimento",
|
|
1129
|
+
"marketing.col.cpa": "CPA",
|
|
1130
|
+
"marketing.col.status": "Status",
|
|
1131
|
+
"marketing.col.visits": "Visitas",
|
|
1132
|
+
"marketing.col.conversions": "Convers\xF5es",
|
|
1133
|
+
"marketing.col.type": "Tipo",
|
|
1134
|
+
"marketing.col.page": "P\xE1gina",
|
|
1135
|
+
// overview
|
|
1136
|
+
"marketing.overview.byChannelSuffix": "por canal",
|
|
1137
|
+
"marketing.overview.recent": "Campanhas recentes",
|
|
1138
|
+
// channels
|
|
1139
|
+
"marketing.channels.subtitle": "Desempenho de aquisi\xE7\xE3o por canal",
|
|
1140
|
+
"marketing.channels.campaignsOn": "Campanhas neste canal",
|
|
1141
|
+
"marketing.channels.noCampaigns": "Nenhuma campanha ainda neste canal.",
|
|
1142
|
+
// campaigns
|
|
1143
|
+
"marketing.campaigns.subtitle": "Todas as campanhas de aquisi\xE7\xE3o e seu desempenho de convers\xE3o",
|
|
1144
|
+
"marketing.campaigns.new": "Nova campanha",
|
|
1145
|
+
"marketing.campaigns.empty": "Nenhuma campanha ainda \u2014 crie a primeira.",
|
|
1146
|
+
"marketing.campaigns.delete": "Excluir campanha",
|
|
1147
|
+
// composer
|
|
1148
|
+
"marketing.composer.title": "Nova campanha",
|
|
1149
|
+
"marketing.composer.name": "Nome da campanha",
|
|
1150
|
+
"marketing.composer.namePlaceholder": "ex.: Promo de Ver\xE3o \u2014 M\xEDdia Paga",
|
|
1151
|
+
"marketing.composer.channel": "Canal",
|
|
1152
|
+
"marketing.composer.channelPlaceholder": "Selecione um canal",
|
|
1153
|
+
"marketing.composer.status": "Status",
|
|
1154
|
+
"marketing.composer.budget": "Or\xE7amento / investimento",
|
|
1155
|
+
"marketing.composer.cancel": "Cancelar",
|
|
1156
|
+
"marketing.composer.create": "Criar campanha",
|
|
1157
|
+
"marketing.status.draft": "rascunho",
|
|
1158
|
+
"marketing.status.active": "ativa",
|
|
1159
|
+
"marketing.status.paused": "pausada",
|
|
1160
|
+
"marketing.status.ended": "encerrada",
|
|
1161
|
+
// funnel
|
|
1162
|
+
"marketing.funnel.subtitlePrefix": "Funil de atribui\xE7\xE3o \u2014 alcance at\xE9",
|
|
1163
|
+
"marketing.funnel.overall": "Taxa de convers\xE3o geral",
|
|
1164
|
+
"marketing.funnel.ofPrev": "do anterior",
|
|
1165
|
+
// landing pages
|
|
1166
|
+
"marketing.landing.subtitle": "Desempenho de visitas \u2192 convers\xF5es por p\xE1gina",
|
|
1167
|
+
// settings
|
|
1168
|
+
"marketing.settings.conversionModel": "Modelo de convers\xE3o",
|
|
1169
|
+
"marketing.settings.conversionModelDesc": "O que conta como convers\xE3o neste workspace e de onde \xE9 atribu\xEDda.",
|
|
1170
|
+
"marketing.settings.conversion": "Convers\xE3o",
|
|
1171
|
+
"marketing.settings.valueMetric": "M\xE9trica de valor",
|
|
1172
|
+
"marketing.settings.attributedFrom": "Atribu\xEDda de",
|
|
1173
|
+
"marketing.settings.channels": "Canais de aquisi\xE7\xE3o",
|
|
1174
|
+
"marketing.settings.channelsDesc": "Fontes monitoradas neste workspace.",
|
|
1175
|
+
"marketing.settings.trackValue": "Acompanhar valor monet\xE1rio",
|
|
1176
|
+
"marketing.settings.trackValueDesc": "Atribuir receita a canais e campanhas.",
|
|
1177
|
+
"marketing.settings.attribution": "Atribui\xE7\xE3o",
|
|
1178
|
+
"marketing.settings.attributionDesc": "Como as convers\xF5es s\xE3o associadas aos canais.",
|
|
1179
|
+
"marketing.settings.autoSync": "Sincronizar convers\xF5es automaticamente",
|
|
1180
|
+
"marketing.settings.autoSyncDesc": "Importar convers\xF5es automaticamente da fonte.",
|
|
1181
|
+
"marketing.settings.assisted": "Contar convers\xF5es assistidas",
|
|
1182
|
+
"marketing.settings.assistedDesc": "Creditar canais que contribu\xEDram para uma convers\xE3o.",
|
|
1183
|
+
"marketing.settings.channelTrackDesc": "Monitorar esta fonte de aquisi\xE7\xE3o."
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
// src/locales/index.ts
|
|
1187
|
+
var marketingLocales = {
|
|
1188
|
+
en,
|
|
1189
|
+
"pt-BR": ptBR
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
// src/index.ts
|
|
1193
|
+
var DEFAULT_LABELS = {
|
|
1194
|
+
pageTitle: "Marketing",
|
|
1195
|
+
pageSubtitle: "Acquisition & conversion performance",
|
|
1196
|
+
overview: "Overview",
|
|
1197
|
+
channels: "Channels",
|
|
1198
|
+
campaigns: "Campaigns",
|
|
1199
|
+
funnel: "Funnel",
|
|
1200
|
+
landingPages: "Landing pages",
|
|
1201
|
+
settings: "Settings"
|
|
1202
|
+
};
|
|
1203
|
+
function resolveConfig(options) {
|
|
1204
|
+
const domain = options?.domain ?? "agency";
|
|
1205
|
+
const preset = MARKETING_PRESETS[domain];
|
|
1206
|
+
const config = {
|
|
1207
|
+
conversion: options?.conversion ?? preset.conversion,
|
|
1208
|
+
channels: options?.channels ?? preset.channels,
|
|
1209
|
+
modules: { ...preset.modules, ...options?.modules },
|
|
1210
|
+
currency: { ...DEFAULT_CURRENCY, ...options?.currency },
|
|
1211
|
+
labels: { ...DEFAULT_LABELS, ...options?.labels }
|
|
1212
|
+
};
|
|
1213
|
+
return { config, domain };
|
|
1214
|
+
}
|
|
1215
|
+
function createSafeProvider(config, domain) {
|
|
1216
|
+
void getSupabaseClientOptional();
|
|
1217
|
+
return createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain });
|
|
1218
|
+
}
|
|
1219
|
+
function createMarketingPlugin(options) {
|
|
1220
|
+
registerTranslations(marketingLocales);
|
|
1221
|
+
const { config, domain } = resolveConfig(options);
|
|
1222
|
+
const provider = options?.dataProvider ?? createSafeProvider(config, domain);
|
|
1223
|
+
const store = createMarketingStore(provider);
|
|
1224
|
+
const dashboardWidgets = createMarketingDashboardWidgets({ config, provider, store });
|
|
1225
|
+
const PageComponent = () => React4.createElement(
|
|
1226
|
+
MarketingContextProvider,
|
|
1227
|
+
{ config, provider, store },
|
|
1228
|
+
React4.createElement(MarketingPage, { config, provider, store })
|
|
1229
|
+
);
|
|
1230
|
+
PageComponent.displayName = "MarketingPage";
|
|
1231
|
+
const SettingsComponent = () => React4.createElement(
|
|
1232
|
+
MarketingContextProvider,
|
|
1233
|
+
{ config, provider, store },
|
|
1234
|
+
React4.createElement(SettingsView)
|
|
1235
|
+
);
|
|
1236
|
+
SettingsComponent.displayName = "MarketingSettings";
|
|
1237
|
+
return {
|
|
1238
|
+
id: "marketing",
|
|
1239
|
+
name: options?.navLabel ?? config.labels.pageTitle,
|
|
1240
|
+
icon: "Megaphone",
|
|
1241
|
+
version: "1.0.0",
|
|
1242
|
+
scope: options?.scope ?? "universal",
|
|
1243
|
+
verticalId: options?.verticalId,
|
|
1244
|
+
defaultEnabled: true,
|
|
1245
|
+
dependencies: [],
|
|
1246
|
+
declaredFeatures: [
|
|
1247
|
+
{ id: "marketing", label: config.labels.pageTitle, group: "Engage" },
|
|
1248
|
+
...config.modules.landingPages ? [{ id: "marketing.landing-pages", label: config.labels.landingPages, group: "Engage" }] : []
|
|
1249
|
+
],
|
|
1250
|
+
navigation: [
|
|
1251
|
+
{
|
|
1252
|
+
section: options?.navSection ?? "main",
|
|
1253
|
+
position: options?.navPosition ?? 5,
|
|
1254
|
+
label: options?.navLabel ?? config.labels.pageTitle,
|
|
1255
|
+
route: "/marketing",
|
|
1256
|
+
icon: "Megaphone",
|
|
1257
|
+
permission: { feature: "marketing", action: "read" }
|
|
1258
|
+
}
|
|
1259
|
+
],
|
|
1260
|
+
routes: [
|
|
1261
|
+
{
|
|
1262
|
+
path: "/marketing",
|
|
1263
|
+
component: PageComponent,
|
|
1264
|
+
permission: { feature: "marketing", action: "read" }
|
|
1265
|
+
}
|
|
1266
|
+
],
|
|
1267
|
+
widgets: [],
|
|
1268
|
+
dashboardWidgets,
|
|
1269
|
+
events: [
|
|
1270
|
+
{ name: "marketing.conversion.tracked", description: "A conversion was attributed to a channel/campaign" },
|
|
1271
|
+
{ name: "marketing.campaign.created", description: "A campaign was created" },
|
|
1272
|
+
{ name: "marketing.campaign.updated", description: "A campaign was updated" },
|
|
1273
|
+
{ name: "marketing.channel.synced", description: "A channel pulled fresh attribution data" }
|
|
1274
|
+
],
|
|
1275
|
+
aiTools: [
|
|
1276
|
+
{
|
|
1277
|
+
id: "marketing.channel-performance",
|
|
1278
|
+
name: "channelPerformance",
|
|
1279
|
+
description: "Returns acquisition performance per channel (reach, conversions, CVR, spend, CPA).",
|
|
1280
|
+
icon: "Radio",
|
|
1281
|
+
mode: "read",
|
|
1282
|
+
category: "Marketing",
|
|
1283
|
+
parameters: {
|
|
1284
|
+
type: "object",
|
|
1285
|
+
properties: { range: { type: "string", enum: ["7d", "30d", "90d"] } }
|
|
1286
|
+
},
|
|
1287
|
+
suggestions: [{ label: "Which channel converts best?" }, { label: "What is my cost per acquisition?" }],
|
|
1288
|
+
permission: { feature: "marketing", action: "read" }
|
|
1289
|
+
},
|
|
1290
|
+
{
|
|
1291
|
+
id: "marketing.top-channels",
|
|
1292
|
+
name: "topChannels",
|
|
1293
|
+
description: "Returns the top acquisition channels ranked by conversions.",
|
|
1294
|
+
icon: "Trophy",
|
|
1295
|
+
mode: "read",
|
|
1296
|
+
category: "Marketing",
|
|
1297
|
+
permission: { feature: "marketing", action: "read" }
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
id: "marketing.campaign-cvr",
|
|
1301
|
+
name: "campaignCvr",
|
|
1302
|
+
description: "Lists campaigns with their conversion rate for a date range.",
|
|
1303
|
+
icon: "Percent",
|
|
1304
|
+
mode: "read",
|
|
1305
|
+
category: "Marketing",
|
|
1306
|
+
parameters: {
|
|
1307
|
+
type: "object",
|
|
1308
|
+
properties: { range: { type: "string", enum: ["7d", "30d", "90d"] } }
|
|
1309
|
+
},
|
|
1310
|
+
permission: { feature: "marketing", action: "read" }
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
id: "marketing.create-campaign",
|
|
1314
|
+
name: "createCampaign",
|
|
1315
|
+
description: "Creates an acquisition campaign on a channel.",
|
|
1316
|
+
icon: "Plus",
|
|
1317
|
+
mode: "persist",
|
|
1318
|
+
category: "Marketing",
|
|
1319
|
+
parameters: {
|
|
1320
|
+
type: "object",
|
|
1321
|
+
properties: {
|
|
1322
|
+
name: { type: "string", description: "Campaign name" },
|
|
1323
|
+
channelId: { type: "string", description: "Acquisition channel id" }
|
|
1324
|
+
},
|
|
1325
|
+
required: ["name", "channelId"]
|
|
1326
|
+
},
|
|
1327
|
+
permission: { feature: "marketing", action: "create" }
|
|
1328
|
+
}
|
|
1329
|
+
],
|
|
1330
|
+
settings: [
|
|
1331
|
+
{
|
|
1332
|
+
id: "marketing",
|
|
1333
|
+
label: config.labels.pageTitle,
|
|
1334
|
+
icon: "Megaphone",
|
|
1335
|
+
component: SettingsComponent,
|
|
1336
|
+
order: 20,
|
|
1337
|
+
permission: { feature: "marketing", action: "read" }
|
|
1338
|
+
}
|
|
1339
|
+
],
|
|
1340
|
+
locales: marketingLocales
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
export { MARKETING_PRESETS, createMarketingPlugin, createMockMarketingProvider };
|
|
1345
|
+
//# sourceMappingURL=index.js.map
|
|
1346
|
+
//# sourceMappingURL=index.js.map
|