@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/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @fayz-ai/plugin-marketing — domain model for ACQUISITION & CONVERSION
|
|
3
|
+
// analytics. The plugin is vertical-agnostic: each app supplies a
|
|
4
|
+
// `ConversionModel` (what a conversion is + where it's read from) and a set of
|
|
5
|
+
// `AcquisitionChannel`s, and the same analytics engine adapts.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export type AcquisitionChannelKind =
|
|
9
|
+
| 'paid'
|
|
10
|
+
| 'organic'
|
|
11
|
+
| 'social'
|
|
12
|
+
| 'referral'
|
|
13
|
+
| 'direct'
|
|
14
|
+
| 'outbound'
|
|
15
|
+
|
|
16
|
+
export interface AcquisitionChannel {
|
|
17
|
+
id: string
|
|
18
|
+
label: string
|
|
19
|
+
/** lucide-react icon name (resolved in components/icons.ts) */
|
|
20
|
+
icon: string
|
|
21
|
+
kind: AcquisitionChannelKind
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where attribution reads conversions from (per vertical). */
|
|
25
|
+
export type ConversionSource = 'crm' | 'agenda' | 'orders' | 'custom'
|
|
26
|
+
|
|
27
|
+
export interface ConversionModel {
|
|
28
|
+
id: string
|
|
29
|
+
/** singular noun for one conversion, e.g. 'Won deal' | 'Booking' | 'Order' */
|
|
30
|
+
label: string
|
|
31
|
+
labelPlural: string
|
|
32
|
+
/** label for the monetary value, e.g. 'Pipeline value' | 'Revenue' */
|
|
33
|
+
valueLabel: string
|
|
34
|
+
source: ConversionSource
|
|
35
|
+
/** average monetary value of a single conversion (mock + estimates) */
|
|
36
|
+
avgValue: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CampaignStatus = 'active' | 'paused' | 'ended' | 'draft'
|
|
40
|
+
|
|
41
|
+
/** An acquisition campaign running on a channel, with attributed performance. */
|
|
42
|
+
export interface Campaign {
|
|
43
|
+
id: string
|
|
44
|
+
name: string
|
|
45
|
+
channelId: string
|
|
46
|
+
status: CampaignStatus
|
|
47
|
+
start: string
|
|
48
|
+
end?: string
|
|
49
|
+
spend: number
|
|
50
|
+
/** visits / sessions / sends reached (NOT raw impressions) */
|
|
51
|
+
reach: number
|
|
52
|
+
leads: number
|
|
53
|
+
conversions: number
|
|
54
|
+
/** attributed monetary value */
|
|
55
|
+
value: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Per-channel rollup used by the Channels view + Overview. */
|
|
59
|
+
export interface ChannelPerformance {
|
|
60
|
+
channelId: string
|
|
61
|
+
reach: number
|
|
62
|
+
leads: number
|
|
63
|
+
conversions: number
|
|
64
|
+
spend: number
|
|
65
|
+
value: number
|
|
66
|
+
/** conversions / reach * 100 */
|
|
67
|
+
cvr: number
|
|
68
|
+
/** spend / conversions (0 when no spend) */
|
|
69
|
+
cpa: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FunnelStage {
|
|
73
|
+
id: string
|
|
74
|
+
label: string
|
|
75
|
+
count: number
|
|
76
|
+
color: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LandingPagePerf {
|
|
80
|
+
id: string
|
|
81
|
+
name: string
|
|
82
|
+
type: 'Funnel' | 'Landing page' | 'Website'
|
|
83
|
+
visits: number
|
|
84
|
+
conversions: number
|
|
85
|
+
/** conversions / visits * 100 */
|
|
86
|
+
cvr: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface MarketingOverview {
|
|
90
|
+
conversions: number
|
|
91
|
+
conversionsPrev: number
|
|
92
|
+
cvr: number
|
|
93
|
+
cvrPrev: number
|
|
94
|
+
spend: number
|
|
95
|
+
cpa: number
|
|
96
|
+
value: number
|
|
97
|
+
topChannelId: string | null
|
|
98
|
+
/** conversions per channel for the mix bars */
|
|
99
|
+
channelMix: Array<{ channelId: string; conversions: number }>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type DateRangeKey = '7d' | '30d' | '90d'
|
|
103
|
+
|
|
104
|
+
export interface MarketingQuery {
|
|
105
|
+
range: DateRangeKey
|
|
106
|
+
/** optionally scope to a single channel (channel detail) */
|
|
107
|
+
channelId?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const DATE_RANGE_LABELS: Record<DateRangeKey, string> = {
|
|
111
|
+
'7d': 'Last 7 days',
|
|
112
|
+
'30d': 'Last 30 days',
|
|
113
|
+
'90d': 'Last 90 days',
|
|
114
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter,
|
|
4
|
+
Button, Input,
|
|
5
|
+
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
6
|
+
} from '@fayz-ai/ui'
|
|
7
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
8
|
+
import { useMarketingConfig, useMarketingStore } from '../MarketingContext'
|
|
9
|
+
import type { CampaignStatus } from '../types'
|
|
10
|
+
|
|
11
|
+
const STATUSES: CampaignStatus[] = ['draft', 'active', 'paused']
|
|
12
|
+
|
|
13
|
+
export function CampaignComposer({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
14
|
+
const t = useTranslation()
|
|
15
|
+
const { channels } = useMarketingConfig()
|
|
16
|
+
const saveCampaign = useMarketingStore((s) => s.saveCampaign)
|
|
17
|
+
|
|
18
|
+
const [name, setName] = React.useState('')
|
|
19
|
+
const [channelId, setChannelId] = React.useState(channels[0]?.id ?? '')
|
|
20
|
+
const [status, setStatus] = React.useState<CampaignStatus>('active')
|
|
21
|
+
const [spend, setSpend] = React.useState('')
|
|
22
|
+
const [saving, setSaving] = React.useState(false)
|
|
23
|
+
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
if (open) {
|
|
26
|
+
setName(''); setChannelId(channels[0]?.id ?? ''); setStatus('active'); setSpend('')
|
|
27
|
+
}
|
|
28
|
+
}, [open, channels])
|
|
29
|
+
|
|
30
|
+
async function handleSave() {
|
|
31
|
+
if (!name.trim() || !channelId) return
|
|
32
|
+
setSaving(true)
|
|
33
|
+
await saveCampaign({
|
|
34
|
+
name: name.trim(),
|
|
35
|
+
channelId,
|
|
36
|
+
status,
|
|
37
|
+
start: new Date().toISOString(),
|
|
38
|
+
spend: Number(spend) || 0,
|
|
39
|
+
})
|
|
40
|
+
setSaving(false)
|
|
41
|
+
onClose()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Modal open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
|
46
|
+
<ModalContent size="md">
|
|
47
|
+
<ModalHeader>
|
|
48
|
+
<ModalTitle>{t('marketing.composer.title')}</ModalTitle>
|
|
49
|
+
</ModalHeader>
|
|
50
|
+
<ModalBody className="space-y-4">
|
|
51
|
+
<label className="block space-y-1.5">
|
|
52
|
+
<span className="text-sm font-medium text-foreground">{t('marketing.composer.name')}</span>
|
|
53
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('marketing.composer.namePlaceholder')} />
|
|
54
|
+
</label>
|
|
55
|
+
|
|
56
|
+
<label className="block space-y-1.5">
|
|
57
|
+
<span className="text-sm font-medium text-foreground">{t('marketing.composer.channel')}</span>
|
|
58
|
+
<Select value={channelId} onValueChange={setChannelId}>
|
|
59
|
+
<SelectTrigger><SelectValue placeholder={t('marketing.composer.channelPlaceholder')} /></SelectTrigger>
|
|
60
|
+
<SelectContent>
|
|
61
|
+
{channels.map((c) => <SelectItem key={c.id} value={c.id}>{c.label}</SelectItem>)}
|
|
62
|
+
</SelectContent>
|
|
63
|
+
</Select>
|
|
64
|
+
</label>
|
|
65
|
+
|
|
66
|
+
<div className="grid grid-cols-2 gap-3">
|
|
67
|
+
<label className="block space-y-1.5">
|
|
68
|
+
<span className="text-sm font-medium text-foreground">{t('marketing.composer.status')}</span>
|
|
69
|
+
<Select value={status} onValueChange={(v) => setStatus(v as CampaignStatus)}>
|
|
70
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
71
|
+
<SelectContent>
|
|
72
|
+
{STATUSES.map((s) => <SelectItem key={s} value={s} className="capitalize">{t(`marketing.status.${s}`)}</SelectItem>)}
|
|
73
|
+
</SelectContent>
|
|
74
|
+
</Select>
|
|
75
|
+
</label>
|
|
76
|
+
<label className="block space-y-1.5">
|
|
77
|
+
<span className="text-sm font-medium text-foreground">{t('marketing.composer.budget')}</span>
|
|
78
|
+
<Input type="number" min={0} value={spend} onChange={(e) => setSpend(e.target.value)} placeholder="0" />
|
|
79
|
+
</label>
|
|
80
|
+
</div>
|
|
81
|
+
</ModalBody>
|
|
82
|
+
<ModalFooter>
|
|
83
|
+
<Button variant="outline" onClick={onClose}>{t('marketing.composer.cancel')}</Button>
|
|
84
|
+
<Button onClick={handleSave} disabled={saving || !name.trim()}>{t('marketing.composer.create')}</Button>
|
|
85
|
+
</ModalFooter>
|
|
86
|
+
</ModalContent>
|
|
87
|
+
</Modal>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Plus, Trash2 } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import { Button, DataTable } from '@fayz-ai/ui'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import { useMarketingConfig, useMarketingStore } from '../MarketingContext'
|
|
7
|
+
import { formatCurrency, formatNumber, formatPercent } from '../format'
|
|
8
|
+
import { RangeTabs, ChannelCell, StatusBadge } from '../components/MarketingBits'
|
|
9
|
+
import { CampaignComposer } from './CampaignComposer'
|
|
10
|
+
import type { Campaign } from '../types'
|
|
11
|
+
|
|
12
|
+
export function CampaignsView() {
|
|
13
|
+
const t = useTranslation()
|
|
14
|
+
const { conversion, currency } = useMarketingConfig()
|
|
15
|
+
const campaigns = useMarketingStore((s) => s.campaigns)
|
|
16
|
+
const deleteCampaign = useMarketingStore((s) => s.deleteCampaign)
|
|
17
|
+
const [composerOpen, setComposerOpen] = React.useState(false)
|
|
18
|
+
|
|
19
|
+
const columns = React.useMemo<ColumnDef<Campaign, any>[]>(() => [
|
|
20
|
+
{ accessorKey: 'name', header: t('marketing.col.campaign'), cell: ({ getValue }) => <span className="font-medium text-foreground">{getValue() as string}</span> },
|
|
21
|
+
{ accessorKey: 'channelId', header: t('marketing.col.channel'), cell: ({ getValue }) => <ChannelCell channelId={getValue() as string} /> },
|
|
22
|
+
{ accessorKey: 'reach', header: t('marketing.col.reach'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatNumber(getValue() as number)}</span> },
|
|
23
|
+
{ accessorKey: 'conversions', header: conversion.labelPlural, cell: ({ getValue }) => <span className="font-medium text-foreground">{formatNumber(getValue() as number)}</span> },
|
|
24
|
+
{ id: 'cvr', accessorFn: (c) => (c.reach > 0 ? (c.conversions / c.reach) * 100 : 0), header: t('marketing.col.cvr'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatPercent(getValue() as number)}</span> },
|
|
25
|
+
{ accessorKey: 'spend', header: t('marketing.col.spend'), cell: ({ getValue }) => { const v = getValue() as number; return <span className="text-muted-foreground">{v > 0 ? formatCurrency(v, currency) : '—'}</span> } },
|
|
26
|
+
{ accessorKey: 'status', header: t('marketing.col.status'), cell: ({ getValue }) => <StatusBadge status={getValue() as Campaign['status']} /> },
|
|
27
|
+
{
|
|
28
|
+
id: 'actions', header: '', enableSorting: false,
|
|
29
|
+
cell: ({ row }) => (
|
|
30
|
+
<button
|
|
31
|
+
onClick={(e) => { e.stopPropagation(); void deleteCampaign(row.original.id) }}
|
|
32
|
+
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
33
|
+
title={t('marketing.campaigns.delete')}
|
|
34
|
+
>
|
|
35
|
+
<Trash2 className="h-4 w-4" />
|
|
36
|
+
</button>
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
], [t, conversion.labelPlural, currency, deleteCampaign])
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="mx-auto max-w-6xl space-y-4">
|
|
43
|
+
<div className="flex items-center justify-between">
|
|
44
|
+
<p className="text-sm text-muted-foreground">{t('marketing.campaigns.subtitle')}</p>
|
|
45
|
+
<div className="flex items-center gap-2">
|
|
46
|
+
<RangeTabs />
|
|
47
|
+
<Button onClick={() => setComposerOpen(true)}><Plus className="mr-1.5 h-4 w-4" /> {t('marketing.campaigns.new')}</Button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<DataTable columns={columns} data={campaigns} emptyMessage={t('marketing.campaigns.empty')} />
|
|
52
|
+
|
|
53
|
+
<CampaignComposer open={composerOpen} onClose={() => setComposerOpen(false)} />
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Target, Percent, DollarSign, Users } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import { DataTable, SubpageHeader } from '@fayz-ai/ui'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import { useMarketingConfig, useMarketingStore, useChannelLookup, useMarketingProvider } from '../MarketingContext'
|
|
7
|
+
import { formatCurrency, formatNumber, formatPercent } from '../format'
|
|
8
|
+
import { KpiCard, StatusBadge } from '../components/MarketingBits'
|
|
9
|
+
import type { Campaign } from '../types'
|
|
10
|
+
|
|
11
|
+
export function ChannelDetailView({ channelId, onBack }: { channelId: string; onBack: () => void }) {
|
|
12
|
+
const t = useTranslation()
|
|
13
|
+
const { conversion, currency, labels } = useMarketingConfig()
|
|
14
|
+
const range = useMarketingStore((s) => s.range)
|
|
15
|
+
const channels = useMarketingStore((s) => s.channels)
|
|
16
|
+
const lookup = useChannelLookup()
|
|
17
|
+
const provider = useMarketingProvider()
|
|
18
|
+
const [campaigns, setCampaigns] = React.useState<Campaign[]>([])
|
|
19
|
+
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
void provider.listCampaigns({ range, channelId }).then(setCampaigns)
|
|
22
|
+
}, [provider, range, channelId])
|
|
23
|
+
|
|
24
|
+
const perf = channels.find((c) => c.channelId === channelId)
|
|
25
|
+
const channel = lookup.get(channelId)
|
|
26
|
+
|
|
27
|
+
const columns = React.useMemo<ColumnDef<Campaign, any>[]>(() => [
|
|
28
|
+
{ accessorKey: 'name', header: t('marketing.col.campaign'), cell: ({ getValue }) => <span className="font-medium text-foreground">{getValue() as string}</span> },
|
|
29
|
+
{ accessorKey: 'conversions', header: conversion.labelPlural, cell: ({ getValue }) => <span className="text-muted-foreground">{formatNumber(getValue() as number)}</span> },
|
|
30
|
+
{ id: 'cvr', accessorFn: (c) => (c.reach > 0 ? (c.conversions / c.reach) * 100 : 0), header: t('marketing.col.cvr'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatPercent(getValue() as number)}</span> },
|
|
31
|
+
{ accessorKey: 'spend', header: t('marketing.col.spend'), cell: ({ getValue }) => { const v = getValue() as number; return <span className="text-muted-foreground">{v > 0 ? formatCurrency(v, currency) : '—'}</span> } },
|
|
32
|
+
{ accessorKey: 'status', header: t('marketing.col.status'), cell: ({ getValue }) => <StatusBadge status={getValue() as Campaign['status']} /> },
|
|
33
|
+
], [t, conversion.labelPlural, currency])
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="mx-auto max-w-6xl space-y-6">
|
|
37
|
+
<SubpageHeader
|
|
38
|
+
title={channel?.label ?? channelId}
|
|
39
|
+
subtitle={channel?.kind}
|
|
40
|
+
onBack={onBack}
|
|
41
|
+
parentLabel={labels.channels}
|
|
42
|
+
/>
|
|
43
|
+
|
|
44
|
+
{perf && (
|
|
45
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
46
|
+
<KpiCard icon={Users} label={t('marketing.metric.reach')} value={formatNumber(perf.reach)} />
|
|
47
|
+
<KpiCard icon={Target} label={conversion.labelPlural} value={formatNumber(perf.conversions)} />
|
|
48
|
+
<KpiCard icon={Percent} label={t('marketing.metric.cvr')} value={formatPercent(perf.cvr)} />
|
|
49
|
+
<KpiCard icon={DollarSign} label={t('marketing.col.cpa')} value={perf.cpa > 0 ? formatCurrency(perf.cpa, currency) : '—'} sub={perf.spend > 0 ? `${formatCurrency(perf.spend, currency)} ${t('marketing.metric.spend')}` : '—'} />
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<div className="space-y-2">
|
|
54
|
+
<h2 className="text-sm font-semibold text-foreground">{t('marketing.channels.campaignsOn')}</h2>
|
|
55
|
+
<DataTable columns={columns} data={campaigns} emptyMessage={t('marketing.channels.noCampaigns')} />
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { ChevronRight } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import { DataTable } from '@fayz-ai/ui'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import { useMarketingConfig, useMarketingStore } from '../MarketingContext'
|
|
7
|
+
import { formatCurrency, formatNumber, formatPercent } from '../format'
|
|
8
|
+
import { RangeTabs, ChannelCell } from '../components/MarketingBits'
|
|
9
|
+
import type { ChannelPerformance } from '../types'
|
|
10
|
+
|
|
11
|
+
export function ChannelsView({ onOpen }: { onOpen: (channelId: string) => void }) {
|
|
12
|
+
const t = useTranslation()
|
|
13
|
+
const { conversion, currency } = useMarketingConfig()
|
|
14
|
+
const channels = useMarketingStore((s) => s.channels)
|
|
15
|
+
|
|
16
|
+
// Default ordering by conversions; DataTable lets the user re-sort any column.
|
|
17
|
+
const sorted = [...channels].sort((a, b) => b.conversions - a.conversions)
|
|
18
|
+
|
|
19
|
+
const columns = React.useMemo<ColumnDef<ChannelPerformance, any>[]>(() => [
|
|
20
|
+
{ accessorKey: 'channelId', header: t('marketing.col.channel'), cell: ({ getValue }) => <ChannelCell channelId={getValue() as string} /> },
|
|
21
|
+
{ accessorKey: 'reach', header: t('marketing.col.reach'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatNumber(getValue() as number)}</span> },
|
|
22
|
+
{ accessorKey: 'leads', header: t('marketing.col.leads'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatNumber(getValue() as number)}</span> },
|
|
23
|
+
{ accessorKey: 'conversions', header: conversion.labelPlural, cell: ({ getValue }) => <span className="font-medium text-foreground">{formatNumber(getValue() as number)}</span> },
|
|
24
|
+
{ accessorKey: 'cvr', header: t('marketing.col.cvr'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatPercent(getValue() as number)}</span> },
|
|
25
|
+
{ accessorKey: 'spend', header: t('marketing.col.spend'), cell: ({ getValue }) => { const v = getValue() as number; return <span className="text-muted-foreground">{v > 0 ? formatCurrency(v, currency) : '—'}</span> } },
|
|
26
|
+
{ accessorKey: 'cpa', header: t('marketing.col.cpa'), cell: ({ getValue }) => { const v = getValue() as number; return <span className="text-muted-foreground">{v > 0 ? formatCurrency(v, currency) : '—'}</span> } },
|
|
27
|
+
{ accessorKey: 'value', header: conversion.valueLabel, cell: ({ getValue }) => <span className="text-muted-foreground">{formatCurrency(getValue() as number, currency)}</span> },
|
|
28
|
+
{ id: 'open', header: '', enableSorting: false, cell: () => <ChevronRight className="h-4 w-4 text-muted-foreground" /> },
|
|
29
|
+
], [t, conversion.labelPlural, conversion.valueLabel, currency])
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="mx-auto max-w-6xl space-y-4">
|
|
33
|
+
<div className="flex items-center justify-between">
|
|
34
|
+
<p className="text-sm text-muted-foreground">{t('marketing.channels.subtitle')}</p>
|
|
35
|
+
<RangeTabs />
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<DataTable
|
|
39
|
+
columns={columns}
|
|
40
|
+
data={sorted}
|
|
41
|
+
onRowClick={(row) => onOpen(row.channelId)}
|
|
42
|
+
emptyMessage={t('marketing.channels.subtitle')}
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
3
|
+
import { useMarketingConfig, useMarketingStore } from '../MarketingContext'
|
|
4
|
+
import { formatNumber, formatPercent } from '../format'
|
|
5
|
+
import { RangeTabs } from '../components/MarketingBits'
|
|
6
|
+
|
|
7
|
+
export function FunnelView() {
|
|
8
|
+
const t = useTranslation()
|
|
9
|
+
const { conversion } = useMarketingConfig()
|
|
10
|
+
const funnel = useMarketingStore((s) => s.funnel)
|
|
11
|
+
const max = Math.max(...funnel.map((s) => s.count), 1)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="mx-auto max-w-4xl space-y-4">
|
|
15
|
+
<div className="flex items-center justify-between">
|
|
16
|
+
<p className="text-sm text-muted-foreground">
|
|
17
|
+
{t('marketing.funnel.subtitlePrefix')} {conversion.label.toLowerCase()}
|
|
18
|
+
</p>
|
|
19
|
+
<RangeTabs />
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div className="rounded-card border border-border bg-card p-5">
|
|
23
|
+
<div className="space-y-3">
|
|
24
|
+
{funnel.map((stage, i) => {
|
|
25
|
+
const pct = max > 0 ? Math.max((stage.count / max) * 100, 4) : 0
|
|
26
|
+
const prev = funnel[i - 1]
|
|
27
|
+
const stepRate = prev && prev.count > 0 ? (stage.count / prev.count) * 100 : null
|
|
28
|
+
return (
|
|
29
|
+
<div key={stage.id}>
|
|
30
|
+
<div className="mb-1 flex items-center justify-between text-sm">
|
|
31
|
+
<span className="font-medium text-foreground">{stage.label}</span>
|
|
32
|
+
<span className="text-muted-foreground">
|
|
33
|
+
{formatNumber(stage.count)}
|
|
34
|
+
{stepRate != null && <span className="ml-2 text-xs">({formatPercent(stepRate, 0)} {t('marketing.funnel.ofPrev')})</span>}
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
<div className="h-9 overflow-hidden rounded-md bg-muted/40">
|
|
38
|
+
<div className="h-full rounded-md" style={{ width: `${pct}%`, backgroundColor: stage.color + '33', borderLeft: `3px solid ${stage.color}` }} />
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
})}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div className="mt-5 border-t border-border pt-4 text-sm">
|
|
46
|
+
<span className="text-muted-foreground">{t('marketing.funnel.overall')}: </span>
|
|
47
|
+
<span className="font-semibold text-foreground">
|
|
48
|
+
{funnel.length > 1 && funnel[0].count > 0
|
|
49
|
+
? formatPercent((funnel[funnel.length - 1].count / funnel[0].count) * 100)
|
|
50
|
+
: '—'}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { MousePointerClick, Layers, Percent } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import { DataTable } from '@fayz-ai/ui'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import { useMarketingStore } from '../MarketingContext'
|
|
7
|
+
import { formatNumber, formatPercent } from '../format'
|
|
8
|
+
import { KpiCard, RangeTabs } from '../components/MarketingBits'
|
|
9
|
+
import type { LandingPagePerf } from '../types'
|
|
10
|
+
|
|
11
|
+
const TYPE_STYLE: Record<string, string> = {
|
|
12
|
+
Funnel: 'bg-indigo-100 text-indigo-700',
|
|
13
|
+
'Landing page': 'bg-pink-100 text-pink-700',
|
|
14
|
+
Website: 'bg-sky-100 text-sky-700',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function LandingPagesView() {
|
|
18
|
+
const t = useTranslation()
|
|
19
|
+
const pages = useMarketingStore((s) => s.landingPages)
|
|
20
|
+
|
|
21
|
+
const totalVisits = pages.reduce((s, p) => s + p.visits, 0)
|
|
22
|
+
const totalConv = pages.reduce((s, p) => s + p.conversions, 0)
|
|
23
|
+
const avgCvr = totalVisits > 0 ? (totalConv / totalVisits) * 100 : 0
|
|
24
|
+
|
|
25
|
+
const columns = React.useMemo<ColumnDef<LandingPagePerf, any>[]>(() => [
|
|
26
|
+
{ accessorKey: 'name', header: t('marketing.col.page'), cell: ({ getValue }) => <span className="font-medium text-foreground">{getValue() as string}</span> },
|
|
27
|
+
{ accessorKey: 'type', header: t('marketing.col.type'), cell: ({ getValue }) => { const v = getValue() as string; return <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${TYPE_STYLE[v] ?? 'bg-muted text-muted-foreground'}`}>{v}</span> } },
|
|
28
|
+
{ accessorKey: 'visits', header: t('marketing.col.visits'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatNumber(getValue() as number)}</span> },
|
|
29
|
+
{ accessorKey: 'conversions', header: t('marketing.col.conversions'), cell: ({ getValue }) => <span className="font-medium text-foreground">{formatNumber(getValue() as number)}</span> },
|
|
30
|
+
{ accessorKey: 'cvr', header: t('marketing.col.cvr'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatPercent(getValue() as number)}</span> },
|
|
31
|
+
], [t])
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="mx-auto max-w-6xl space-y-4">
|
|
35
|
+
<div className="flex items-center justify-between">
|
|
36
|
+
<p className="text-sm text-muted-foreground">{t('marketing.landing.subtitle')}</p>
|
|
37
|
+
<RangeTabs />
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div className="grid grid-cols-3 gap-4">
|
|
41
|
+
<KpiCard icon={MousePointerClick} label={t('marketing.metric.totalVisits')} value={formatNumber(totalVisits)} />
|
|
42
|
+
<KpiCard icon={Layers} label={t('marketing.metric.pages')} value={String(pages.length)} />
|
|
43
|
+
<KpiCard icon={Percent} label={t('marketing.metric.avgConversion')} value={formatPercent(avgCvr)} />
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<DataTable columns={columns} data={pages} emptyMessage={t('marketing.landing.subtitle')} />
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { DashboardCanvas } from '@fayz-ai/ui'
|
|
3
|
+
import { useMarketingStore } from '../MarketingContext'
|
|
4
|
+
import type { DateRangeKey } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Marketing overview. KPIs, channel mix and the campaigns table are registered
|
|
8
|
+
* dashboard widgets (see ./dashboardWidgets) rendered through DashboardCanvas —
|
|
9
|
+
* the same widgets also surface on the global app home. The sticky range control
|
|
10
|
+
* drives the marketing store's range, which refetches all widgets' data.
|
|
11
|
+
*/
|
|
12
|
+
export function OverviewView() {
|
|
13
|
+
const setRange = useMarketingStore((s) => s.setRange)
|
|
14
|
+
const range = useMarketingStore((s) => s.range)
|
|
15
|
+
return (
|
|
16
|
+
<DashboardCanvas
|
|
17
|
+
surface="plugin-home"
|
|
18
|
+
domain="marketing"
|
|
19
|
+
showHeader={false}
|
|
20
|
+
className="mx-auto max-w-6xl space-y-6"
|
|
21
|
+
range={{ default: range, onChange: (r) => void setRange(r as DateRangeKey) }}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
3
|
+
import { SettingsGroup, ToggleRow, SelectRow } from '@fayz-ai/saas'
|
|
4
|
+
import { useMarketingConfig } from '../MarketingContext'
|
|
5
|
+
|
|
6
|
+
const SOURCE_OPTIONS = [
|
|
7
|
+
{ value: 'crm', label: 'CRM' },
|
|
8
|
+
{ value: 'agenda', label: 'Agenda' },
|
|
9
|
+
{ value: 'orders', label: 'Orders' },
|
|
10
|
+
{ value: 'custom', label: 'Custom' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export function SettingsView() {
|
|
14
|
+
const t = useTranslation()
|
|
15
|
+
const { conversion, channels } = useMarketingConfig()
|
|
16
|
+
|
|
17
|
+
// Local UI state — mock toggles (no backend yet), mirrors other plugins' settings.
|
|
18
|
+
const [source, setSource] = React.useState(conversion.source)
|
|
19
|
+
const [trackValue, setTrackValue] = React.useState(true)
|
|
20
|
+
const [autoSync, setAutoSync] = React.useState(true)
|
|
21
|
+
const [assisted, setAssisted] = React.useState(false)
|
|
22
|
+
const [tracked, setTracked] = React.useState<Record<string, boolean>>(
|
|
23
|
+
() => Object.fromEntries(channels.map((c) => [c.id, true])),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="space-y-4">
|
|
28
|
+
<SettingsGroup title={t('marketing.settings.conversionModel')} description={t('marketing.settings.conversionModelDesc')}>
|
|
29
|
+
<SelectRow
|
|
30
|
+
label={t('marketing.settings.attributedFrom')}
|
|
31
|
+
description={conversion.label}
|
|
32
|
+
value={source}
|
|
33
|
+
options={SOURCE_OPTIONS}
|
|
34
|
+
onChange={(v) => setSource(v as typeof source)}
|
|
35
|
+
/>
|
|
36
|
+
<ToggleRow
|
|
37
|
+
label={t('marketing.settings.trackValue')}
|
|
38
|
+
description={t('marketing.settings.trackValueDesc')}
|
|
39
|
+
checked={trackValue}
|
|
40
|
+
onChange={setTrackValue}
|
|
41
|
+
/>
|
|
42
|
+
</SettingsGroup>
|
|
43
|
+
|
|
44
|
+
<SettingsGroup title={t('marketing.settings.channels')} description={t('marketing.settings.channelsDesc')}>
|
|
45
|
+
{channels.map((c) => (
|
|
46
|
+
<ToggleRow
|
|
47
|
+
key={c.id}
|
|
48
|
+
label={c.label}
|
|
49
|
+
description={t('marketing.settings.channelTrackDesc')}
|
|
50
|
+
checked={tracked[c.id] ?? true}
|
|
51
|
+
onChange={(v) => setTracked((prev) => ({ ...prev, [c.id]: v }))}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</SettingsGroup>
|
|
55
|
+
|
|
56
|
+
<SettingsGroup title={t('marketing.settings.attribution')} description={t('marketing.settings.attributionDesc')}>
|
|
57
|
+
<ToggleRow
|
|
58
|
+
label={t('marketing.settings.autoSync')}
|
|
59
|
+
description={t('marketing.settings.autoSyncDesc')}
|
|
60
|
+
checked={autoSync}
|
|
61
|
+
onChange={setAutoSync}
|
|
62
|
+
/>
|
|
63
|
+
<ToggleRow
|
|
64
|
+
label={t('marketing.settings.assisted')}
|
|
65
|
+
description={t('marketing.settings.assistedDesc')}
|
|
66
|
+
checked={assisted}
|
|
67
|
+
onChange={setAssisted}
|
|
68
|
+
/>
|
|
69
|
+
</SettingsGroup>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|