@fayz-ai/plugin-marketing 0.8.2 → 0.9.0-next.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 (43) 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 +14 -12
  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
  42. package/dist/index.cjs +0 -5237
  43. package/dist/index.cjs.map +0 -1
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql, 005_analytics_base.sql, 006_analytics_views.sql, 007_agent_rpcs.sql — regenerate with scripts/embed-migrations.mjs
1
+ // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql, 005_analytics_base.sql, 006_analytics_views.sql, 007_agent_rpcs.sql, 008_landing_pages.sql — regenerate with scripts/embed-migrations.mjs
2
2
  // SQL files are the source of truth; this inline copy lets the manifest declare
3
3
  // migrations as data. Do not edit by hand — run the embed script instead.
4
4
 
@@ -474,6 +474,120 @@ GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jso
474
474
  TO authenticated, service_role;
475
475
  `
476
476
 
477
+ export const MIGRATION_008_LANDING_PAGES = `-- Marketing Plugin: landing pages.
478
+ -- Prefix: plg_marketing_
479
+ -- A landing page IS a site plus a route (espacorenovario.com.br + /lp-capilar).
480
+ -- The SITE is not modelled here: it is the tenant's fayz project
481
+ -- (public.fayz_projects, @fayz-ai/db 020) — the same rows Settings → Sites
482
+ -- manages — so \`app_id\` / \`fayz_project_id\` / \`preview_url\` come from there.
483
+ -- Visit analytics are read from the platform that hosts the deploy (app id +
484
+ -- route + period); that source is not wired yet, so visits render as pending.
485
+ -- \`lead_tags\` is the cross with the CRM: leads that arrive through the page
486
+ -- carry those tag slugs in people.tags (v_marketing_lead_tag_events counts them).
487
+ -- \`status\`='planned' + \`prompt\` is a page that does NOT exist on the site yet:
488
+ -- the brief is written here and the page gets built with Fayz later.
489
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
490
+
491
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_landing_pages (
492
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
493
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
494
+ site_id uuid NOT NULL REFERENCES public.fayz_projects(id) ON DELETE CASCADE,
495
+ name text NOT NULL,
496
+ route text NOT NULL,
497
+ lead_tags text[] NOT NULL DEFAULT '{}',
498
+ status text NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'planned')),
499
+ prompt text,
500
+ is_active boolean NOT NULL DEFAULT true,
501
+ created_at timestamptz NOT NULL DEFAULT now(),
502
+ updated_at timestamptz NOT NULL DEFAULT now()
503
+ );
504
+
505
+ -- Self-healing for pools that ran an earlier cut of this migration (single
506
+ -- lead_tag column, no status/prompt). No-op on a fresh table. The views are
507
+ -- rebuilt at the bottom; dropping them first is what lets the column go.
508
+ DROP VIEW IF EXISTS public.v_marketing_landing_pages;
509
+
510
+ ALTER TABLE public.plg_marketing_landing_pages
511
+ ADD COLUMN IF NOT EXISTS lead_tags text[] NOT NULL DEFAULT '{}',
512
+ ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'live',
513
+ ADD COLUMN IF NOT EXISTS prompt text;
514
+
515
+ DO $$
516
+ BEGIN
517
+ IF EXISTS (
518
+ SELECT 1 FROM information_schema.columns
519
+ WHERE table_schema = 'public' AND table_name = 'plg_marketing_landing_pages' AND column_name = 'lead_tag'
520
+ ) THEN
521
+ UPDATE public.plg_marketing_landing_pages
522
+ SET lead_tags = ARRAY[lead_tag]
523
+ WHERE lead_tag IS NOT NULL AND lead_tags = '{}';
524
+ ALTER TABLE public.plg_marketing_landing_pages DROP COLUMN lead_tag;
525
+ END IF;
526
+ IF NOT EXISTS (
527
+ SELECT 1 FROM pg_constraint
528
+ WHERE conname = 'plg_marketing_landing_pages_status_check'
529
+ ) THEN
530
+ ALTER TABLE public.plg_marketing_landing_pages
531
+ ADD CONSTRAINT plg_marketing_landing_pages_status_check CHECK (status IN ('live', 'planned'));
532
+ END IF;
533
+ END $$;
534
+
535
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_tenant ON public.plg_marketing_landing_pages(tenant_id);
536
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_site ON public.plg_marketing_landing_pages(site_id);
537
+ -- One row per route within a site: the route IS the page's identity.
538
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_landing_pages_site_route
539
+ ON public.plg_marketing_landing_pages(site_id, route);
540
+
541
+ DO $$
542
+ DECLARE t text := 'plg_marketing_landing_pages';
543
+ BEGIN
544
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
545
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
546
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
547
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
548
+ END IF;
549
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
550
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
551
+ END IF;
552
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
553
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
554
+ END IF;
555
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
556
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
557
+ END IF;
558
+ END $$;
559
+
560
+ -- Read view: the page joined to its site, so the surface renders the full URL
561
+ -- (and the analytics keys) without a second round trip.
562
+ CREATE VIEW public.v_marketing_landing_pages WITH (security_invoker=true) AS
563
+ SELECT lp.id, lp.tenant_id, lp.site_id, lp.name, lp.route, lp.lead_tags, lp.status, lp.prompt, lp.is_active,
564
+ s.name AS site_name, s.preview_url AS site_url, s.app_id, s.fayz_project_id, s.editor_url,
565
+ lp.created_at, lp.updated_at
566
+ FROM public.plg_marketing_landing_pages lp
567
+ JOIN public.fayz_projects s ON s.id = lp.site_id;
568
+ GRANT SELECT ON public.v_marketing_landing_pages TO authenticated;
569
+
570
+ -- Lead events keyed by tag — the cross that turns "leads tagged lp-capilar"
571
+ -- into that page's conversions. One row per (lead, tag); \`person_id\` is what
572
+ -- keeps a lead carrying two of a page's tags from counting twice. Archived
573
+ -- leads are OUT: plugin-crm archives by flipping people.is_active to false, and
574
+ -- a page's number has to match what the funnel shows. Apps whose leads live
575
+ -- elsewhere may CREATE OR REPLACE this view keeping the contract:
576
+ -- (tenant_id uuid, tag text, person_id uuid, occurred_at timestamptz, value numeric)
577
+ DROP VIEW IF EXISTS public.v_marketing_lead_tag_events;
578
+ CREATE VIEW public.v_marketing_lead_tag_events WITH (security_invoker=true) AS
579
+ SELECT p.tenant_id,
580
+ tag,
581
+ p.id AS person_id,
582
+ p.created_at AS occurred_at,
583
+ NULLIF(p.metadata->>'value', '')::numeric AS value
584
+ FROM public.people p
585
+ CROSS JOIN LATERAL unnest(COALESCE(p.tags, '{}'::text[])) AS tag
586
+ WHERE p.kind = 'lead'
587
+ AND COALESCE(p.is_active, true);
588
+ GRANT SELECT ON public.v_marketing_lead_tag_events TO authenticated;
589
+ `
590
+
477
591
  export const MIGRATIONS: Array<{ id: string; sql: string }> = [
478
592
  { id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
479
593
  { id: "001_content_planner", sql: MIGRATION_001_CONTENT_PLANNER },
@@ -483,4 +597,5 @@ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
483
597
  { id: "005_analytics_base", sql: MIGRATION_005_ANALYTICS_BASE },
484
598
  { id: "006_analytics_views", sql: MIGRATION_006_ANALYTICS_VIEWS },
485
599
  { id: "007_agent_rpcs", sql: MIGRATION_007_AGENT_RPCS },
600
+ { id: "008_landing_pages", sql: MIGRATION_008_LANDING_PAGES },
486
601
  ]
package/src/registries.ts CHANGED
@@ -41,7 +41,9 @@ const channelEntity: EntityDef = {
41
41
 
42
42
  /** Build the marketing settings registries. Channels are tenant-editable rows
43
43
  * in plg_marketing_channels; the Supabase provider lazily seeds them from the
44
- * resolved domain preset (seedData doubles as walkable mock data). */
44
+ * resolved domain preset (seedData doubles as walkable mock data). Sites are
45
+ * NOT here on purpose: they are the tenant's fayz projects, managed by the
46
+ * shell in Settings → Sites, and marketing only points a page at one. */
45
47
  export function buildMarketingRegistries(channels: AcquisitionChannel[]): PluginRegistryDef[] {
46
48
  return [
47
49
  {
package/src/store.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { createStore, type StoreApi } from 'zustand/vanilla'
2
- import type { MarketingDataProvider, SaveCampaignInput } from './data/types'
2
+ import type { MarketingDataProvider, SaveCampaignInput, SaveLandingPageInput } from './data/types'
3
3
  import type {
4
4
  Campaign,
5
5
  ChannelPerformance,
6
6
  DateRangeKey,
7
7
  FunnelStage,
8
+ LandingPage,
8
9
  LandingPagePerf,
9
10
  MarketingOverview,
11
+ MarketingSite,
10
12
  } from './types'
11
13
 
12
14
  export interface MarketingUIState {
@@ -16,6 +18,8 @@ export interface MarketingUIState {
16
18
  campaigns: Campaign[]
17
19
  funnel: FunnelStage[]
18
20
  landingPages: LandingPagePerf[]
21
+ sites: MarketingSite[]
22
+ leadTags: Array<{ slug: string; name: string }>
19
23
  loading: boolean
20
24
 
21
25
  load(): Promise<void>
@@ -24,6 +28,8 @@ export interface MarketingUIState {
24
28
  setRange(range: DateRangeKey): Promise<void>
25
29
  saveCampaign(input: SaveCampaignInput): Promise<Campaign>
26
30
  deleteCampaign(id: string): Promise<void>
31
+ saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage>
32
+ deleteLandingPage(id: string): Promise<void>
27
33
  }
28
34
 
29
35
  export function createMarketingStore(provider: MarketingDataProvider): StoreApi<MarketingUIState> {
@@ -34,20 +40,24 @@ export function createMarketingStore(provider: MarketingDataProvider): StoreApi<
34
40
  campaigns: [],
35
41
  funnel: [],
36
42
  landingPages: [],
43
+ sites: [],
44
+ leadTags: [],
37
45
  loading: false,
38
46
 
39
47
  async load() {
40
48
  const range = get().range
41
49
  set({ loading: true })
42
50
  try {
43
- const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
51
+ const [overview, channels, campaigns, funnel, landingPages, sites, leadTags] = await Promise.all([
44
52
  provider.overview({ range }),
45
53
  provider.channelPerformance({ range }),
46
54
  provider.listCampaigns({ range }),
47
55
  provider.funnel({ range }),
48
56
  provider.landingPages({ range }),
57
+ provider.listSites(),
58
+ provider.listLeadTags(),
49
59
  ])
50
- set({ overview, channels, campaigns, funnel, landingPages, loading: false })
60
+ set({ overview, channels, campaigns, funnel, landingPages, sites, leadTags, loading: false })
51
61
  } catch (err) {
52
62
  // A failed load must never wedge the surface in `loading` forever.
53
63
  set({ loading: false })
@@ -75,5 +85,18 @@ export function createMarketingStore(provider: MarketingDataProvider): StoreApi<
75
85
  await provider.deleteCampaign(id)
76
86
  await get().load()
77
87
  },
88
+
89
+ async saveLandingPage(input) {
90
+ const saved = await provider.saveLandingPage(input)
91
+ provider.invalidateCache?.()
92
+ await get().load()
93
+ return saved
94
+ },
95
+
96
+ async deleteLandingPage(id) {
97
+ await provider.deleteLandingPage(id)
98
+ provider.invalidateCache?.()
99
+ await get().load()
100
+ },
78
101
  }))
79
102
  }
package/src/types.ts CHANGED
@@ -76,14 +76,53 @@ export interface FunnelStage {
76
76
  color: string
77
77
  }
78
78
 
79
- export interface LandingPagePerf {
79
+ /** A site the tenant owns — a row of public.fayz_projects, managed in
80
+ * Settings → Sites. `appId`/`fayzProjectId` are the deploy's ids on the
81
+ * hosting platform: the keys the visit-analytics call needs (app + route +
82
+ * period). Marketing reads these; it does not own them. */
83
+ export interface MarketingSite {
80
84
  id: string
81
85
  name: string
82
- type: 'Funnel' | 'Landing page' | 'Website'
83
- visits: number
86
+ /** live site URL, e.g. 'https://espacorenovario.com.br' */
87
+ url?: string
88
+ appId?: string
89
+ fayzProjectId?: string
90
+ /** Fayz editor for this project — where a planned page gets built. */
91
+ editorUrl?: string
92
+ }
93
+
94
+ /** 'live' = the route already exists on the site. 'planned' = it does not yet;
95
+ * `prompt` is the brief for building it with Fayz. */
96
+ export type LandingPageStatus = 'live' | 'planned'
97
+
98
+ /** A landing page IS a site + a route. `leadTags` is the CRM cross: leads that
99
+ * come in through the page carry those tag slugs. */
100
+ export interface LandingPage {
101
+ id: string
102
+ siteId: string
103
+ name: string
104
+ /** path within the site, always leading-slashed, e.g. '/lp-capilar' */
105
+ route: string
106
+ leadTags: string[]
107
+ status: LandingPageStatus
108
+ /** what the page should be — only meaningful while status is 'planned' */
109
+ prompt?: string
110
+ isActive: boolean
111
+ }
112
+
113
+ export interface LandingPagePerf extends LandingPage {
114
+ siteName: string
115
+ siteUrl?: string
116
+ appId?: string
117
+ editorUrl?: string
118
+ /** full public URL (site + route); the bare route when the site has no URL */
119
+ url: string
120
+ /** null while no visit source is connected — renders as "—", not as zero. */
121
+ visits: number | null
122
+ /** leads carrying `leadTag` within the queried period */
84
123
  conversions: number
85
- /** conversions / visits * 100 */
86
- cvr: number
124
+ /** conversions / visits * 100 — null when visits are unknown */
125
+ cvr: number | null
87
126
  }
88
127
 
89
128
  export interface MarketingOverview {
@@ -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
+ }