@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.
Files changed (41) hide show
  1. package/dist/data/mock.d.ts.map +1 -1
  2. package/dist/data/supabase.d.ts +6 -0
  3. package/dist/data/supabase.d.ts.map +1 -1
  4. package/dist/data/tables.d.ts +15 -0
  5. package/dist/data/tables.d.ts.map +1 -1
  6. package/dist/data/types.d.ts +43 -2
  7. package/dist/data/types.d.ts.map +1 -1
  8. package/dist/index.d.ts +8 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +782 -104
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
  13. package/dist/locales/en.d.ts.map +1 -1
  14. package/dist/locales/pt-BR.d.ts.map +1 -1
  15. package/dist/migrations/index.d.ts +1 -0
  16. package/dist/migrations/index.d.ts.map +1 -1
  17. package/dist/registries.d.ts +3 -1
  18. package/dist/registries.d.ts.map +1 -1
  19. package/dist/store.d.ts +9 -2
  20. package/dist/store.d.ts.map +1 -1
  21. package/dist/types.d.ts +41 -5
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/views/LandingPageComposer.d.ts +8 -0
  24. package/dist/views/LandingPageComposer.d.ts.map +1 -0
  25. package/dist/views/LandingPagesView.d.ts.map +1 -1
  26. package/package.json +7 -7
  27. package/src/data/mock.ts +83 -8
  28. package/src/data/supabase.ts +215 -3
  29. package/src/data/tables.ts +17 -0
  30. package/src/data/types.ts +41 -1
  31. package/src/index.ts +12 -2
  32. package/src/integrations/ranklayer/connectorDef.tsx +4 -0
  33. package/src/locales/en.ts +24 -1
  34. package/src/locales/pt-BR.ts +24 -1
  35. package/src/migrations/008_landing_pages.sql +112 -0
  36. package/src/migrations/index.ts +116 -1
  37. package/src/registries.ts +3 -1
  38. package/src/store.ts +26 -3
  39. package/src/types.ts +44 -5
  40. package/src/views/LandingPageComposer.tsx +201 -0
  41. 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, Percent } from 'lucide-react'
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
- 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
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
- { 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])
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-between">
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 icon={Percent} label={t('marketing.metric.avgConversion')} value={formatPercent(avgCvr)} />
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.subtitle')} />
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
  }