@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.
Files changed (88) hide show
  1. package/README.md +3 -3
  2. package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/data/mock.d.ts +1 -1
  5. package/dist/data/mock.d.ts.map +1 -1
  6. package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
  7. package/dist/data/registries.d.ts.map +1 -0
  8. package/dist/index.d.ts +4 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +17 -22
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/format.d.ts.map +1 -0
  13. package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
  14. package/dist/lib/presets.d.ts.map +1 -0
  15. package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
  16. package/dist/views/MarketingPage.d.ts.map +1 -0
  17. package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
  18. package/dist/views/content/ContentPlannerContext.d.ts +1 -1
  19. package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
  20. package/dist/views/dashboardWidgets.d.ts +1 -1
  21. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  22. package/package.json +6 -9
  23. package/dist/MarketingContext.d.ts.map +0 -1
  24. package/dist/MarketingPage.d.ts.map +0 -1
  25. package/dist/format.d.ts.map +0 -1
  26. package/dist/presets.d.ts.map +0 -1
  27. package/dist/registries.d.ts.map +0 -1
  28. package/src/MarketingContext.tsx +0 -42
  29. package/src/MarketingPage.tsx +0 -139
  30. package/src/components/MarketingBits.tsx +0 -131
  31. package/src/components/icons.tsx +0 -36
  32. package/src/data/blogEntities.tsx +0 -91
  33. package/src/data/capability.test.ts +0 -92
  34. package/src/data/contentMedia.ts +0 -25
  35. package/src/data/contentMock.ts +0 -253
  36. package/src/data/contentSupabase.ts +0 -263
  37. package/src/data/contentTypes.ts +0 -138
  38. package/src/data/mock.ts +0 -308
  39. package/src/data/supabase.ts +0 -613
  40. package/src/data/tables.ts +0 -33
  41. package/src/data/types.ts +0 -97
  42. package/src/format.ts +0 -40
  43. package/src/index.ts +0 -457
  44. package/src/integrations/ranklayer/RANKLAYER.md +0 -40
  45. package/src/integrations/ranklayer/connectorDef.tsx +0 -102
  46. package/src/integrations/ranklayer/data/supabase.ts +0 -106
  47. package/src/integrations/ranklayer/types.ts +0 -16
  48. package/src/locales/en.ts +0 -192
  49. package/src/locales/index.ts +0 -7
  50. package/src/locales/pt-BR.ts +0 -192
  51. package/src/migrations/000_plg_rename.sql +0 -15
  52. package/src/migrations/001_content_planner.sql +0 -94
  53. package/src/migrations/002_multi_platform.sql +0 -25
  54. package/src/migrations/003_recording_ops.sql +0 -25
  55. package/src/migrations/004_ranklayer.sql +0 -50
  56. package/src/migrations/005_analytics_base.sql +0 -60
  57. package/src/migrations/006_analytics_views.sql +0 -68
  58. package/src/migrations/007_agent_rpcs.sql +0 -119
  59. package/src/migrations/008_landing_pages.sql +0 -112
  60. package/src/migrations/index.ts +0 -601
  61. package/src/presets.ts +0 -119
  62. package/src/registries.ts +0 -62
  63. package/src/schema/index.ts +0 -106
  64. package/src/store.ts +0 -102
  65. package/src/types.ts +0 -153
  66. package/src/views/CampaignComposer.tsx +0 -89
  67. package/src/views/CampaignsView.tsx +0 -59
  68. package/src/views/ChannelDetailView.tsx +0 -59
  69. package/src/views/ChannelsView.tsx +0 -46
  70. package/src/views/FunnelView.tsx +0 -56
  71. package/src/views/LandingPageComposer.tsx +0 -201
  72. package/src/views/LandingPagesView.tsx +0 -172
  73. package/src/views/OverviewView.tsx +0 -24
  74. package/src/views/SettingsView.tsx +0 -72
  75. package/src/views/blog/BlogAdminView.tsx +0 -99
  76. package/src/views/blog/BlogEntityForm.tsx +0 -101
  77. package/src/views/blog/useEntityCrud.ts +0 -50
  78. package/src/views/content/ContentPlannerContext.tsx +0 -13
  79. package/src/views/content/ContentView.tsx +0 -908
  80. package/src/views/content/PlanBriefView.tsx +0 -268
  81. package/src/views/content/PostPage.tsx +0 -370
  82. package/src/views/content/PostSocialPreview.tsx +0 -212
  83. package/src/views/content/RecordingChecklist.tsx +0 -145
  84. package/src/views/content/contentBits.tsx +0 -114
  85. package/src/views/content/contentStore.ts +0 -160
  86. package/src/views/content/templates.ts +0 -172
  87. package/src/views/dashboardWidgets.tsx +0 -173
  88. /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
- }
@@ -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
- }