@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.
Files changed (74) hide show
  1. package/dist/MarketingContext.d.ts +35 -0
  2. package/dist/MarketingContext.d.ts.map +1 -0
  3. package/dist/MarketingPage.d.ts +11 -0
  4. package/dist/MarketingPage.d.ts.map +1 -0
  5. package/dist/components/MarketingBits.d.ts +29 -0
  6. package/dist/components/MarketingBits.d.ts.map +1 -0
  7. package/dist/components/icons.d.ts +6 -0
  8. package/dist/components/icons.d.ts.map +1 -0
  9. package/dist/data/mock.d.ts +10 -0
  10. package/dist/data/mock.d.ts.map +1 -0
  11. package/dist/data/types.d.ts +32 -0
  12. package/dist/data/types.d.ts.map +1 -0
  13. package/dist/format.d.ts +17 -0
  14. package/dist/format.d.ts.map +1 -0
  15. package/dist/index.cjs +1354 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.ts +33 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +1346 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/locales/en.d.ts +2 -0
  22. package/dist/locales/en.d.ts.map +1 -0
  23. package/dist/locales/index.d.ts +2 -0
  24. package/dist/locales/index.d.ts.map +1 -0
  25. package/dist/locales/pt-BR.d.ts +2 -0
  26. package/dist/locales/pt-BR.d.ts.map +1 -0
  27. package/dist/presets.d.ts +20 -0
  28. package/dist/presets.d.ts.map +1 -0
  29. package/dist/store.d.ts +18 -0
  30. package/dist/store.d.ts.map +1 -0
  31. package/dist/types.d.ts +89 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/views/CampaignComposer.d.ts +6 -0
  34. package/dist/views/CampaignComposer.d.ts.map +1 -0
  35. package/dist/views/CampaignsView.d.ts +3 -0
  36. package/dist/views/CampaignsView.d.ts.map +1 -0
  37. package/dist/views/ChannelDetailView.d.ts +6 -0
  38. package/dist/views/ChannelDetailView.d.ts.map +1 -0
  39. package/dist/views/ChannelsView.d.ts +5 -0
  40. package/dist/views/ChannelsView.d.ts.map +1 -0
  41. package/dist/views/FunnelView.d.ts +3 -0
  42. package/dist/views/FunnelView.d.ts.map +1 -0
  43. package/dist/views/LandingPagesView.d.ts +3 -0
  44. package/dist/views/LandingPagesView.d.ts.map +1 -0
  45. package/dist/views/OverviewView.d.ts +9 -0
  46. package/dist/views/OverviewView.d.ts.map +1 -0
  47. package/dist/views/SettingsView.d.ts +3 -0
  48. package/dist/views/SettingsView.d.ts.map +1 -0
  49. package/dist/views/dashboardWidgets.d.ts +11 -0
  50. package/dist/views/dashboardWidgets.d.ts.map +1 -0
  51. package/package.json +55 -0
  52. package/src/MarketingContext.tsx +39 -0
  53. package/src/MarketingPage.tsx +94 -0
  54. package/src/components/MarketingBits.tsx +131 -0
  55. package/src/components/icons.tsx +17 -0
  56. package/src/data/mock.ts +233 -0
  57. package/src/data/types.ts +55 -0
  58. package/src/format.ts +40 -0
  59. package/src/index.ts +225 -0
  60. package/src/locales/en.ts +74 -0
  61. package/src/locales/index.ts +7 -0
  62. package/src/locales/pt-BR.ts +74 -0
  63. package/src/presets.ts +115 -0
  64. package/src/store.ts +66 -0
  65. package/src/types.ts +114 -0
  66. package/src/views/CampaignComposer.tsx +89 -0
  67. package/src/views/CampaignsView.tsx +56 -0
  68. package/src/views/ChannelDetailView.tsx +59 -0
  69. package/src/views/ChannelsView.tsx +46 -0
  70. package/src/views/FunnelView.tsx +56 -0
  71. package/src/views/LandingPagesView.tsx +49 -0
  72. package/src/views/OverviewView.tsx +24 -0
  73. package/src/views/SettingsView.tsx +72 -0
  74. 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
+ }
@@ -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
+ }