@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
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
+ }