@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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connectorDef.d.ts","sourceRoot":"","sources":["../../../src/integrations/ranklayer/connectorDef.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"connectorDef.d.ts","sourceRoot":"","sources":["../../../src/integrations/ranklayer/connectorDef.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAA;AAsExD,eAAO,MAAM,qBAAqB,EAAE,mBAwBnC,CAAA"}
|
package/dist/locales/en.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/locales/en.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/locales/en.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA+LrC,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pt-BR.d.ts","sourceRoot":"","sources":["../../src/locales/pt-BR.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"pt-BR.d.ts","sourceRoot":"","sources":["../../src/locales/pt-BR.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA+LvC,CAAA"}
|
|
@@ -6,6 +6,7 @@ export declare const MIGRATION_004_RANKLAYER = "-- Marketing Plugin: RankLayer i
|
|
|
6
6
|
export declare const MIGRATION_005_ANALYTICS_BASE = "-- Marketing Plugin: analytics base tables (channels + campaigns).\n-- Prefix: plg_marketing_\n-- Channels are tenant-owned rows lazily seeded from the app's domain preset\n-- (channel_key = preset/config id). Campaign performance is DERIVED at read\n-- time from v_marketing_attribution (006); only spend is stored.\n-- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).\n\nCREATE TABLE IF NOT EXISTS public.plg_marketing_channels (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n channel_key text NOT NULL,\n label text NOT NULL,\n icon text,\n kind text NOT NULL DEFAULT 'organic'\n CHECK (kind IN ('paid', 'organic', 'social', 'referral', 'direct', 'outbound')),\n is_active boolean NOT NULL DEFAULT true,\n monthly_spend numeric NOT NULL DEFAULT 0,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_channels_tenant ON public.plg_marketing_channels(tenant_id);\nCREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_channels_tenant_key ON public.plg_marketing_channels(tenant_id, channel_key);\n\nCREATE TABLE IF NOT EXISTS public.plg_marketing_campaigns (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n channel_key text NOT NULL,\n status text NOT NULL DEFAULT 'draft'\n CHECK (status IN ('active', 'paused', 'ended', 'draft')),\n starts_at timestamptz,\n ends_at timestamptz,\n spend numeric NOT NULL DEFAULT 0,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_tenant ON public.plg_marketing_campaigns(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_channel ON public.plg_marketing_campaigns(tenant_id, channel_key);\n\nDO $$\nDECLARE t text;\nBEGIN\n FOR t IN SELECT unnest(ARRAY['plg_marketing_channels','plg_marketing_campaigns'])\n LOOP\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN\n 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);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);\n END IF;\n END LOOP;\nEND $$;\n";
|
|
7
7
|
export declare const MIGRATION_006_ANALYTICS_VIEWS = "-- Marketing Plugin: analytics read views.\n-- v_marketing_channels / v_marketing_campaigns \u2014 bridge views for the surface\n-- and the agent's data primitives.\n-- v_marketing_attribution \u2014 GENERIC, spine-only attribution event stream\n-- (people/appointments/orders metadata). `channel_raw` is the unnormalized\n-- origin string; the provider matches it against the app's channel set\n-- (id or label, case/punctuation-insensitive).\n-- Apps whose origin lives in an app-local extension table (e.g. beauty's\n-- clients.origin) may CREATE OR REPLACE this view in their own incubator\n-- migration, KEEPING the column contract:\n-- (tenant_id uuid, kind text, source text, channel_raw text,\n-- occurred_at timestamptz, value numeric)\n\nCREATE OR REPLACE VIEW public.v_marketing_channels WITH (security_invoker=true) AS\nSELECT c.id, c.tenant_id, c.channel_key, c.label, c.icon, c.kind,\n c.is_active, c.monthly_spend, c.created_at, c.updated_at\nFROM public.plg_marketing_channels c;\nGRANT SELECT ON public.v_marketing_channels TO authenticated;\n\nCREATE OR REPLACE VIEW public.v_marketing_campaigns WITH (security_invoker=true) AS\nSELECT g.id, g.tenant_id, g.name, g.channel_key, ch.label AS channel_label,\n g.status, g.starts_at, g.ends_at, g.spend, g.created_at, g.updated_at\nFROM public.plg_marketing_campaigns g\nLEFT JOIN public.plg_marketing_channels ch\n ON ch.tenant_id = g.tenant_id AND ch.channel_key = g.channel_key;\nGRANT SELECT ON public.v_marketing_campaigns TO authenticated;\n\nCREATE OR REPLACE VIEW public.v_marketing_attribution WITH (security_invoker=true) AS\n-- lead events (CRM leads; sourceId aligns with lead-sources/channel ids)\nSELECT p.tenant_id,\n 'lead'::text AS kind,\n 'crm'::text AS source,\n COALESCE(p.metadata->>'sourceId', p.metadata->>'sourceName', p.metadata->>'origin') AS channel_raw,\n p.created_at AS occurred_at,\n NULLIF(p.metadata->>'value', '')::numeric AS value\nFROM public.people p\nWHERE p.kind = 'lead'\nUNION ALL\n-- booking conversions (agenda verticals)\nSELECT b.tenant_id, 'conversion', 'agenda',\n COALESCE(b.metadata->>'origin', pc.metadata->>'origin', pc.metadata->>'sourceId', pc.metadata->>'sourceName'),\n b.starts_at,\n o.total\nFROM public.appointments b\nLEFT JOIN public.people pc ON pc.id = b.party_id\nLEFT JOIN public.orders o ON o.id = b.order_id\nWHERE b.status NOT IN ('cancelled', 'no_show')\nUNION ALL\n-- order conversions (commerce verticals)\nSELECT o.tenant_id, 'conversion', 'orders',\n COALESCE(o.metadata->>'origin', pp.metadata->>'origin', pp.metadata->>'sourceId'),\n o.created_at,\n o.total\nFROM public.orders o\nLEFT JOIN public.people pp ON pp.id = o.party_id\nWHERE o.kind NOT IN ('appointment', 'deal')\n AND o.status NOT IN ('cancelled', 'draft')\nUNION ALL\n-- won-deal conversions (CRM verticals)\nSELECT o.tenant_id, 'conversion', 'crm',\n COALESCE(pp.metadata->>'sourceId', pp.metadata->>'sourceName', o.metadata->>'origin'),\n o.updated_at,\n o.total\nFROM public.orders o\nLEFT JOIN public.people pp ON pp.id = o.party_id\nWHERE o.kind = 'deal' AND o.status = 'won';\n\nGRANT SELECT ON public.v_marketing_attribution TO authenticated;\n";
|
|
8
8
|
export declare const MIGRATION_007_AGENT_RPCS = "-- ============================================================================\n-- plugin-marketing 007: server-plane agent write RPC.\n--\n-- public.agent_marketing_create_campaign \u2014 guarded campaign create for the\n-- assistant. Mirrors agent_agenda_create_appointment (agenda 005):\n-- * actor-authorized: public.agent_guard (spine 015) runs role\u2192plan\u2192limit\n-- BEFORE anything \u2014 denial comes back as structured jsonb;\n-- * channel is validated against the tenant's plg_marketing_channels rows\n-- (key or label, case/punctuation-insensitive); when the tenant has no\n-- channel rows yet (surface not opened \u2192 lazy seed not run) the raw key is\n-- accepted as-is;\n-- * audited: audit_logs row with the acting user.\n--\n-- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)\n-- \u2192 jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,\n-- error text}. GRANT authenticated+service_role \u2014 NEVER anon.\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION public.agent_marketing_create_campaign(\n p_tenant_id uuid,\n p_actor_user_id uuid,\n p_payload jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_denial jsonb;\n v_used int;\n v_name text;\n v_channel_raw text;\n v_channel_key text;\n v_status text;\n v_starts timestamptz;\n v_ends timestamptz;\n v_spend numeric;\n v_has_channels boolean;\n v_id uuid;\nBEGIN\n -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n v_name := left(trim(p_payload->>'name'), 200);\n v_channel_raw := trim(p_payload->>'channel_key');\n v_status := COALESCE(NULLIF(trim(p_payload->>'status'), ''), 'draft');\n v_spend := COALESCE(NULLIF(p_payload->>'spend', '')::numeric, 0);\n BEGIN\n v_starts := NULLIF(p_payload->>'starts_at', '')::timestamptz;\n v_ends := NULLIF(p_payload->>'ends_at', '')::timestamptz;\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);\n END;\n IF v_name IS NULL OR v_name = '' OR v_channel_raw IS NULL OR v_channel_raw = '' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'name and channel_key are required');\n END IF;\n IF v_status NOT IN ('active', 'paused', 'ended', 'draft') THEN\n RETURN jsonb_build_object('ok', false, 'error', 'status must be one of active|paused|ended|draft');\n END IF;\n\n -- \u2500\u2500 authorization: role \u2192 plan \u2192 campaigns_active cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n SELECT count(*) INTO v_used FROM plg_marketing_campaigns c\n WHERE c.tenant_id = p_tenant_id AND c.status = 'active';\n v_denial := agent_guard(p_tenant_id, p_actor_user_id,\n 'marketing', 'create', 'campaigns_active', v_used,\n CASE WHEN v_status = 'active' THEN 1 ELSE 0 END);\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n -- \u2500\u2500 channel resolution (key or label, normalized) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n SELECT EXISTS (SELECT 1 FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id)\n INTO v_has_channels;\n IF v_has_channels THEN\n SELECT ch.channel_key INTO v_channel_key\n FROM plg_marketing_channels ch\n WHERE ch.tenant_id = p_tenant_id\n AND (\n regexp_replace(lower(ch.channel_key), '[^a-z0-9]', '', 'g')\n = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')\n OR regexp_replace(lower(ch.label), '[^a-z0-9]', '', 'g')\n = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')\n )\n LIMIT 1;\n IF v_channel_key IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'unknown channel \"' || v_channel_raw || '\" \u2014 valid channels: ' ||\n COALESCE((SELECT string_agg(ch.channel_key, ', ' ORDER BY ch.channel_key)\n FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id), ''));\n END IF;\n ELSE\n v_channel_key := v_channel_raw;\n END IF;\n\n -- \u2500\u2500 write + audit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n INSERT INTO plg_marketing_campaigns (tenant_id, name, channel_key, status, starts_at, ends_at, spend)\n VALUES (p_tenant_id, v_name, v_channel_key, v_status, COALESCE(v_starts, now()), v_ends, v_spend)\n RETURNING id INTO v_id;\n\n INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant_id, p_actor_user_id, 'agent.createCampaign', 'marketing_campaign', v_id::text,\n jsonb_build_object('payload', p_payload));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_id,\n 'record', jsonb_build_object(\n 'id', v_id,\n 'name', v_name,\n 'channel_key', v_channel_key,\n 'status', v_status,\n 'starts_at', COALESCE(v_starts, now()),\n 'spend', v_spend\n )\n );\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM public;\nREVOKE EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM anon;\nGRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb)\n TO authenticated, service_role;\n";
|
|
9
|
+
export declare const MIGRATION_008_LANDING_PAGES = "-- Marketing Plugin: landing pages.\n-- Prefix: plg_marketing_\n-- A landing page IS a site plus a route (espacorenovario.com.br + /lp-capilar).\n-- The SITE is not modelled here: it is the tenant's fayz project\n-- (public.fayz_projects, @fayz-ai/db 020) \u2014 the same rows Settings \u2192 Sites\n-- manages \u2014 so `app_id` / `fayz_project_id` / `preview_url` come from there.\n-- Visit analytics are read from the platform that hosts the deploy (app id +\n-- route + period); that source is not wired yet, so visits render as pending.\n-- `lead_tags` is the cross with the CRM: leads that arrive through the page\n-- carry those tag slugs in people.tags (v_marketing_lead_tag_events counts them).\n-- `status`='planned' + `prompt` is a page that does NOT exist on the site yet:\n-- the brief is written here and the page gets built with Fayz later.\n-- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).\n\nCREATE TABLE IF NOT EXISTS public.plg_marketing_landing_pages (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n site_id uuid NOT NULL REFERENCES public.fayz_projects(id) ON DELETE CASCADE,\n name text NOT NULL,\n route text NOT NULL,\n lead_tags text[] NOT NULL DEFAULT '{}',\n status text NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'planned')),\n prompt text,\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\n-- Self-healing for pools that ran an earlier cut of this migration (single\n-- lead_tag column, no status/prompt). No-op on a fresh table. The views are\n-- rebuilt at the bottom; dropping them first is what lets the column go.\nDROP VIEW IF EXISTS public.v_marketing_landing_pages;\n\nALTER TABLE public.plg_marketing_landing_pages\n ADD COLUMN IF NOT EXISTS lead_tags text[] NOT NULL DEFAULT '{}',\n ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'live',\n ADD COLUMN IF NOT EXISTS prompt text;\n\nDO $$\nBEGIN\n IF EXISTS (\n SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = 'plg_marketing_landing_pages' AND column_name = 'lead_tag'\n ) THEN\n UPDATE public.plg_marketing_landing_pages\n SET lead_tags = ARRAY[lead_tag]\n WHERE lead_tag IS NOT NULL AND lead_tags = '{}';\n ALTER TABLE public.plg_marketing_landing_pages DROP COLUMN lead_tag;\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = 'plg_marketing_landing_pages_status_check'\n ) THEN\n ALTER TABLE public.plg_marketing_landing_pages\n ADD CONSTRAINT plg_marketing_landing_pages_status_check CHECK (status IN ('live', 'planned'));\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_tenant ON public.plg_marketing_landing_pages(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_landing_pages_site ON public.plg_marketing_landing_pages(site_id);\n-- One row per route within a site: the route IS the page's identity.\nCREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_landing_pages_site_route\n ON public.plg_marketing_landing_pages(site_id, route);\n\nDO $$\nDECLARE t text := 'plg_marketing_landing_pages';\nBEGIN\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN\n 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);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);\n END IF;\nEND $$;\n\n-- Read view: the page joined to its site, so the surface renders the full URL\n-- (and the analytics keys) without a second round trip.\nCREATE VIEW public.v_marketing_landing_pages WITH (security_invoker=true) AS\nSELECT lp.id, lp.tenant_id, lp.site_id, lp.name, lp.route, lp.lead_tags, lp.status, lp.prompt, lp.is_active,\n s.name AS site_name, s.preview_url AS site_url, s.app_id, s.fayz_project_id, s.editor_url,\n lp.created_at, lp.updated_at\nFROM public.plg_marketing_landing_pages lp\nJOIN public.fayz_projects s ON s.id = lp.site_id;\nGRANT SELECT ON public.v_marketing_landing_pages TO authenticated;\n\n-- Lead events keyed by tag \u2014 the cross that turns \"leads tagged lp-capilar\"\n-- into that page's conversions. One row per (lead, tag); `person_id` is what\n-- keeps a lead carrying two of a page's tags from counting twice. Archived\n-- leads are OUT: plugin-crm archives by flipping people.is_active to false, and\n-- a page's number has to match what the funnel shows. Apps whose leads live\n-- elsewhere may CREATE OR REPLACE this view keeping the contract:\n-- (tenant_id uuid, tag text, person_id uuid, occurred_at timestamptz, value numeric)\nDROP VIEW IF EXISTS public.v_marketing_lead_tag_events;\nCREATE VIEW public.v_marketing_lead_tag_events WITH (security_invoker=true) AS\nSELECT p.tenant_id,\n tag,\n p.id AS person_id,\n p.created_at AS occurred_at,\n NULLIF(p.metadata->>'value', '')::numeric AS value\nFROM public.people p\nCROSS JOIN LATERAL unnest(COALESCE(p.tags, '{}'::text[])) AS tag\nWHERE p.kind = 'lead'\n AND COALESCE(p.is_active, true);\nGRANT SELECT ON public.v_marketing_lead_tag_events TO authenticated;\n";
|
|
9
10
|
export declare const MIGRATIONS: Array<{
|
|
10
11
|
id: string;
|
|
11
12
|
sql: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,wBAAwB,i6BAepC,CAAA;AAED,eAAO,MAAM,6BAA6B,ivNA8FzC,CAAA;AAED,eAAO,MAAM,4BAA4B,inCAyBxC,CAAA;AAED,eAAO,MAAM,2BAA2B,8/CAyBvC,CAAA;AAED,eAAO,MAAM,uBAAuB,6tHAkDnC,CAAA;AAED,eAAO,MAAM,4BAA4B,k4GA4DxC,CAAA;AAED,eAAO,MAAM,6BAA6B,6rGAoEzC,CAAA;AAED,eAAO,MAAM,wBAAwB,qgMAuHpC,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,wBAAwB,i6BAepC,CAAA;AAED,eAAO,MAAM,6BAA6B,ivNA8FzC,CAAA;AAED,eAAO,MAAM,4BAA4B,inCAyBxC,CAAA;AAED,eAAO,MAAM,2BAA2B,8/CAyBvC,CAAA;AAED,eAAO,MAAM,uBAAuB,6tHAkDnC,CAAA;AAED,eAAO,MAAM,4BAA4B,k4GA4DxC,CAAA;AAED,eAAO,MAAM,6BAA6B,6rGAoEzC,CAAA;AAED,eAAO,MAAM,wBAAwB,qgMAuHpC,CAAA;AAED,eAAO,MAAM,2BAA2B,spMAgHvC,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAUzD,CAAA"}
|
package/dist/registries.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { PluginRegistryDef } from '@fayz-ai/core';
|
|
|
2
2
|
import type { AcquisitionChannel } from './types';
|
|
3
3
|
/** Build the marketing settings registries. Channels are tenant-editable rows
|
|
4
4
|
* in plg_marketing_channels; the Supabase provider lazily seeds them from the
|
|
5
|
-
* resolved domain preset (seedData doubles as walkable mock data).
|
|
5
|
+
* resolved domain preset (seedData doubles as walkable mock data). Sites are
|
|
6
|
+
* NOT here on purpose: they are the tenant's fayz projects, managed by the
|
|
7
|
+
* shell in Settings → Sites, and marketing only points a page at one. */
|
|
6
8
|
export declare function buildMarketingRegistries(channels: AcquisitionChannel[]): PluginRegistryDef[];
|
|
7
9
|
//# sourceMappingURL=registries.d.ts.map
|
package/dist/registries.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registries.d.ts","sourceRoot":"","sources":["../src/registries.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,eAAe,CAAA;AACjE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAwCjD
|
|
1
|
+
{"version":3,"file":"registries.d.ts","sourceRoot":"","sources":["../src/registries.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,eAAe,CAAA;AACjE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAwCjD;;;;0EAI0E;AAC1E,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,kBAAkB,EAAE,GAAG,iBAAiB,EAAE,CAe5F"}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type StoreApi } from 'zustand/vanilla';
|
|
2
|
-
import type { MarketingDataProvider, SaveCampaignInput } from './data/types';
|
|
3
|
-
import type { Campaign, ChannelPerformance, DateRangeKey, FunnelStage, LandingPagePerf, MarketingOverview } from './types';
|
|
2
|
+
import type { MarketingDataProvider, SaveCampaignInput, SaveLandingPageInput } from './data/types';
|
|
3
|
+
import type { Campaign, ChannelPerformance, DateRangeKey, FunnelStage, LandingPage, LandingPagePerf, MarketingOverview, MarketingSite } from './types';
|
|
4
4
|
export interface MarketingUIState {
|
|
5
5
|
range: DateRangeKey;
|
|
6
6
|
overview: MarketingOverview | null;
|
|
@@ -8,6 +8,11 @@ export interface MarketingUIState {
|
|
|
8
8
|
campaigns: Campaign[];
|
|
9
9
|
funnel: FunnelStage[];
|
|
10
10
|
landingPages: LandingPagePerf[];
|
|
11
|
+
sites: MarketingSite[];
|
|
12
|
+
leadTags: Array<{
|
|
13
|
+
slug: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}>;
|
|
11
16
|
loading: boolean;
|
|
12
17
|
load(): Promise<void>;
|
|
13
18
|
/** Invalidate the provider cache and reload (external writes, e.g. agent RPCs). */
|
|
@@ -15,6 +20,8 @@ export interface MarketingUIState {
|
|
|
15
20
|
setRange(range: DateRangeKey): Promise<void>;
|
|
16
21
|
saveCampaign(input: SaveCampaignInput): Promise<Campaign>;
|
|
17
22
|
deleteCampaign(id: string): Promise<void>;
|
|
23
|
+
saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage>;
|
|
24
|
+
deleteLandingPage(id: string): Promise<void>;
|
|
18
25
|
}
|
|
19
26
|
export declare function createMarketingStore(provider: MarketingDataProvider): StoreApi<MarketingUIState>;
|
|
20
27
|
//# sourceMappingURL=store.d.ts.map
|
package/dist/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC5D,OAAO,KAAK,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC5D,OAAO,KAAK,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAA;AAClG,OAAO,KAAK,EACV,QAAQ,EACR,kBAAkB,EAClB,YAAY,EACZ,WAAW,EACX,WAAW,EACX,eAAe,EACf,iBAAiB,EACjB,aAAa,EACd,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,YAAY,CAAA;IACnB,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAA;IAClC,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,SAAS,EAAE,QAAQ,EAAE,CAAA;IACrB,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,YAAY,EAAE,eAAe,EAAE,CAAA;IAC/B,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC/C,OAAO,EAAE,OAAO,CAAA;IAEhB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,mFAAmF;IACnF,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACzD,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACzC,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAClE,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7C;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,qBAAqB,GAAG,QAAQ,CAAC,gBAAgB,CAAC,CAmEhG"}
|
package/dist/types.d.ts
CHANGED
|
@@ -55,14 +55,50 @@ export interface FunnelStage {
|
|
|
55
55
|
count: number;
|
|
56
56
|
color: string;
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
/** A site the tenant owns — a row of public.fayz_projects, managed in
|
|
59
|
+
* Settings → Sites. `appId`/`fayzProjectId` are the deploy's ids on the
|
|
60
|
+
* hosting platform: the keys the visit-analytics call needs (app + route +
|
|
61
|
+
* period). Marketing reads these; it does not own them. */
|
|
62
|
+
export interface MarketingSite {
|
|
59
63
|
id: string;
|
|
60
64
|
name: string;
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
/** live site URL, e.g. 'https://espacorenovario.com.br' */
|
|
66
|
+
url?: string;
|
|
67
|
+
appId?: string;
|
|
68
|
+
fayzProjectId?: string;
|
|
69
|
+
/** Fayz editor for this project — where a planned page gets built. */
|
|
70
|
+
editorUrl?: string;
|
|
71
|
+
}
|
|
72
|
+
/** 'live' = the route already exists on the site. 'planned' = it does not yet;
|
|
73
|
+
* `prompt` is the brief for building it with Fayz. */
|
|
74
|
+
export type LandingPageStatus = 'live' | 'planned';
|
|
75
|
+
/** A landing page IS a site + a route. `leadTags` is the CRM cross: leads that
|
|
76
|
+
* come in through the page carry those tag slugs. */
|
|
77
|
+
export interface LandingPage {
|
|
78
|
+
id: string;
|
|
79
|
+
siteId: string;
|
|
80
|
+
name: string;
|
|
81
|
+
/** path within the site, always leading-slashed, e.g. '/lp-capilar' */
|
|
82
|
+
route: string;
|
|
83
|
+
leadTags: string[];
|
|
84
|
+
status: LandingPageStatus;
|
|
85
|
+
/** what the page should be — only meaningful while status is 'planned' */
|
|
86
|
+
prompt?: string;
|
|
87
|
+
isActive: boolean;
|
|
88
|
+
}
|
|
89
|
+
export interface LandingPagePerf extends LandingPage {
|
|
90
|
+
siteName: string;
|
|
91
|
+
siteUrl?: string;
|
|
92
|
+
appId?: string;
|
|
93
|
+
editorUrl?: string;
|
|
94
|
+
/** full public URL (site + route); the bare route when the site has no URL */
|
|
95
|
+
url: string;
|
|
96
|
+
/** null while no visit source is connected — renders as "—", not as zero. */
|
|
97
|
+
visits: number | null;
|
|
98
|
+
/** leads carrying `leadTag` within the queried period */
|
|
63
99
|
conversions: number;
|
|
64
|
-
/** conversions / visits * 100 */
|
|
65
|
-
cvr: number;
|
|
100
|
+
/** conversions / visits * 100 — null when visits are unknown */
|
|
101
|
+
cvr: number | null;
|
|
66
102
|
}
|
|
67
103
|
export interface MarketingOverview {
|
|
68
104
|
conversions: number;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,sBAAsB,GAC9B,MAAM,GACN,SAAS,GACT,QAAQ,GACR,UAAU,GACV,QAAQ,GACR,UAAU,CAAA;AAEd,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,sBAAsB,CAAA;CAC7B;AAED,+DAA+D;AAC/D,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAErE,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAA;IACV,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,gBAAgB,CAAA;IACxB,uEAAuE;IACvE,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAA;AAEpE,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,cAAc,CAAA;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAA;CACd;AAED,+DAA+D;AAC/D,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,gCAAgC;IAChC,GAAG,EAAE,MAAM,CAAA;IACX,4CAA4C;IAC5C,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { LandingPage } from '../types';
|
|
3
|
+
export declare function LandingPageComposer({ open, page, onClose, }: {
|
|
4
|
+
open: boolean;
|
|
5
|
+
page?: LandingPage | null;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
}): React.JSX.Element;
|
|
8
|
+
//# sourceMappingURL=LandingPageComposer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LandingPageComposer.d.ts","sourceRoot":"","sources":["../../src/views/LandingPageComposer.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AASzB,OAAO,KAAK,EAAE,WAAW,EAAqB,MAAM,UAAU,CAAA;AAW9D,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EAAE,IAAI,EAAE,OAAO,GACpB,EAAE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,qBAkLnE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LandingPagesView.d.ts","sourceRoot":"","sources":["../../src/views/LandingPagesView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"LandingPagesView.d.ts","sourceRoot":"","sources":["../../src/views/LandingPagesView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAYzB,wBAAgB,gBAAgB,sBA+J/B"}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"fayz": {
|
|
4
4
|
"status": "beta"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.9.0
|
|
6
|
+
"version": "0.9.0",
|
|
7
7
|
"description": "Fayz SDK — marketing campaigns & broadcasts plugin",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"sideEffects": false,
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"react": "^18.0.0 || ^19.0.0",
|
|
32
32
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
33
|
-
"zustand": "^4.5.0",
|
|
33
|
+
"zustand": "^4.5.0 || ^5.0.0",
|
|
34
34
|
"lucide-react": ">=0.400.0 <1.0.0"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@tanstack/react-table": "^8.20.0",
|
|
38
|
-
"@fayz-ai/core": "^0.9.0
|
|
39
|
-
"@fayz-ai/saas": "^0.9.0
|
|
40
|
-
"@fayz-ai/
|
|
41
|
-
"@fayz-ai/
|
|
38
|
+
"@fayz-ai/core": "^0.9.0",
|
|
39
|
+
"@fayz-ai/saas": "^0.9.0",
|
|
40
|
+
"@fayz-ai/ui": "^0.9.0",
|
|
41
|
+
"@fayz-ai/db": "^0.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/react": "^18.3.0",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"tsup": "^8.2.0",
|
|
47
47
|
"typescript": "^5.5.0",
|
|
48
48
|
"@types/react-dom": "^18.3.0",
|
|
49
|
-
"vitest": "^
|
|
49
|
+
"vitest": "^4.1.5",
|
|
50
50
|
"zustand": "^4.5.0",
|
|
51
51
|
"lucide-react": "^0.400.0"
|
|
52
52
|
},
|
package/src/data/mock.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MarketingDataProvider, SaveCampaignInput } from './types'
|
|
1
|
+
import type { MarketingDataProvider, SaveCampaignInput, SaveLandingPageInput } from './types'
|
|
2
2
|
import type {
|
|
3
3
|
AcquisitionChannel,
|
|
4
4
|
AcquisitionChannelKind,
|
|
@@ -7,9 +7,11 @@ import type {
|
|
|
7
7
|
ConversionModel,
|
|
8
8
|
DateRangeKey,
|
|
9
9
|
FunnelStage,
|
|
10
|
+
LandingPage,
|
|
10
11
|
LandingPagePerf,
|
|
11
12
|
MarketingOverview,
|
|
12
13
|
MarketingQuery,
|
|
14
|
+
MarketingSite,
|
|
13
15
|
} from '../types'
|
|
14
16
|
import { CAMPAIGN_NAME_SEEDS, type MarketingDomain } from '../presets'
|
|
15
17
|
|
|
@@ -118,16 +120,37 @@ function scaleCampaign(c: Campaign, range: DateRangeKey): Campaign {
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
const MOCK_SITE: MarketingSite = {
|
|
124
|
+
id: 'site-1',
|
|
125
|
+
name: 'Site principal',
|
|
126
|
+
url: 'https://exemplo.com.br',
|
|
127
|
+
appId: 'app-demo',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const MOCK_LEAD_TAGS = [
|
|
131
|
+
{ slug: 'lp-consulta', name: 'LP Consulta' },
|
|
132
|
+
{ slug: 'lp-promo', name: 'LP Promo' },
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
interface MockLandingPage extends LandingPage {
|
|
136
|
+
/** 30d baseline; scaled per range at read time. */
|
|
137
|
+
visits: number
|
|
138
|
+
conversions: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function seedLandingPages(): MockLandingPage[] {
|
|
123
142
|
return [
|
|
124
|
-
{ id: 'lp-1', name: '
|
|
125
|
-
{ id: 'lp-2', name: '
|
|
126
|
-
{ id: 'lp-3', name: 'Pricing Page', type: 'Landing page', visits: 2240, conversions: 198 },
|
|
127
|
-
{ id: 'lp-4', name: 'Company Website', type: 'Website', visits: 9240, conversions: 296 },
|
|
143
|
+
{ id: 'lp-1', siteId: MOCK_SITE.id, name: 'Consulta gratuita', route: '/consulta', leadTags: ['lp-consulta'], status: 'live', isActive: true, visits: 3820, conversions: 474 },
|
|
144
|
+
{ id: 'lp-2', siteId: MOCK_SITE.id, name: 'Promo da estação', route: '/promo', leadTags: ['lp-promo'], status: 'live', isActive: true, visits: 1560, conversions: 126 },
|
|
128
145
|
]
|
|
129
146
|
}
|
|
130
147
|
|
|
148
|
+
function normalizeRoute(route: string): string {
|
|
149
|
+
const trimmed = route.trim().replace(/\/+$/, '')
|
|
150
|
+
if (!trimmed || trimmed === '/') return '/'
|
|
151
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
export function createMockMarketingProvider(cfg: MockMarketingConfig): MarketingDataProvider {
|
|
132
155
|
const base = channelBase30(cfg.channels, cfg.conversion)
|
|
133
156
|
const campaigns = seedCampaigns(cfg)
|
|
@@ -226,8 +249,60 @@ export function createMockMarketingProvider(cfg: MockMarketingConfig): Marketing
|
|
|
226
249
|
return landing.map((p) => {
|
|
227
250
|
const visits = scale(p.visits, query.range)
|
|
228
251
|
const conversions = scale(p.conversions, query.range)
|
|
229
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
...p,
|
|
254
|
+
siteName: MOCK_SITE.name,
|
|
255
|
+
siteUrl: MOCK_SITE.url,
|
|
256
|
+
appId: MOCK_SITE.appId,
|
|
257
|
+
editorUrl: MOCK_SITE.editorUrl,
|
|
258
|
+
url: `${MOCK_SITE.url}${p.route}`,
|
|
259
|
+
visits,
|
|
260
|
+
conversions,
|
|
261
|
+
cvr: visits > 0 ? (conversions / visits) * 100 : null,
|
|
262
|
+
}
|
|
230
263
|
})
|
|
231
264
|
},
|
|
265
|
+
|
|
266
|
+
async saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage> {
|
|
267
|
+
const route = normalizeRoute(input.route)
|
|
268
|
+
const existing = input.id ? landing.find((p) => p.id === input.id) : undefined
|
|
269
|
+
if (existing) {
|
|
270
|
+
Object.assign(existing, {
|
|
271
|
+
siteId: input.siteId, name: input.name, route,
|
|
272
|
+
leadTags: input.leadTags ?? existing.leadTags,
|
|
273
|
+
status: input.status ?? existing.status,
|
|
274
|
+
prompt: input.prompt,
|
|
275
|
+
isActive: input.isActive ?? existing.isActive,
|
|
276
|
+
})
|
|
277
|
+
return existing
|
|
278
|
+
}
|
|
279
|
+
const created: MockLandingPage = {
|
|
280
|
+
id: `lp-${++counter}`,
|
|
281
|
+
siteId: input.siteId,
|
|
282
|
+
name: input.name,
|
|
283
|
+
route,
|
|
284
|
+
leadTags: input.leadTags ?? [],
|
|
285
|
+
status: input.status ?? 'live',
|
|
286
|
+
prompt: input.prompt,
|
|
287
|
+
isActive: input.isActive ?? true,
|
|
288
|
+
visits: 0,
|
|
289
|
+
conversions: 0,
|
|
290
|
+
}
|
|
291
|
+
landing.push(created)
|
|
292
|
+
return created
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
async deleteLandingPage(id: string): Promise<void> {
|
|
296
|
+
const idx = landing.findIndex((p) => p.id === id)
|
|
297
|
+
if (idx >= 0) landing.splice(idx, 1)
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
async listSites(): Promise<MarketingSite[]> {
|
|
301
|
+
return [MOCK_SITE]
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async listLeadTags(): Promise<Array<{ slug: string; name: string }>> {
|
|
305
|
+
return MOCK_LEAD_TAGS
|
|
306
|
+
},
|
|
232
307
|
}
|
|
233
308
|
}
|
package/src/data/supabase.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
AttributionBridge,
|
|
4
4
|
MarketingDataProvider,
|
|
5
5
|
SaveCampaignInput,
|
|
6
|
+
SaveLandingPageInput,
|
|
6
7
|
SitesPerformanceBridge,
|
|
7
8
|
} from './types'
|
|
8
9
|
import type {
|
|
@@ -12,11 +13,13 @@ import type {
|
|
|
12
13
|
ConversionModel,
|
|
13
14
|
DateRangeKey,
|
|
14
15
|
FunnelStage,
|
|
16
|
+
LandingPage,
|
|
15
17
|
LandingPagePerf,
|
|
16
18
|
MarketingOverview,
|
|
17
19
|
MarketingQuery,
|
|
20
|
+
MarketingSite,
|
|
18
21
|
} from '../types'
|
|
19
|
-
import { T, V } from './tables'
|
|
22
|
+
import { LEAD_TAGS_SOURCE, SITES_TABLE, T, V } from './tables'
|
|
20
23
|
|
|
21
24
|
// ---------------------------------------------------------------------------
|
|
22
25
|
// Supabase marketing provider. Channels/campaigns are real CRUD rows
|
|
@@ -32,6 +35,8 @@ export interface SupabaseMarketingProviderOptions {
|
|
|
32
35
|
conversion: ConversionModel
|
|
33
36
|
attributionBridge?: AttributionBridge
|
|
34
37
|
sitesBridge?: SitesPerformanceBridge
|
|
38
|
+
/** Where the lead-tag catalog is read from (defaults to plugin-crm's). */
|
|
39
|
+
leadTags?: { table?: string; valueField?: string; labelField?: string }
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
const RANGE_DAYS: Record<DateRangeKey, number> = { '7d': 7, '30d': 30, '90d': 90 }
|
|
@@ -54,6 +59,56 @@ function normKey(value: string | null | undefined): string {
|
|
|
54
59
|
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
/** Fallback editor link when the project row has no explicit editor_url —
|
|
63
|
+
* same convention the shell's "Seu site" widget uses. */
|
|
64
|
+
const FAYZ_EDITOR_BASE = 'https://fayz.ai/editor'
|
|
65
|
+
|
|
66
|
+
/** '/lp-capilar' ← 'lp-capilar' | 'lp-capilar/' | '' */
|
|
67
|
+
function normalizeRoute(route: string): string {
|
|
68
|
+
const trimmed = route.trim().replace(/\/+$/, '')
|
|
69
|
+
if (!trimmed || trimmed === '/') return '/'
|
|
70
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The site's URL is whatever the fayz project recorded (may be missing while
|
|
74
|
+
* the deploy has no public URL yet) — then the page shows just its route. */
|
|
75
|
+
function pageUrl(siteUrl: string | null | undefined, route: string): string {
|
|
76
|
+
const path = normalizeRoute(route)
|
|
77
|
+
if (!siteUrl) return path
|
|
78
|
+
const base = siteUrl.trim().replace(/\/+$/, '')
|
|
79
|
+
const withScheme = /^https?:\/\//.test(base) ? base : `https://${base}`
|
|
80
|
+
return `${withScheme}${path === '/' ? '' : path}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function landingPageFromRow(row: Record<string, any>): LandingPage {
|
|
84
|
+
return {
|
|
85
|
+
id: row.id,
|
|
86
|
+
siteId: row.site_id,
|
|
87
|
+
name: row.name,
|
|
88
|
+
route: row.route,
|
|
89
|
+
leadTags: row.lead_tags ?? [],
|
|
90
|
+
status: row.status === 'planned' ? 'planned' : 'live',
|
|
91
|
+
prompt: row.prompt ?? undefined,
|
|
92
|
+
isActive: row.is_active !== false,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface LandingPageRow {
|
|
97
|
+
id: string
|
|
98
|
+
site_id: string
|
|
99
|
+
name: string
|
|
100
|
+
route: string
|
|
101
|
+
lead_tags: string[] | null
|
|
102
|
+
status: string
|
|
103
|
+
prompt: string | null
|
|
104
|
+
is_active: boolean
|
|
105
|
+
site_name: string
|
|
106
|
+
site_url: string | null
|
|
107
|
+
app_id: string | null
|
|
108
|
+
fayz_project_id: string | null
|
|
109
|
+
editor_url: string | null
|
|
110
|
+
}
|
|
111
|
+
|
|
57
112
|
interface AttributionRow {
|
|
58
113
|
kind: 'lead' | 'conversion'
|
|
59
114
|
source: string
|
|
@@ -66,6 +121,7 @@ export function createSupabaseMarketingProvider(
|
|
|
66
121
|
options: SupabaseMarketingProviderOptions,
|
|
67
122
|
): MarketingDataProvider {
|
|
68
123
|
const { channels, conversion, attributionBridge, sitesBridge } = options
|
|
124
|
+
const leadTagsSource = { ...LEAD_TAGS_SOURCE, ...options.leadTags }
|
|
69
125
|
|
|
70
126
|
const channelByNorm = new Map<string, string>()
|
|
71
127
|
for (const ch of channels) {
|
|
@@ -237,6 +293,66 @@ export function createSupabaseMarketingProvider(
|
|
|
237
293
|
return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list
|
|
238
294
|
}
|
|
239
295
|
|
|
296
|
+
function fetchLandingPages(tenantId: string): Promise<LandingPageRow[]> {
|
|
297
|
+
return memo(`lp:${tenantId}`, async () => {
|
|
298
|
+
const { data, error } = await getClient()
|
|
299
|
+
.from(V.landingPages)
|
|
300
|
+
.select('id, site_id, name, route, lead_tags, status, prompt, is_active, site_name, site_url, app_id, fayz_project_id, editor_url')
|
|
301
|
+
.eq('tenant_id', tenantId)
|
|
302
|
+
.order('name')
|
|
303
|
+
// A pool that hasn't run migration 008 has no view — that must cost the
|
|
304
|
+
// page its rows, not take the whole Marketing surface down with it.
|
|
305
|
+
if (error) return []
|
|
306
|
+
return (data ?? []) as LandingPageRow[]
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Leads carrying each page's tags within the window — the CRM cross. Keyed
|
|
311
|
+
* by tag → lead ids so a page with several tags counts each lead once. */
|
|
312
|
+
function fetchLeadsByTag(
|
|
313
|
+
tenantId: string,
|
|
314
|
+
tags: string[],
|
|
315
|
+
since: Date,
|
|
316
|
+
): Promise<Map<string, Set<string>>> {
|
|
317
|
+
const key = `lptags:${tenantId}:${since.getTime()}:${tags.join(',')}`
|
|
318
|
+
return memo(key, async () => {
|
|
319
|
+
const { data, error } = await getClient()
|
|
320
|
+
.from(V.leadTagEvents)
|
|
321
|
+
.select('tag, person_id')
|
|
322
|
+
.eq('tenant_id', tenantId)
|
|
323
|
+
.in('tag', tags)
|
|
324
|
+
.gte('occurred_at', since.toISOString())
|
|
325
|
+
if (error) return new Map<string, Set<string>>()
|
|
326
|
+
const byTag = new Map<string, Set<string>>()
|
|
327
|
+
for (const row of data ?? []) {
|
|
328
|
+
const set = byTag.get(row.tag) ?? new Set<string>()
|
|
329
|
+
set.add(row.person_id)
|
|
330
|
+
byTag.set(row.tag, set)
|
|
331
|
+
}
|
|
332
|
+
return byTag
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Visits come from wherever the site is deployed. No bridge ⇒ unknown, and
|
|
337
|
+
* the surface says so instead of showing zeros. */
|
|
338
|
+
async function fetchVisits(
|
|
339
|
+
range: DateRangeKey,
|
|
340
|
+
rows: LandingPageRow[],
|
|
341
|
+
): Promise<Map<string, number>> {
|
|
342
|
+
if (!sitesBridge) return new Map()
|
|
343
|
+
const result = await sitesBridge.visitsByRoute({
|
|
344
|
+
range,
|
|
345
|
+
pages: rows.map((r) => ({
|
|
346
|
+
id: r.id,
|
|
347
|
+
appId: r.app_id ?? undefined,
|
|
348
|
+
fayzProjectId: r.fayz_project_id ?? undefined,
|
|
349
|
+
siteUrl: r.site_url ?? undefined,
|
|
350
|
+
route: r.route,
|
|
351
|
+
})),
|
|
352
|
+
})
|
|
353
|
+
return new Map(result.map((r) => [r.id, r.visits]))
|
|
354
|
+
}
|
|
355
|
+
|
|
240
356
|
function campaignFromRow(row: Record<string, any>): Campaign {
|
|
241
357
|
return {
|
|
242
358
|
id: row.id,
|
|
@@ -394,8 +510,104 @@ export function createSupabaseMarketingProvider(
|
|
|
394
510
|
},
|
|
395
511
|
|
|
396
512
|
async landingPages(query: MarketingQuery): Promise<LandingPagePerf[]> {
|
|
397
|
-
|
|
398
|
-
|
|
513
|
+
const tenantId = requireTenantId()
|
|
514
|
+
const { since } = windowFor(query.range)
|
|
515
|
+
const rows = await fetchLandingPages(tenantId)
|
|
516
|
+
if (!rows.length) return []
|
|
517
|
+
|
|
518
|
+
const tags = [...new Set(rows.flatMap((r) => r.lead_tags ?? []))]
|
|
519
|
+
const [leadsByTag, visitsById] = await Promise.all([
|
|
520
|
+
tags.length ? fetchLeadsByTag(tenantId, tags, since) : Promise.resolve(new Map<string, Set<string>>()),
|
|
521
|
+
fetchVisits(query.range, rows),
|
|
522
|
+
])
|
|
523
|
+
|
|
524
|
+
return rows.map((r) => {
|
|
525
|
+
const visits = visitsById.get(r.id) ?? null
|
|
526
|
+
const leads = new Set<string>()
|
|
527
|
+
for (const tag of r.lead_tags ?? []) {
|
|
528
|
+
for (const personId of leadsByTag.get(tag) ?? []) leads.add(personId)
|
|
529
|
+
}
|
|
530
|
+
const conversions = leads.size
|
|
531
|
+
return {
|
|
532
|
+
...landingPageFromRow(r),
|
|
533
|
+
siteName: r.site_name,
|
|
534
|
+
siteUrl: r.site_url ?? undefined,
|
|
535
|
+
appId: r.app_id ?? undefined,
|
|
536
|
+
editorUrl: r.editor_url ?? (r.fayz_project_id ? `${FAYZ_EDITOR_BASE}/${r.fayz_project_id}` : undefined),
|
|
537
|
+
url: pageUrl(r.site_url, r.route),
|
|
538
|
+
visits,
|
|
539
|
+
conversions,
|
|
540
|
+
cvr: visits && visits > 0 ? (conversions / visits) * 100 : null,
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
async saveLandingPage(input: SaveLandingPageInput): Promise<LandingPage> {
|
|
546
|
+
const tenantId = requireTenantId()
|
|
547
|
+
const payload = {
|
|
548
|
+
tenant_id: tenantId,
|
|
549
|
+
site_id: input.siteId,
|
|
550
|
+
name: input.name,
|
|
551
|
+
route: normalizeRoute(input.route),
|
|
552
|
+
lead_tags: input.leadTags ?? [],
|
|
553
|
+
status: input.status ?? 'live',
|
|
554
|
+
prompt: input.prompt?.trim() || null,
|
|
555
|
+
is_active: input.isActive ?? true,
|
|
556
|
+
updated_at: new Date().toISOString(),
|
|
557
|
+
}
|
|
558
|
+
const query = input.id
|
|
559
|
+
? getClient().from(T.landingPages).update(payload).eq('id', input.id)
|
|
560
|
+
: getClient().from(T.landingPages).insert(payload)
|
|
561
|
+
const { data, error } = await query.select('*').single()
|
|
562
|
+
if (error) throw error
|
|
563
|
+
cache.clear()
|
|
564
|
+
return landingPageFromRow(data)
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
async deleteLandingPage(id: string): Promise<void> {
|
|
568
|
+
const { error } = await getClient().from(T.landingPages).delete().eq('id', id)
|
|
569
|
+
if (error) throw error
|
|
570
|
+
cache.clear()
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
async listSites(): Promise<MarketingSite[]> {
|
|
574
|
+
const tenantId = requireTenantId()
|
|
575
|
+
return memo(`sites:${tenantId}`, async () => {
|
|
576
|
+
// Owned by the shell (Settings → Sites); marketing just picks one.
|
|
577
|
+
const { data, error } = await getClient()
|
|
578
|
+
.from(SITES_TABLE)
|
|
579
|
+
.select('id, name, preview_url, app_id, fayz_project_id, editor_url')
|
|
580
|
+
.eq('tenant_id', tenantId)
|
|
581
|
+
.order('is_default', { ascending: false })
|
|
582
|
+
.order('name')
|
|
583
|
+
if (error) return []
|
|
584
|
+
return (data ?? []).map((r: any) => ({
|
|
585
|
+
id: r.id,
|
|
586
|
+
name: r.name,
|
|
587
|
+
url: r.preview_url ?? undefined,
|
|
588
|
+
appId: r.app_id ?? undefined,
|
|
589
|
+
fayzProjectId: r.fayz_project_id ?? undefined,
|
|
590
|
+
editorUrl: r.editor_url ?? (r.fayz_project_id ? `${FAYZ_EDITOR_BASE}/${r.fayz_project_id}` : undefined),
|
|
591
|
+
}))
|
|
592
|
+
})
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
async listLeadTags(): Promise<Array<{ slug: string; name: string }>> {
|
|
596
|
+
const tenantId = requireTenantId()
|
|
597
|
+
const { table, valueField, labelField } = leadTagsSource
|
|
598
|
+
return memo(`leadtags:${tenantId}`, async () => {
|
|
599
|
+
// The catalog belongs to another plugin — an app without it (or with a
|
|
600
|
+
// different tag store) must not break the landing-page form.
|
|
601
|
+
const { data, error } = await getClient()
|
|
602
|
+
.from(table)
|
|
603
|
+
.select(`${valueField}, ${labelField}`)
|
|
604
|
+
.eq('tenant_id', tenantId)
|
|
605
|
+
.order(labelField)
|
|
606
|
+
if (error) return []
|
|
607
|
+
return (data ?? [])
|
|
608
|
+
.map((r: any) => ({ slug: r[valueField] as string, name: (r[labelField] as string) ?? r[valueField] }))
|
|
609
|
+
.filter((tag: { slug: string }) => Boolean(tag.slug))
|
|
610
|
+
})
|
|
399
611
|
},
|
|
400
612
|
}
|
|
401
613
|
}
|