@fayz-ai/plugin-marketing 0.9.0-next.1 → 0.9.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/README.md +3 -3
- package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
- package/dist/context.d.ts.map +1 -0
- package/dist/data/mock.d.ts +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -22
- package/dist/index.js.map +1 -1
- package/dist/lib/format.d.ts.map +1 -0
- package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
- package/dist/lib/presets.d.ts.map +1 -0
- package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
- package/dist/views/MarketingPage.d.ts.map +1 -0
- package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
- package/dist/views/content/ContentPlannerContext.d.ts +1 -1
- package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
- package/dist/views/dashboardWidgets.d.ts +1 -1
- package/dist/views/dashboardWidgets.d.ts.map +1 -1
- package/package.json +6 -9
- package/dist/MarketingContext.d.ts.map +0 -1
- package/dist/MarketingPage.d.ts.map +0 -1
- package/dist/format.d.ts.map +0 -1
- package/dist/presets.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/MarketingContext.tsx +0 -42
- package/src/MarketingPage.tsx +0 -139
- package/src/components/MarketingBits.tsx +0 -131
- package/src/components/icons.tsx +0 -36
- package/src/data/blogEntities.tsx +0 -91
- package/src/data/capability.test.ts +0 -92
- package/src/data/contentMedia.ts +0 -25
- package/src/data/contentMock.ts +0 -253
- package/src/data/contentSupabase.ts +0 -263
- package/src/data/contentTypes.ts +0 -138
- package/src/data/mock.ts +0 -308
- package/src/data/supabase.ts +0 -613
- package/src/data/tables.ts +0 -33
- package/src/data/types.ts +0 -97
- package/src/format.ts +0 -40
- package/src/index.ts +0 -457
- package/src/integrations/ranklayer/RANKLAYER.md +0 -40
- package/src/integrations/ranklayer/connectorDef.tsx +0 -102
- package/src/integrations/ranklayer/data/supabase.ts +0 -106
- package/src/integrations/ranklayer/types.ts +0 -16
- package/src/locales/en.ts +0 -192
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -192
- package/src/migrations/000_plg_rename.sql +0 -15
- package/src/migrations/001_content_planner.sql +0 -94
- package/src/migrations/002_multi_platform.sql +0 -25
- package/src/migrations/003_recording_ops.sql +0 -25
- package/src/migrations/004_ranklayer.sql +0 -50
- package/src/migrations/005_analytics_base.sql +0 -60
- package/src/migrations/006_analytics_views.sql +0 -68
- package/src/migrations/007_agent_rpcs.sql +0 -119
- package/src/migrations/008_landing_pages.sql +0 -112
- package/src/migrations/index.ts +0 -601
- package/src/presets.ts +0 -119
- package/src/registries.ts +0 -62
- package/src/schema/index.ts +0 -106
- package/src/store.ts +0 -102
- package/src/types.ts +0 -153
- package/src/views/CampaignComposer.tsx +0 -89
- package/src/views/CampaignsView.tsx +0 -59
- package/src/views/ChannelDetailView.tsx +0 -59
- package/src/views/ChannelsView.tsx +0 -46
- package/src/views/FunnelView.tsx +0 -56
- package/src/views/LandingPageComposer.tsx +0 -201
- package/src/views/LandingPagesView.tsx +0 -172
- package/src/views/OverviewView.tsx +0 -24
- package/src/views/SettingsView.tsx +0 -72
- package/src/views/blog/BlogAdminView.tsx +0 -99
- package/src/views/blog/BlogEntityForm.tsx +0 -101
- package/src/views/blog/useEntityCrud.ts +0 -50
- package/src/views/content/ContentPlannerContext.tsx +0 -13
- package/src/views/content/ContentView.tsx +0 -908
- package/src/views/content/PlanBriefView.tsx +0 -268
- package/src/views/content/PostPage.tsx +0 -370
- package/src/views/content/PostSocialPreview.tsx +0 -212
- package/src/views/content/RecordingChecklist.tsx +0 -145
- package/src/views/content/contentBits.tsx +0 -114
- package/src/views/content/contentStore.ts +0 -160
- package/src/views/content/templates.ts +0 -172
- package/src/views/dashboardWidgets.tsx +0 -173
- /package/dist/{format.d.ts → lib/format.d.ts} +0 -0
|
@@ -1,59 +0,0 @@
|
|
|
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 { PermissionGate } from '@fayz-ai/saas'
|
|
6
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
7
|
-
import { useMarketingConfig, useMarketingStore } from '../MarketingContext'
|
|
8
|
-
import { formatCurrency, formatNumber, formatPercent } from '../format'
|
|
9
|
-
import { RangeTabs, ChannelCell, StatusBadge } from '../components/MarketingBits'
|
|
10
|
-
import { CampaignComposer } from './CampaignComposer'
|
|
11
|
-
import type { Campaign } from '../types'
|
|
12
|
-
|
|
13
|
-
export function CampaignsView() {
|
|
14
|
-
const t = useTranslation()
|
|
15
|
-
const { conversion, currency } = useMarketingConfig()
|
|
16
|
-
const campaigns = useMarketingStore((s) => s.campaigns)
|
|
17
|
-
const deleteCampaign = useMarketingStore((s) => s.deleteCampaign)
|
|
18
|
-
const [composerOpen, setComposerOpen] = React.useState(false)
|
|
19
|
-
|
|
20
|
-
const columns = React.useMemo<ColumnDef<Campaign, any>[]>(() => [
|
|
21
|
-
{ accessorKey: 'name', header: t('marketing.col.campaign'), cell: ({ getValue }) => <span className="font-medium text-foreground">{getValue() as string}</span> },
|
|
22
|
-
{ accessorKey: 'channelId', header: t('marketing.col.channel'), cell: ({ getValue }) => <ChannelCell channelId={getValue() as string} /> },
|
|
23
|
-
{ accessorKey: 'reach', header: t('marketing.col.reach'), cell: ({ getValue }) => <span className="text-muted-foreground">{formatNumber(getValue() as number)}</span> },
|
|
24
|
-
{ accessorKey: 'conversions', header: conversion.labelPlural, cell: ({ getValue }) => <span className="font-medium text-foreground">{formatNumber(getValue() as number)}</span> },
|
|
25
|
-
{ 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> },
|
|
26
|
-
{ 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> } },
|
|
27
|
-
{ accessorKey: 'status', header: t('marketing.col.status'), cell: ({ getValue }) => <StatusBadge status={getValue() as Campaign['status']} /> },
|
|
28
|
-
{
|
|
29
|
-
id: 'actions', header: '', enableSorting: false,
|
|
30
|
-
cell: ({ row }) => (
|
|
31
|
-
<button
|
|
32
|
-
onClick={(e) => { e.stopPropagation(); void deleteCampaign(row.original.id) }}
|
|
33
|
-
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
34
|
-
title={t('marketing.campaigns.delete')}
|
|
35
|
-
>
|
|
36
|
-
<Trash2 className="h-4 w-4" />
|
|
37
|
-
</button>
|
|
38
|
-
),
|
|
39
|
-
},
|
|
40
|
-
], [t, conversion.labelPlural, currency, deleteCampaign])
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<div className="mx-auto max-w-6xl space-y-4">
|
|
44
|
-
<div className="flex items-center justify-between">
|
|
45
|
-
<p className="text-sm text-muted-foreground">{t('marketing.campaigns.subtitle')}</p>
|
|
46
|
-
<div className="flex items-center gap-2">
|
|
47
|
-
<RangeTabs />
|
|
48
|
-
<PermissionGate feature="marketing" action="create">
|
|
49
|
-
<Button onClick={() => setComposerOpen(true)}><Plus className="mr-1.5 h-4 w-4" /> {t('marketing.campaigns.new')}</Button>
|
|
50
|
-
</PermissionGate>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
53
|
-
|
|
54
|
-
<DataTable columns={columns} data={campaigns} emptyMessage={t('marketing.campaigns.empty')} />
|
|
55
|
-
|
|
56
|
-
<CampaignComposer open={composerOpen} onClose={() => setComposerOpen(false)} />
|
|
57
|
-
</div>
|
|
58
|
-
)
|
|
59
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
}
|
package/src/views/FunnelView.tsx
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { Sparkles } from 'lucide-react'
|
|
3
|
-
import {
|
|
4
|
-
Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter,
|
|
5
|
-
Button, Input, Badge, SegmentedControl,
|
|
6
|
-
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
7
|
-
} from '@fayz-ai/ui'
|
|
8
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
9
|
-
import { useMarketingStore } from '../MarketingContext'
|
|
10
|
-
import type { LandingPage, LandingPageStatus } from '../types'
|
|
11
|
-
|
|
12
|
-
/** 'Consulta capilar' → 'consulta-capilar' — the shape CRM tag slugs take. */
|
|
13
|
-
function slugify(value: string): string {
|
|
14
|
-
return value
|
|
15
|
-
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
16
|
-
.toLowerCase().trim()
|
|
17
|
-
.replace(/[^a-z0-9:]+/g, '-')
|
|
18
|
-
.replace(/^-+|-+$/g, '')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function LandingPageComposer({
|
|
22
|
-
open, page, onClose,
|
|
23
|
-
}: { open: boolean; page?: LandingPage | null; onClose: () => void }) {
|
|
24
|
-
const t = useTranslation()
|
|
25
|
-
const sites = useMarketingStore((s) => s.sites)
|
|
26
|
-
const leadTags = useMarketingStore((s) => s.leadTags)
|
|
27
|
-
const saveLandingPage = useMarketingStore((s) => s.saveLandingPage)
|
|
28
|
-
|
|
29
|
-
const [siteId, setSiteId] = React.useState('')
|
|
30
|
-
const [name, setName] = React.useState('')
|
|
31
|
-
const [route, setRoute] = React.useState('')
|
|
32
|
-
const [status, setStatus] = React.useState<LandingPageStatus>('live')
|
|
33
|
-
const [prompt, setPrompt] = React.useState('')
|
|
34
|
-
const [tags, setTags] = React.useState<string[]>([])
|
|
35
|
-
const [newTag, setNewTag] = React.useState('')
|
|
36
|
-
const [saving, setSaving] = React.useState(false)
|
|
37
|
-
|
|
38
|
-
React.useEffect(() => {
|
|
39
|
-
if (!open) return
|
|
40
|
-
setSiteId(page?.siteId ?? sites[0]?.id ?? '')
|
|
41
|
-
setName(page?.name ?? '')
|
|
42
|
-
setRoute(page?.route ?? '')
|
|
43
|
-
setStatus(page?.status ?? 'live')
|
|
44
|
-
setPrompt(page?.prompt ?? '')
|
|
45
|
-
setTags(page?.leadTags ?? [])
|
|
46
|
-
setNewTag('')
|
|
47
|
-
}, [open, page, sites])
|
|
48
|
-
|
|
49
|
-
const site = sites.find((s) => s.id === siteId)
|
|
50
|
-
const previewPath = route.trim() ? (route.trim().startsWith('/') ? route.trim() : `/${route.trim()}`) : ''
|
|
51
|
-
|
|
52
|
-
// The catalog plus whatever this page already carries: a slug used by a
|
|
53
|
-
// campaign before anyone curated the vocabulary is still a real tag.
|
|
54
|
-
const options = React.useMemo(() => {
|
|
55
|
-
const byslug = new Map(leadTags.map((tag) => [tag.slug, tag.name]))
|
|
56
|
-
for (const slug of tags) if (!byslug.has(slug)) byslug.set(slug, slug)
|
|
57
|
-
return [...byslug].map(([slug, label]) => ({ slug, label }))
|
|
58
|
-
}, [leadTags, tags])
|
|
59
|
-
|
|
60
|
-
function toggleTag(slug: string) {
|
|
61
|
-
setTags((current) => (current.includes(slug) ? current.filter((s) => s !== slug) : [...current, slug]))
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function addTag() {
|
|
65
|
-
const slug = slugify(newTag)
|
|
66
|
-
if (!slug) return
|
|
67
|
-
setTags((current) => (current.includes(slug) ? current : [...current, slug]))
|
|
68
|
-
setNewTag('')
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function handleSave() {
|
|
72
|
-
if (!siteId || !name.trim() || !route.trim()) return
|
|
73
|
-
setSaving(true)
|
|
74
|
-
try {
|
|
75
|
-
await saveLandingPage({
|
|
76
|
-
id: page?.id,
|
|
77
|
-
siteId,
|
|
78
|
-
name: name.trim(),
|
|
79
|
-
route: route.trim(),
|
|
80
|
-
leadTags: tags,
|
|
81
|
-
status,
|
|
82
|
-
prompt: status === 'planned' ? prompt.trim() || undefined : undefined,
|
|
83
|
-
})
|
|
84
|
-
onClose()
|
|
85
|
-
} finally {
|
|
86
|
-
setSaving(false)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return (
|
|
91
|
-
<Modal open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
|
92
|
-
<ModalContent size="md">
|
|
93
|
-
<ModalHeader>
|
|
94
|
-
<ModalTitle>{page ? t('marketing.landing.edit') : t('marketing.landing.new')}</ModalTitle>
|
|
95
|
-
</ModalHeader>
|
|
96
|
-
<ModalBody className="space-y-4">
|
|
97
|
-
<label className="block space-y-1.5">
|
|
98
|
-
<span className="text-sm font-medium text-foreground">{t('marketing.landing.site')}</span>
|
|
99
|
-
<Select value={siteId} onValueChange={setSiteId}>
|
|
100
|
-
<SelectTrigger><SelectValue placeholder={t('marketing.landing.sitePlaceholder')} /></SelectTrigger>
|
|
101
|
-
<SelectContent>
|
|
102
|
-
{sites.map((s) => (
|
|
103
|
-
<SelectItem key={s.id} value={s.id}>
|
|
104
|
-
{s.url ? `${s.name} · ${s.url.replace(/^https?:\/\//, '')}` : s.name}
|
|
105
|
-
</SelectItem>
|
|
106
|
-
))}
|
|
107
|
-
</SelectContent>
|
|
108
|
-
</Select>
|
|
109
|
-
</label>
|
|
110
|
-
|
|
111
|
-
<label className="block space-y-1.5">
|
|
112
|
-
<span className="text-sm font-medium text-foreground">{t('marketing.landing.name')}</span>
|
|
113
|
-
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('marketing.landing.namePlaceholder')} />
|
|
114
|
-
</label>
|
|
115
|
-
|
|
116
|
-
<label className="block space-y-1.5">
|
|
117
|
-
<span className="text-sm font-medium text-foreground">{t('marketing.landing.route')}</span>
|
|
118
|
-
<Input value={route} onChange={(e) => setRoute(e.target.value)} placeholder="/lp-capilar" />
|
|
119
|
-
{site?.url && previewPath ? (
|
|
120
|
-
<span className="block text-xs text-muted-foreground">
|
|
121
|
-
{site.url.replace(/\/+$/, '')}{previewPath}
|
|
122
|
-
</span>
|
|
123
|
-
) : null}
|
|
124
|
-
</label>
|
|
125
|
-
|
|
126
|
-
<div className="space-y-1.5">
|
|
127
|
-
<span className="text-sm font-medium text-foreground">{t('marketing.landing.status')}</span>
|
|
128
|
-
<SegmentedControl
|
|
129
|
-
value={status}
|
|
130
|
-
onChange={(v) => setStatus(v as LandingPageStatus)}
|
|
131
|
-
options={[
|
|
132
|
-
{ value: 'live', label: t('marketing.landing.statusLive') },
|
|
133
|
-
{ value: 'planned', label: t('marketing.landing.statusPlanned') },
|
|
134
|
-
]}
|
|
135
|
-
/>
|
|
136
|
-
</div>
|
|
137
|
-
|
|
138
|
-
{status === 'planned' ? (
|
|
139
|
-
<div className="space-y-1.5">
|
|
140
|
-
<div className="flex items-center justify-between gap-2">
|
|
141
|
-
<span className="text-sm font-medium text-foreground">{t('marketing.landing.prompt')}</span>
|
|
142
|
-
{site?.editorUrl ? (
|
|
143
|
-
<button
|
|
144
|
-
type="button"
|
|
145
|
-
onClick={() => window.open(site.editorUrl!, '_blank', 'noopener,noreferrer')}
|
|
146
|
-
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
|
147
|
-
>
|
|
148
|
-
<Sparkles className="h-3.5 w-3.5" /> {t('marketing.landing.buildWithFayz')}
|
|
149
|
-
</button>
|
|
150
|
-
) : null}
|
|
151
|
-
</div>
|
|
152
|
-
<textarea
|
|
153
|
-
value={prompt}
|
|
154
|
-
onChange={(e) => setPrompt(e.target.value)}
|
|
155
|
-
rows={3}
|
|
156
|
-
placeholder={t('marketing.landing.promptPlaceholder')}
|
|
157
|
-
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
158
|
-
/>
|
|
159
|
-
</div>
|
|
160
|
-
) : null}
|
|
161
|
-
|
|
162
|
-
{/* One field, not a chip list plus a form: the catalog and the free
|
|
163
|
-
slug live in the same box, like any tag picker. */}
|
|
164
|
-
<div className="space-y-1.5">
|
|
165
|
-
<span className="text-sm font-medium text-foreground">{t('marketing.landing.leadTag')}</span>
|
|
166
|
-
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-border bg-background px-2 py-2 focus-within:ring-2 focus-within:ring-ring">
|
|
167
|
-
{options.map((opt) => {
|
|
168
|
-
const active = tags.includes(opt.slug)
|
|
169
|
-
return (
|
|
170
|
-
<Badge
|
|
171
|
-
key={opt.slug}
|
|
172
|
-
variant={active ? 'default' : 'outline'}
|
|
173
|
-
className="cursor-pointer text-xs"
|
|
174
|
-
onClick={() => toggleTag(opt.slug)}
|
|
175
|
-
>
|
|
176
|
-
{opt.label}
|
|
177
|
-
</Badge>
|
|
178
|
-
)
|
|
179
|
-
})}
|
|
180
|
-
<input
|
|
181
|
-
value={newTag}
|
|
182
|
-
onChange={(e) => setNewTag(e.target.value)}
|
|
183
|
-
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag() } }}
|
|
184
|
-
onBlur={addTag}
|
|
185
|
-
placeholder={t('marketing.landing.leadTagPlaceholder')}
|
|
186
|
-
className="min-w-[8rem] flex-1 border-0 bg-transparent px-1 text-xs text-foreground outline-none placeholder:text-muted-foreground"
|
|
187
|
-
/>
|
|
188
|
-
</div>
|
|
189
|
-
<span className="block text-xs text-muted-foreground">{t('marketing.landing.leadTagHint')}</span>
|
|
190
|
-
</div>
|
|
191
|
-
</ModalBody>
|
|
192
|
-
<ModalFooter>
|
|
193
|
-
<Button variant="outline" onClick={onClose}>{t('marketing.composer.cancel')}</Button>
|
|
194
|
-
<Button onClick={handleSave} disabled={saving || !siteId || !name.trim() || !route.trim()}>
|
|
195
|
-
{page ? t('marketing.landing.save') : t('marketing.landing.create')}
|
|
196
|
-
</Button>
|
|
197
|
-
</ModalFooter>
|
|
198
|
-
</ModalContent>
|
|
199
|
-
</Modal>
|
|
200
|
-
)
|
|
201
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { MousePointerClick, Layers, Users, Plus, Pencil, Trash2, ExternalLink, Sparkles } from 'lucide-react'
|
|
3
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
-
import { Button, DataTable } from '@fayz-ai/ui'
|
|
5
|
-
import { PermissionGate } from '@fayz-ai/saas'
|
|
6
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
7
|
-
import { useMarketingStore } from '../MarketingContext'
|
|
8
|
-
import { formatNumber, formatPercent } from '../format'
|
|
9
|
-
import { KpiCard, RangeTabs } from '../components/MarketingBits'
|
|
10
|
-
import { LandingPageComposer } from './LandingPageComposer'
|
|
11
|
-
import type { LandingPagePerf } from '../types'
|
|
12
|
-
|
|
13
|
-
export function LandingPagesView() {
|
|
14
|
-
const t = useTranslation()
|
|
15
|
-
const pages = useMarketingStore((s) => s.landingPages)
|
|
16
|
-
const deleteLandingPage = useMarketingStore((s) => s.deleteLandingPage)
|
|
17
|
-
const [editing, setEditing] = React.useState<LandingPagePerf | null>(null)
|
|
18
|
-
const [composerOpen, setComposerOpen] = React.useState(false)
|
|
19
|
-
|
|
20
|
-
// Visits are only known for pages whose host reports them; a page with an
|
|
21
|
-
// unknown count must not drag the total down to a wrong number.
|
|
22
|
-
const measured = pages.filter((p) => p.visits !== null)
|
|
23
|
-
const totalVisits = measured.reduce((s, p) => s + (p.visits ?? 0), 0)
|
|
24
|
-
const totalLeads = pages.reduce((s, p) => s + p.conversions, 0)
|
|
25
|
-
|
|
26
|
-
function openNew() {
|
|
27
|
-
setEditing(null)
|
|
28
|
-
setComposerOpen(true)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function openEdit(page: LandingPagePerf) {
|
|
32
|
-
setEditing(page)
|
|
33
|
-
setComposerOpen(true)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const columns = React.useMemo<ColumnDef<LandingPagePerf, any>[]>(() => [
|
|
37
|
-
{
|
|
38
|
-
accessorKey: 'name',
|
|
39
|
-
header: t('marketing.col.page'),
|
|
40
|
-
cell: ({ row }) => (
|
|
41
|
-
<div className="min-w-0">
|
|
42
|
-
<span className="flex items-center gap-1.5 truncate font-medium text-foreground">
|
|
43
|
-
{row.original.name}
|
|
44
|
-
{row.original.status === 'planned' ? (
|
|
45
|
-
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-800">
|
|
46
|
-
{t('marketing.landing.statusPlanned')}
|
|
47
|
-
</span>
|
|
48
|
-
) : null}
|
|
49
|
-
</span>
|
|
50
|
-
<span className="block truncate text-xs text-muted-foreground">{row.original.siteName}</span>
|
|
51
|
-
</div>
|
|
52
|
-
),
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
accessorKey: 'route',
|
|
56
|
-
header: t('marketing.col.route'),
|
|
57
|
-
cell: ({ row }) => (
|
|
58
|
-
<a
|
|
59
|
-
href={row.original.url}
|
|
60
|
-
target="_blank"
|
|
61
|
-
rel="noreferrer"
|
|
62
|
-
onClick={(e) => e.stopPropagation()}
|
|
63
|
-
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground hover:underline"
|
|
64
|
-
>
|
|
65
|
-
{row.original.route}
|
|
66
|
-
<ExternalLink className="h-3 w-3" />
|
|
67
|
-
</a>
|
|
68
|
-
),
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
accessorKey: 'leadTags',
|
|
72
|
-
header: t('marketing.col.leadTag'),
|
|
73
|
-
cell: ({ getValue }) => {
|
|
74
|
-
const tags = (getValue() as string[]) ?? []
|
|
75
|
-
if (!tags.length) return <span className="text-muted-foreground">—</span>
|
|
76
|
-
return (
|
|
77
|
-
<div className="flex flex-wrap gap-1">
|
|
78
|
-
{tags.map((tag) => (
|
|
79
|
-
<span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">{tag}</span>
|
|
80
|
-
))}
|
|
81
|
-
</div>
|
|
82
|
-
)
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
accessorKey: 'visits',
|
|
87
|
-
header: t('marketing.col.visits'),
|
|
88
|
-
cell: ({ getValue }) => {
|
|
89
|
-
const visits = getValue() as number | null
|
|
90
|
-
return visits === null
|
|
91
|
-
? <span className="text-muted-foreground" title={t('marketing.landing.visitsPending')}>—</span>
|
|
92
|
-
: <span className="text-muted-foreground">{formatNumber(visits)}</span>
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
accessorKey: 'conversions',
|
|
97
|
-
header: t('marketing.col.leads'),
|
|
98
|
-
cell: ({ getValue }) => <span className="font-medium text-foreground">{formatNumber(getValue() as number)}</span>,
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
accessorKey: 'cvr',
|
|
102
|
-
header: t('marketing.col.cvr'),
|
|
103
|
-
cell: ({ getValue }) => {
|
|
104
|
-
const cvr = getValue() as number | null
|
|
105
|
-
return cvr === null
|
|
106
|
-
? <span className="text-muted-foreground">—</span>
|
|
107
|
-
: <span className="text-muted-foreground">{formatPercent(cvr)}</span>
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
id: 'actions', header: '', enableSorting: false,
|
|
112
|
-
cell: ({ row }) => (
|
|
113
|
-
<div className="flex items-center justify-end gap-1">
|
|
114
|
-
{row.original.status === 'planned' && row.original.editorUrl ? (
|
|
115
|
-
<button
|
|
116
|
-
onClick={(e) => { e.stopPropagation(); window.open(row.original.editorUrl!, '_blank', 'noopener,noreferrer') }}
|
|
117
|
-
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
118
|
-
title={t('marketing.landing.buildWithFayz')}
|
|
119
|
-
>
|
|
120
|
-
<Sparkles className="h-4 w-4" />
|
|
121
|
-
</button>
|
|
122
|
-
) : null}
|
|
123
|
-
<button
|
|
124
|
-
onClick={(e) => { e.stopPropagation(); openEdit(row.original) }}
|
|
125
|
-
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
126
|
-
title={t('marketing.landing.edit')}
|
|
127
|
-
>
|
|
128
|
-
<Pencil className="h-4 w-4" />
|
|
129
|
-
</button>
|
|
130
|
-
<button
|
|
131
|
-
onClick={(e) => { e.stopPropagation(); void deleteLandingPage(row.original.id) }}
|
|
132
|
-
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
133
|
-
title={t('marketing.landing.delete')}
|
|
134
|
-
>
|
|
135
|
-
<Trash2 className="h-4 w-4" />
|
|
136
|
-
</button>
|
|
137
|
-
</div>
|
|
138
|
-
),
|
|
139
|
-
},
|
|
140
|
-
], [t, deleteLandingPage])
|
|
141
|
-
|
|
142
|
-
return (
|
|
143
|
-
<div className="mx-auto max-w-6xl space-y-4">
|
|
144
|
-
<div className="flex items-center justify-end gap-2">
|
|
145
|
-
<RangeTabs />
|
|
146
|
-
<PermissionGate feature="marketing" action="create">
|
|
147
|
-
<Button onClick={openNew}>
|
|
148
|
-
<Plus className="mr-1.5 h-4 w-4" /> {t('marketing.landing.new')}
|
|
149
|
-
</Button>
|
|
150
|
-
</PermissionGate>
|
|
151
|
-
</div>
|
|
152
|
-
|
|
153
|
-
<div className="grid grid-cols-3 gap-4">
|
|
154
|
-
<KpiCard icon={Layers} label={t('marketing.metric.pages')} value={String(pages.length)} />
|
|
155
|
-
<KpiCard
|
|
156
|
-
icon={MousePointerClick}
|
|
157
|
-
label={t('marketing.metric.totalVisits')}
|
|
158
|
-
value={measured.length > 0 ? formatNumber(totalVisits) : '—'}
|
|
159
|
-
/>
|
|
160
|
-
<KpiCard icon={Users} label={t('marketing.metric.leads')} value={formatNumber(totalLeads)} />
|
|
161
|
-
</div>
|
|
162
|
-
|
|
163
|
-
<DataTable columns={columns} data={pages} emptyMessage={t('marketing.landing.empty')} />
|
|
164
|
-
|
|
165
|
-
{measured.length < pages.length ? (
|
|
166
|
-
<p className="text-xs text-muted-foreground">{t('marketing.landing.visitsPending')}</p>
|
|
167
|
-
) : null}
|
|
168
|
-
|
|
169
|
-
<LandingPageComposer open={composerOpen} page={editing} onClose={() => { setComposerOpen(false); setEditing(null) }} />
|
|
170
|
-
</div>
|
|
171
|
-
)
|
|
172
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
}
|