@fayz-ai/plugin-marketing 0.9.0-next.0 → 0.9.0
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/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +6 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +15 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/types.d.ts +43 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +782 -104
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/registries.d.ts +3 -1
- package/dist/registries.d.ts.map +1 -1
- package/dist/store.d.ts +9 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +41 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/views/LandingPageComposer.d.ts +8 -0
- package/dist/views/LandingPageComposer.d.ts.map +1 -0
- package/dist/views/LandingPagesView.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/data/mock.ts +83 -8
- package/src/data/supabase.ts +215 -3
- package/src/data/tables.ts +17 -0
- package/src/data/types.ts +41 -1
- package/src/index.ts +12 -2
- package/src/integrations/ranklayer/connectorDef.tsx +4 -0
- package/src/locales/en.ts +24 -1
- package/src/locales/pt-BR.ts +24 -1
- package/src/migrations/008_landing_pages.sql +112 -0
- package/src/migrations/index.ts +116 -1
- package/src/registries.ts +3 -1
- package/src/store.ts +26 -3
- package/src/types.ts +44 -5
- package/src/views/LandingPageComposer.tsx +201 -0
- package/src/views/LandingPagesView.tsx +145 -22
|
@@ -0,0 +1,201 @@
|
|
|
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,49 +1,172 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { MousePointerClick, Layers,
|
|
2
|
+
import { MousePointerClick, Layers, Users, Plus, Pencil, Trash2, ExternalLink, Sparkles } from 'lucide-react'
|
|
3
3
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
-
import { DataTable } from '@fayz-ai/ui'
|
|
4
|
+
import { Button, DataTable } from '@fayz-ai/ui'
|
|
5
|
+
import { PermissionGate } from '@fayz-ai/saas'
|
|
5
6
|
import { useTranslation } from '@fayz-ai/core'
|
|
6
7
|
import { useMarketingStore } from '../MarketingContext'
|
|
7
8
|
import { formatNumber, formatPercent } from '../format'
|
|
8
9
|
import { KpiCard, RangeTabs } from '../components/MarketingBits'
|
|
10
|
+
import { LandingPageComposer } from './LandingPageComposer'
|
|
9
11
|
import type { LandingPagePerf } from '../types'
|
|
10
12
|
|
|
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
13
|
export function LandingPagesView() {
|
|
18
14
|
const t = useTranslation()
|
|
19
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)
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
function openNew() {
|
|
27
|
+
setEditing(null)
|
|
28
|
+
setComposerOpen(true)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function openEdit(page: LandingPagePerf) {
|
|
32
|
+
setEditing(page)
|
|
33
|
+
setComposerOpen(true)
|
|
34
|
+
}
|
|
24
35
|
|
|
25
36
|
const columns = React.useMemo<ColumnDef<LandingPagePerf, any>[]>(() => [
|
|
26
|
-
{
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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])
|
|
32
141
|
|
|
33
142
|
return (
|
|
34
143
|
<div className="mx-auto max-w-6xl space-y-4">
|
|
35
|
-
<div className="flex items-center justify-
|
|
36
|
-
<p className="text-sm text-muted-foreground">{t('marketing.landing.subtitle')}</p>
|
|
144
|
+
<div className="flex items-center justify-end gap-2">
|
|
37
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>
|
|
38
151
|
</div>
|
|
39
152
|
|
|
40
153
|
<div className="grid grid-cols-3 gap-4">
|
|
41
|
-
<KpiCard icon={MousePointerClick} label={t('marketing.metric.totalVisits')} value={formatNumber(totalVisits)} />
|
|
42
154
|
<KpiCard icon={Layers} label={t('marketing.metric.pages')} value={String(pages.length)} />
|
|
43
|
-
<KpiCard
|
|
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)} />
|
|
44
161
|
</div>
|
|
45
162
|
|
|
46
|
-
<DataTable columns={columns} data={pages} emptyMessage={t('marketing.landing.
|
|
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) }} />
|
|
47
170
|
</div>
|
|
48
171
|
)
|
|
49
172
|
}
|