@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
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StoreApi } from 'zustand';
|
|
2
|
+
import type { DashboardWidgetDef } from '@fayz-ai/core';
|
|
3
|
+
import { type ResolvedMarketingConfig } from '../MarketingContext';
|
|
4
|
+
import type { MarketingDataProvider } from '../data/types';
|
|
5
|
+
import type { MarketingUIState } from '../store';
|
|
6
|
+
export declare function createMarketingDashboardWidgets(ctx: {
|
|
7
|
+
config: ResolvedMarketingConfig;
|
|
8
|
+
provider: MarketingDataProvider;
|
|
9
|
+
store: StoreApi<MarketingUIState>;
|
|
10
|
+
}): DashboardWidgetDef[];
|
|
11
|
+
//# sourceMappingURL=dashboardWidgets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboardWidgets.d.ts","sourceRoot":"","sources":["../../src/views/dashboardWidgets.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AACvC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAMvD,OAAO,EAEL,KAAK,uBAAuB,EAC7B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAC1D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AAkIhD,wBAAgB,+BAA+B,CAAC,GAAG,EAAE;IACnD,MAAM,EAAE,uBAAuB,CAAA;IAC/B,QAAQ,EAAE,qBAAqB,CAAA;IAC/B,KAAK,EAAE,QAAQ,CAAC,gBAAgB,CAAC,CAAA;CAClC,GAAG,kBAAkB,EAAE,CAuBvB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fayz-ai/plugin-marketing",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Fayz SDK — marketing campaigns & broadcasts plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"source": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
23
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@tanstack/react-table": "^8.20.0",
|
|
27
|
+
"lucide-react": "^0.400.0",
|
|
28
|
+
"zustand": "^4.5.0",
|
|
29
|
+
"@fayz-ai/ui": "^0.1.6",
|
|
30
|
+
"@fayz-ai/saas": "^0.1.6",
|
|
31
|
+
"@fayz-ai/core": "^0.1.6"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^18.3.0",
|
|
35
|
+
"react": "^18.3.0",
|
|
36
|
+
"tsup": "^8.2.0",
|
|
37
|
+
"typescript": "^5.5.0",
|
|
38
|
+
"@types/react-dom": "^18.3.0"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"fayz",
|
|
43
|
+
"fayz-plugin",
|
|
44
|
+
"fayz-sdk"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
|
|
51
|
+
"dev": "tsup --watch",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"clean": "rm -rf dist"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { createPluginContext } from '@fayz-ai/saas'
|
|
3
|
+
import type { AcquisitionChannel, ConversionModel } from './types'
|
|
4
|
+
import type { MarketingCurrency } from './format'
|
|
5
|
+
import type { MarketingDomainModules } from './presets'
|
|
6
|
+
import type { MarketingDataProvider } from './data/types'
|
|
7
|
+
import type { MarketingUIState } from './store'
|
|
8
|
+
|
|
9
|
+
export interface MarketingLabels {
|
|
10
|
+
pageTitle: string
|
|
11
|
+
pageSubtitle: string
|
|
12
|
+
overview: string
|
|
13
|
+
channels: string
|
|
14
|
+
campaigns: string
|
|
15
|
+
funnel: string
|
|
16
|
+
landingPages: string
|
|
17
|
+
settings: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResolvedMarketingConfig {
|
|
21
|
+
conversion: ConversionModel
|
|
22
|
+
channels: AcquisitionChannel[]
|
|
23
|
+
modules: MarketingDomainModules
|
|
24
|
+
currency: MarketingCurrency
|
|
25
|
+
labels: MarketingLabels
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ctx = createPluginContext<ResolvedMarketingConfig, MarketingDataProvider, MarketingUIState>('MarketingPage')
|
|
29
|
+
|
|
30
|
+
export const MarketingContextProvider = ctx.ContextProvider
|
|
31
|
+
export const useMarketingConfig = ctx.useConfig
|
|
32
|
+
export const useMarketingProvider = ctx.useProvider
|
|
33
|
+
export const useMarketingStore = ctx.useStore
|
|
34
|
+
|
|
35
|
+
/** Convenience: look up a channel definition by id. */
|
|
36
|
+
export function useChannelLookup(): Map<string, AcquisitionChannel> {
|
|
37
|
+
const { channels } = useMarketingConfig()
|
|
38
|
+
return React.useMemo(() => new Map(channels.map((c) => [c.id, c])), [channels])
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { StoreApi } from 'zustand/vanilla'
|
|
3
|
+
import { ModulePage, type ModuleNavItem } from '@fayz-ai/ui'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
+
import type { PluginQuickAction } from '@fayz-ai/core'
|
|
6
|
+
import { useModuleNavigation, ModuleActionBar, createViewRouter } from '@fayz-ai/saas'
|
|
7
|
+
import { MarketingContextProvider, type ResolvedMarketingConfig } from './MarketingContext'
|
|
8
|
+
import type { MarketingDataProvider } from './data/types'
|
|
9
|
+
import type { MarketingUIState } from './store'
|
|
10
|
+
import { OverviewView } from './views/OverviewView'
|
|
11
|
+
import { ChannelsView } from './views/ChannelsView'
|
|
12
|
+
import { ChannelDetailView } from './views/ChannelDetailView'
|
|
13
|
+
import { CampaignsView } from './views/CampaignsView'
|
|
14
|
+
import { FunnelView } from './views/FunnelView'
|
|
15
|
+
import { LandingPagesView } from './views/LandingPagesView'
|
|
16
|
+
|
|
17
|
+
function buildNav(
|
|
18
|
+
config: ResolvedMarketingConfig,
|
|
19
|
+
view: string,
|
|
20
|
+
navigate: (v: string) => void,
|
|
21
|
+
): ModuleNavItem[] {
|
|
22
|
+
const items: ModuleNavItem[] = [
|
|
23
|
+
{ id: 'overview', label: config.labels.overview, icon: 'BarChart3', active: view === 'overview', onClick: () => navigate('overview') },
|
|
24
|
+
]
|
|
25
|
+
if (config.modules.channels) {
|
|
26
|
+
items.push({
|
|
27
|
+
id: 'channels', label: config.labels.channels, icon: 'Radio',
|
|
28
|
+
active: view === 'channels' || view.startsWith('channel-detail:'),
|
|
29
|
+
onClick: () => navigate('channels'),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
if (config.modules.campaigns) {
|
|
33
|
+
items.push({ id: 'campaigns', label: config.labels.campaigns, icon: 'Megaphone', active: view === 'campaigns', onClick: () => navigate('campaigns') })
|
|
34
|
+
}
|
|
35
|
+
if (config.modules.funnel) {
|
|
36
|
+
items.push({ id: 'funnel', label: config.labels.funnel, icon: 'Filter', active: view === 'funnel', onClick: () => navigate('funnel') })
|
|
37
|
+
}
|
|
38
|
+
if (config.modules.landingPages) {
|
|
39
|
+
items.push({ id: 'landing-pages', label: config.labels.landingPages, icon: 'LayoutTemplate', active: view === 'landing-pages', onClick: () => navigate('landing-pages') })
|
|
40
|
+
}
|
|
41
|
+
return items
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function MarketingPage({ config, provider, store }: {
|
|
45
|
+
config: ResolvedMarketingConfig
|
|
46
|
+
provider: MarketingDataProvider
|
|
47
|
+
store: StoreApi<MarketingUIState>
|
|
48
|
+
}) {
|
|
49
|
+
const t = useTranslation()
|
|
50
|
+
const { view, direction, navigate } = useModuleNavigation('/marketing', {
|
|
51
|
+
overview: 0, channels: 0, campaigns: 0, funnel: 0, 'landing-pages': 0,
|
|
52
|
+
'channel-detail': 1,
|
|
53
|
+
}, 'overview')
|
|
54
|
+
|
|
55
|
+
React.useEffect(() => { void store.getState().load() }, [store])
|
|
56
|
+
|
|
57
|
+
const isOverview = view === 'overview'
|
|
58
|
+
const nav = buildNav(config, view, navigate)
|
|
59
|
+
|
|
60
|
+
const quickActions = React.useMemo<PluginQuickAction[]>(() => [
|
|
61
|
+
{ id: 'new-campaign', label: t('marketing.campaigns.new'), icon: 'Megaphone', description: config.labels.campaigns, action: () => navigate('campaigns') },
|
|
62
|
+
], [t, config.labels.campaigns])
|
|
63
|
+
|
|
64
|
+
const renderView = createViewRouter([
|
|
65
|
+
{ id: 'channels', render: () => <ChannelsView onOpen={(id) => navigate(`channel-detail:${id}`)} /> },
|
|
66
|
+
{ id: 'channel-detail', render: ({ id }) => <ChannelDetailView channelId={id!} onBack={() => navigate('channels')} /> },
|
|
67
|
+
{ id: 'campaigns', render: () => <CampaignsView /> },
|
|
68
|
+
{ id: 'funnel', render: () => <FunnelView /> },
|
|
69
|
+
{ id: 'landing-pages', render: () => <LandingPagesView /> },
|
|
70
|
+
{ id: 'overview', render: () => <OverviewView /> },
|
|
71
|
+
], 'overview')
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<MarketingContextProvider config={config} provider={provider} store={store}>
|
|
75
|
+
<ModulePage
|
|
76
|
+
title={config.labels.pageTitle}
|
|
77
|
+
subtitle={config.labels.pageSubtitle}
|
|
78
|
+
nav={nav}
|
|
79
|
+
showHeader={isOverview}
|
|
80
|
+
viewKey={view}
|
|
81
|
+
direction={direction}
|
|
82
|
+
headerAction={
|
|
83
|
+
<ModuleActionBar
|
|
84
|
+
quickActions={quickActions}
|
|
85
|
+
settingsPath="/settings/marketing"
|
|
86
|
+
settingsLabel={config.labels.pageTitle}
|
|
87
|
+
/>
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
{renderView(view)}
|
|
91
|
+
</ModulePage>
|
|
92
|
+
</MarketingContextProvider>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { ArrowDownRight, ArrowUpRight } from 'lucide-react'
|
|
3
|
+
import { cn, SegmentedControl } from '@fayz-ai/ui'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
+
import type { CampaignStatus, DateRangeKey } from '../types'
|
|
6
|
+
import { trendDelta } from '../format'
|
|
7
|
+
import { useMarketingStore } from '../MarketingContext'
|
|
8
|
+
import { ChannelIcon } from './icons'
|
|
9
|
+
import { useChannelLookup } from '../MarketingContext'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// KPI card with optional trend chip.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export function KpiCard({
|
|
16
|
+
icon: Icon, label, value, sub, current, previous, invertTrend,
|
|
17
|
+
}: {
|
|
18
|
+
icon: React.ComponentType<{ className?: string }>
|
|
19
|
+
label: string
|
|
20
|
+
value: string
|
|
21
|
+
sub?: string
|
|
22
|
+
current?: number
|
|
23
|
+
previous?: number
|
|
24
|
+
/** for cost-style metrics where down is good (e.g. CPA) */
|
|
25
|
+
invertTrend?: boolean
|
|
26
|
+
}) {
|
|
27
|
+
const trend = current != null && previous != null ? trendDelta(current, previous) : null
|
|
28
|
+
const good = trend ? (invertTrend ? trend.dir === 'down' : trend.dir === 'up') : false
|
|
29
|
+
const bad = trend ? (invertTrend ? trend.dir === 'up' : trend.dir === 'down') : false
|
|
30
|
+
return (
|
|
31
|
+
<div className="rounded-card border border-border bg-card p-4">
|
|
32
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
33
|
+
<Icon className="h-4 w-4" />
|
|
34
|
+
<span className="text-xs font-medium">{label}</span>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="mt-2 flex items-end justify-between gap-2">
|
|
37
|
+
<p className="text-2xl font-semibold text-foreground">{value}</p>
|
|
38
|
+
{trend && trend.dir !== 'neutral' && (
|
|
39
|
+
<span className={cn(
|
|
40
|
+
'mb-0.5 inline-flex items-center gap-0.5 text-xs font-medium',
|
|
41
|
+
good && 'text-emerald-600', bad && 'text-rose-600',
|
|
42
|
+
!good && !bad && 'text-muted-foreground',
|
|
43
|
+
)}>
|
|
44
|
+
{trend.dir === 'up' ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
|
|
45
|
+
{trend.pct.toFixed(0)}%
|
|
46
|
+
</span>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
{sub && <p className="mt-0.5 text-xs text-muted-foreground">{sub}</p>}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Date-range selector (segmented). Reads/writes the shared store range.
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const RANGES: DateRangeKey[] = ['7d', '30d', '90d']
|
|
59
|
+
|
|
60
|
+
export function RangeTabs() {
|
|
61
|
+
const range = useMarketingStore((s) => s.range)
|
|
62
|
+
const setRange = useMarketingStore((s) => s.setRange)
|
|
63
|
+
return (
|
|
64
|
+
<SegmentedControl
|
|
65
|
+
options={RANGES}
|
|
66
|
+
value={range}
|
|
67
|
+
onChange={(r) => void setRange(r)}
|
|
68
|
+
aria-label="Time range"
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Status badge for campaigns.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const STATUS_STYLE: Record<CampaignStatus, string> = {
|
|
78
|
+
active: 'bg-emerald-100 text-emerald-700',
|
|
79
|
+
paused: 'bg-amber-100 text-amber-700',
|
|
80
|
+
ended: 'bg-muted text-muted-foreground',
|
|
81
|
+
draft: 'bg-slate-100 text-slate-600',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function StatusBadge({ status }: { status: CampaignStatus }) {
|
|
85
|
+
const t = useTranslation()
|
|
86
|
+
return (
|
|
87
|
+
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', STATUS_STYLE[status])}>
|
|
88
|
+
{t(`marketing.status.${status}`)}
|
|
89
|
+
</span>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Channel cell (icon + label) used in tables.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export function ChannelCell({ channelId }: { channelId: string }) {
|
|
98
|
+
const lookup = useChannelLookup()
|
|
99
|
+
const ch = lookup.get(channelId)
|
|
100
|
+
return (
|
|
101
|
+
<span className="inline-flex items-center gap-1.5 text-foreground">
|
|
102
|
+
<ChannelIcon name={ch?.icon ?? 'Link2'} className="h-3.5 w-3.5 text-muted-foreground" />
|
|
103
|
+
{ch?.label ?? channelId}
|
|
104
|
+
</span>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Horizontal proportion bar (funnel / channel-mix), no charting dep.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export function ProportionBar({ value, max, color, label, right }: {
|
|
113
|
+
value: number
|
|
114
|
+
max: number
|
|
115
|
+
color: string
|
|
116
|
+
label: React.ReactNode
|
|
117
|
+
right?: React.ReactNode
|
|
118
|
+
}) {
|
|
119
|
+
const pct = max > 0 ? Math.max((value / max) * 100, 2) : 0
|
|
120
|
+
return (
|
|
121
|
+
<div className="flex items-center gap-3">
|
|
122
|
+
<div className="w-32 shrink-0 text-sm text-muted-foreground">{label}</div>
|
|
123
|
+
<div className="h-7 flex-1 overflow-hidden rounded-md bg-muted/40">
|
|
124
|
+
<div className="flex h-full items-center rounded-md px-2 text-xs font-semibold"
|
|
125
|
+
style={{ width: `${pct}%`, backgroundColor: color + '26', color }}>
|
|
126
|
+
{right}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Search, Megaphone, Globe, Mail, Users, MousePointerClick,
|
|
4
|
+
Instagram, MessageCircle, DoorOpen, Utensils, Bike, Link2,
|
|
5
|
+
} from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
// Channel icon names referenced by presets → lucide components. Falls back to
|
|
8
|
+
// a generic link icon for anything unmapped.
|
|
9
|
+
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
10
|
+
Search, Megaphone, Globe, Mail, Users, MousePointerClick,
|
|
11
|
+
Instagram, MessageCircle, DoorOpen, Utensils, Bike,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ChannelIcon({ name, className }: { name: string; className?: string }) {
|
|
15
|
+
const Icon = ICONS[name] ?? Link2
|
|
16
|
+
return <Icon className={className} />
|
|
17
|
+
}
|
package/src/data/mock.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { MarketingDataProvider, SaveCampaignInput } from './types'
|
|
2
|
+
import type {
|
|
3
|
+
AcquisitionChannel,
|
|
4
|
+
AcquisitionChannelKind,
|
|
5
|
+
Campaign,
|
|
6
|
+
ChannelPerformance,
|
|
7
|
+
ConversionModel,
|
|
8
|
+
DateRangeKey,
|
|
9
|
+
FunnelStage,
|
|
10
|
+
LandingPagePerf,
|
|
11
|
+
MarketingOverview,
|
|
12
|
+
MarketingQuery,
|
|
13
|
+
} from '../types'
|
|
14
|
+
import { CAMPAIGN_NAME_SEEDS, type MarketingDomain } from '../presets'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Deterministic seed knobs (no Date.now at module init for stable analytics).
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const RANGE_FACTOR: Record<DateRangeKey, number> = { '7d': 0.25, '30d': 1, '90d': 3.05 }
|
|
21
|
+
const BASE_REACH = [4200, 3100, 2600, 1900, 1500, 1200, 1000]
|
|
22
|
+
|
|
23
|
+
const KIND_LEAD_RATE: Record<AcquisitionChannelKind, number> = {
|
|
24
|
+
paid: 0.18, social: 0.22, organic: 0.14, referral: 0.3, direct: 0.1, outbound: 0.16,
|
|
25
|
+
}
|
|
26
|
+
const KIND_CONV_RATE: Record<AcquisitionChannelKind, number> = {
|
|
27
|
+
paid: 0.28, social: 0.25, organic: 0.32, referral: 0.45, direct: 0.22, outbound: 0.24,
|
|
28
|
+
}
|
|
29
|
+
const KIND_SPEND30: Record<AcquisitionChannelKind, number> = {
|
|
30
|
+
paid: 2800, social: 900, outbound: 600, organic: 0, referral: 0, direct: 0,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ChannelBase {
|
|
34
|
+
channelId: string
|
|
35
|
+
kind: AcquisitionChannelKind
|
|
36
|
+
reach: number
|
|
37
|
+
leads: number
|
|
38
|
+
conversions: number
|
|
39
|
+
spend: number
|
|
40
|
+
value: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MockMarketingConfig {
|
|
44
|
+
channels: AcquisitionChannel[]
|
|
45
|
+
conversion: ConversionModel
|
|
46
|
+
domain: MarketingDomain
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function channelBase30(channels: AcquisitionChannel[], conversion: ConversionModel): ChannelBase[] {
|
|
50
|
+
return channels.map((ch, i) => {
|
|
51
|
+
const reach = BASE_REACH[i % BASE_REACH.length]
|
|
52
|
+
const leads = Math.round(reach * KIND_LEAD_RATE[ch.kind])
|
|
53
|
+
const conversions = Math.round(leads * KIND_CONV_RATE[ch.kind])
|
|
54
|
+
const spend = KIND_SPEND30[ch.kind]
|
|
55
|
+
const value = Math.round(conversions * conversion.avgValue)
|
|
56
|
+
return { channelId: ch.id, kind: ch.kind, reach, leads, conversions, spend, value }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function scale(n: number, range: DateRangeKey): number {
|
|
61
|
+
return Math.round(n * RANGE_FACTOR[range])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toPerformance(base: ChannelBase, range: DateRangeKey): ChannelPerformance {
|
|
65
|
+
const reach = scale(base.reach, range)
|
|
66
|
+
const leads = scale(base.leads, range)
|
|
67
|
+
const conversions = scale(base.conversions, range)
|
|
68
|
+
const spend = scale(base.spend, range)
|
|
69
|
+
const value = scale(base.value, range)
|
|
70
|
+
return {
|
|
71
|
+
channelId: base.channelId,
|
|
72
|
+
reach, leads, conversions, spend, value,
|
|
73
|
+
cvr: reach > 0 ? (conversions / reach) * 100 : 0,
|
|
74
|
+
cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function seedCampaigns(cfg: MockMarketingConfig): Campaign[] {
|
|
79
|
+
const names = CAMPAIGN_NAME_SEEDS[cfg.domain]
|
|
80
|
+
const base = channelBase30(cfg.channels, cfg.conversion)
|
|
81
|
+
const statuses: Campaign['status'][] = ['active', 'active', 'paused', 'ended', 'draft']
|
|
82
|
+
const startDays = [12, 26, 40, 8, 3]
|
|
83
|
+
const baseDate = new Date('2026-06-16T00:00:00Z').getTime()
|
|
84
|
+
|
|
85
|
+
return names.map((name, i) => {
|
|
86
|
+
const ch = cfg.channels[i % cfg.channels.length]
|
|
87
|
+
const b = base[i % base.length]
|
|
88
|
+
const status = statuses[i % statuses.length]
|
|
89
|
+
const draft = status === 'draft'
|
|
90
|
+
const start = new Date(baseDate - startDays[i % startDays.length] * 86_400_000).toISOString()
|
|
91
|
+
// Campaigns carry a slice of their channel's volume so totals stay plausible.
|
|
92
|
+
const share = 0.55 + (i % 3) * 0.12
|
|
93
|
+
return {
|
|
94
|
+
id: `cmp-${i + 1}`,
|
|
95
|
+
name,
|
|
96
|
+
channelId: ch.id,
|
|
97
|
+
status,
|
|
98
|
+
start,
|
|
99
|
+
end: status === 'ended' ? new Date(baseDate - 2 * 86_400_000).toISOString() : undefined,
|
|
100
|
+
spend: draft ? 0 : Math.round(b.spend * share),
|
|
101
|
+
reach: draft ? 0 : Math.round(b.reach * share),
|
|
102
|
+
leads: draft ? 0 : Math.round(b.leads * share),
|
|
103
|
+
conversions: draft ? 0 : Math.round(b.conversions * share),
|
|
104
|
+
value: draft ? 0 : Math.round(b.value * share),
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function scaleCampaign(c: Campaign, range: DateRangeKey): Campaign {
|
|
110
|
+
if (c.status === 'draft') return c
|
|
111
|
+
return {
|
|
112
|
+
...c,
|
|
113
|
+
spend: scale(c.spend, range),
|
|
114
|
+
reach: scale(c.reach, range),
|
|
115
|
+
leads: scale(c.leads, range),
|
|
116
|
+
conversions: scale(c.conversions, range),
|
|
117
|
+
value: scale(c.value, range),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function seedLandingPages(): Array<Omit<LandingPagePerf, 'cvr'>> {
|
|
122
|
+
// 30d baseline; scaled per range at read time.
|
|
123
|
+
return [
|
|
124
|
+
{ id: 'lp-1', name: 'Free Consultation Funnel', type: 'Funnel', visits: 3820, conversions: 474 },
|
|
125
|
+
{ id: 'lp-2', name: 'Summer Promo Landing', type: 'Landing page', visits: 1560, conversions: 126 },
|
|
126
|
+
{ id: 'lp-3', name: 'Pricing Page', type: 'Landing page', visits: 2240, conversions: 198 },
|
|
127
|
+
{ id: 'lp-4', name: 'Company Website', type: 'Website', visits: 9240, conversions: 296 },
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createMockMarketingProvider(cfg: MockMarketingConfig): MarketingDataProvider {
|
|
132
|
+
const base = channelBase30(cfg.channels, cfg.conversion)
|
|
133
|
+
const campaigns = seedCampaigns(cfg)
|
|
134
|
+
const landing = seedLandingPages()
|
|
135
|
+
let counter = 100
|
|
136
|
+
|
|
137
|
+
async function channelPerformance(query: MarketingQuery): Promise<ChannelPerformance[]> {
|
|
138
|
+
const list = base.map((b) => toPerformance(b, query.range))
|
|
139
|
+
return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
channelPerformance,
|
|
144
|
+
|
|
145
|
+
async overview(query: MarketingQuery): Promise<MarketingOverview> {
|
|
146
|
+
const perf = await channelPerformance(query)
|
|
147
|
+
const reach = perf.reduce((s, c) => s + c.reach, 0)
|
|
148
|
+
const conversions = perf.reduce((s, c) => s + c.conversions, 0)
|
|
149
|
+
const spend = perf.reduce((s, c) => s + c.spend, 0)
|
|
150
|
+
const value = perf.reduce((s, c) => s + c.value, 0)
|
|
151
|
+
const cvr = reach > 0 ? (conversions / reach) * 100 : 0
|
|
152
|
+
const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0]
|
|
153
|
+
return {
|
|
154
|
+
conversions,
|
|
155
|
+
conversionsPrev: Math.round(conversions * 0.86),
|
|
156
|
+
cvr,
|
|
157
|
+
cvrPrev: cvr * 0.94,
|
|
158
|
+
spend,
|
|
159
|
+
cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
|
|
160
|
+
value,
|
|
161
|
+
topChannelId: top?.channelId ?? null,
|
|
162
|
+
channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions })),
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async funnel(query: MarketingQuery): Promise<FunnelStage[]> {
|
|
167
|
+
const perf = await channelPerformance(query)
|
|
168
|
+
const reach = perf.reduce((s, c) => s + c.reach, 0)
|
|
169
|
+
const leads = perf.reduce((s, c) => s + c.leads, 0)
|
|
170
|
+
const conversions = perf.reduce((s, c) => s + c.conversions, 0)
|
|
171
|
+
const qualified = Math.round(leads * 0.62)
|
|
172
|
+
return [
|
|
173
|
+
{ id: 'reach', label: 'Reach / visits', count: reach, color: '#6366f1' },
|
|
174
|
+
{ id: 'leads', label: 'Leads', count: leads, color: '#0ea5e9' },
|
|
175
|
+
{ id: 'qualified', label: 'Qualified', count: qualified, color: '#14b8a6' },
|
|
176
|
+
{ id: 'converted', label: cfg.conversion.labelPlural, count: conversions, color: '#22c55e' },
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async listCampaigns(query: MarketingQuery): Promise<Campaign[]> {
|
|
181
|
+
const list = query.channelId ? campaigns.filter((c) => c.channelId === query.channelId) : campaigns
|
|
182
|
+
return list.map((c) => scaleCampaign(c, query.range))
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async getCampaign(id: string): Promise<Campaign | null> {
|
|
186
|
+
return campaigns.find((c) => c.id === id) ?? null
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async saveCampaign(input: SaveCampaignInput): Promise<Campaign> {
|
|
190
|
+
if (input.id) {
|
|
191
|
+
const existing = campaigns.find((c) => c.id === input.id)
|
|
192
|
+
if (existing) {
|
|
193
|
+
Object.assign(existing, {
|
|
194
|
+
name: input.name, channelId: input.channelId, status: input.status,
|
|
195
|
+
start: input.start, end: input.end, spend: input.spend,
|
|
196
|
+
})
|
|
197
|
+
return existing
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const b = base.find((x) => x.channelId === input.channelId) ?? base[0]
|
|
201
|
+
const active = input.status === 'active'
|
|
202
|
+
const created: Campaign = {
|
|
203
|
+
id: `cmp-${++counter}`,
|
|
204
|
+
name: input.name,
|
|
205
|
+
channelId: input.channelId,
|
|
206
|
+
status: input.status,
|
|
207
|
+
start: input.start,
|
|
208
|
+
end: input.end,
|
|
209
|
+
spend: input.spend,
|
|
210
|
+
// New campaigns ramp from a small slice of their channel's volume.
|
|
211
|
+
reach: active ? Math.round(b.reach * 0.15) : 0,
|
|
212
|
+
leads: active ? Math.round(b.leads * 0.15) : 0,
|
|
213
|
+
conversions: active ? Math.round(b.conversions * 0.15) : 0,
|
|
214
|
+
value: active ? Math.round(b.value * 0.15) : 0,
|
|
215
|
+
}
|
|
216
|
+
campaigns.unshift(created)
|
|
217
|
+
return created
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async deleteCampaign(id: string): Promise<void> {
|
|
221
|
+
const idx = campaigns.findIndex((c) => c.id === id)
|
|
222
|
+
if (idx >= 0) campaigns.splice(idx, 1)
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async landingPages(query: MarketingQuery): Promise<LandingPagePerf[]> {
|
|
226
|
+
return landing.map((p) => {
|
|
227
|
+
const visits = scale(p.visits, query.range)
|
|
228
|
+
const conversions = scale(p.conversions, query.range)
|
|
229
|
+
return { ...p, visits, conversions, cvr: visits > 0 ? (conversions / visits) * 100 : 0 }
|
|
230
|
+
})
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Campaign,
|
|
3
|
+
CampaignStatus,
|
|
4
|
+
ChannelPerformance,
|
|
5
|
+
FunnelStage,
|
|
6
|
+
LandingPagePerf,
|
|
7
|
+
MarketingOverview,
|
|
8
|
+
MarketingQuery,
|
|
9
|
+
} from '../types'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Provider — everything the marketing surface reads/writes. The mock provider
|
|
13
|
+
// computes analytics from a vertical-flavored seed; a Supabase-backed provider
|
|
14
|
+
// lands later. Attribution + sites data can also come from sibling plugins via
|
|
15
|
+
// the bridge seams below (defined now, injected later).
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface SaveCampaignInput {
|
|
19
|
+
id?: string
|
|
20
|
+
name: string
|
|
21
|
+
channelId: string
|
|
22
|
+
status: CampaignStatus
|
|
23
|
+
start: string
|
|
24
|
+
end?: string
|
|
25
|
+
spend: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MarketingDataProvider {
|
|
29
|
+
overview(query: MarketingQuery): Promise<MarketingOverview>
|
|
30
|
+
channelPerformance(query: MarketingQuery): Promise<ChannelPerformance[]>
|
|
31
|
+
listCampaigns(query: MarketingQuery): Promise<Campaign[]>
|
|
32
|
+
getCampaign(id: string): Promise<Campaign | null>
|
|
33
|
+
saveCampaign(input: SaveCampaignInput): Promise<Campaign>
|
|
34
|
+
deleteCampaign(id: string): Promise<void>
|
|
35
|
+
funnel(query: MarketingQuery): Promise<FunnelStage[]>
|
|
36
|
+
landingPages(query: MarketingQuery): Promise<LandingPagePerf[]>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Bridge seams (optional DI). Apps inject these to swap mock numbers for real
|
|
41
|
+
// conversions read from plugin-crm / plugin-agenda / plugin-orders, and real
|
|
42
|
+
// landing-page performance read from plugin-sites. Mirrors the agenda↔financial
|
|
43
|
+
// bridge pattern (plugins compose via DI, never direct imports).
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface AttributionBridge {
|
|
47
|
+
conversionsByChannel(
|
|
48
|
+
query: MarketingQuery,
|
|
49
|
+
): Promise<Array<{ channelId: string; conversions: number; value: number }>>
|
|
50
|
+
funnel(query: MarketingQuery): Promise<FunnelStage[]>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SitesPerformanceBridge {
|
|
54
|
+
landingPages(query: MarketingQuery): Promise<LandingPagePerf[]>
|
|
55
|
+
}
|