@fayz-ai/plugin-marketing 0.8.0 → 0.8.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 (41) hide show
  1. package/dist/MarketingContext.d.ts +2 -0
  2. package/dist/MarketingContext.d.ts.map +1 -1
  3. package/dist/MarketingPage.d.ts.map +1 -1
  4. package/dist/data/blogEntities.d.ts +28 -0
  5. package/dist/data/blogEntities.d.ts.map +1 -0
  6. package/dist/index.cjs +509 -43
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +515 -49
  10. package/dist/index.js.map +1 -1
  11. package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
  12. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
  13. package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
  14. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
  15. package/dist/integrations/ranklayer/types.d.ts +16 -0
  16. package/dist/integrations/ranklayer/types.d.ts.map +1 -0
  17. package/dist/migrations/index.d.ts +1 -0
  18. package/dist/migrations/index.d.ts.map +1 -1
  19. package/dist/presets.d.ts +2 -0
  20. package/dist/presets.d.ts.map +1 -1
  21. package/dist/views/blog/BlogAdminView.d.ts +5 -0
  22. package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
  23. package/dist/views/blog/BlogEntityForm.d.ts +11 -0
  24. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
  25. package/dist/views/blog/useEntityCrud.d.ts +29 -0
  26. package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
  27. package/package.json +4 -4
  28. package/src/MarketingContext.tsx +2 -0
  29. package/src/MarketingPage.tsx +22 -1
  30. package/src/data/blogEntities.tsx +91 -0
  31. package/src/index.ts +16 -1
  32. package/src/integrations/ranklayer/RANKLAYER.md +40 -0
  33. package/src/integrations/ranklayer/connectorDef.tsx +98 -0
  34. package/src/integrations/ranklayer/data/supabase.ts +106 -0
  35. package/src/integrations/ranklayer/types.ts +16 -0
  36. package/src/migrations/004_ranklayer.sql +50 -0
  37. package/src/migrations/index.ts +54 -1
  38. package/src/presets.ts +5 -3
  39. package/src/views/blog/BlogAdminView.tsx +99 -0
  40. package/src/views/blog/BlogEntityForm.tsx +92 -0
  41. package/src/views/blog/useEntityCrud.ts +50 -0
@@ -0,0 +1,3 @@
1
+ import type { ConnectorDefinition } from '@fayz-ai/core';
2
+ export declare const ranklayerConnectorDef: ConnectorDefinition;
3
+ //# sourceMappingURL=connectorDef.d.ts.map
@@ -0,0 +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;AAmExD,eAAO,MAAM,qBAAqB,EAAE,mBAuBnC,CAAA"}
@@ -0,0 +1,16 @@
1
+ import type { TestConnectionResult } from '@fayz-ai/core';
2
+ import type { RankLayerIntegration, RankLayerSyncLogEntry } from '../types';
3
+ export declare function createRankLayerProvider(): {
4
+ getIntegration(): Promise<RankLayerIntegration | null>;
5
+ saveIntegration(values: {
6
+ apiKey: string;
7
+ siteDomain?: string;
8
+ }): Promise<RankLayerIntegration>;
9
+ disconnect(): Promise<void>;
10
+ testConnection(values: {
11
+ apiKey: string;
12
+ }): Promise<TestConnectionResult>;
13
+ getSyncLog(integrationId: string): Promise<RankLayerSyncLogEntry[]>;
14
+ };
15
+ export type RankLayerProvider = ReturnType<typeof createRankLayerProvider>;
16
+ //# sourceMappingURL=supabase.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../../../src/integrations/ranklayer/data/supabase.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AACzD,OAAO,KAAK,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAA;AAqC3E,wBAAgB,uBAAuB;sBAEX,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;4BAW9B;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,oBAAoB,CAAC;kBAmBjF,OAAO,CAAC,IAAI,CAAC;2BAQJ;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,oBAAoB,CAAC;8BAO/C,MAAM,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC;EAiB5E;AAED,MAAM,MAAM,iBAAiB,GAAG,UAAU,CAAC,OAAO,uBAAuB,CAAC,CAAA"}
@@ -0,0 +1,16 @@
1
+ export interface RankLayerIntegration {
2
+ id: string;
3
+ apiKey?: string;
4
+ siteDomain?: string;
5
+ active: boolean;
6
+ lastSyncAt?: string;
7
+ }
8
+ export interface RankLayerSyncLogEntry {
9
+ id: string;
10
+ createdAt: string;
11
+ status: 'success' | 'partial' | 'error';
12
+ message?: string;
13
+ fetched: number;
14
+ written: number;
15
+ }
16
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/integrations/ranklayer/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,OAAO,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,OAAO,CAAA;IACvC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB"}
@@ -2,6 +2,7 @@ export declare const MIGRATION_000_PLG_RENAME = "-- 000_plg_rename.sql \u2014 re
2
2
  export declare const MIGRATION_001_CONTENT_PLANNER = "-- Marketing Plugin: Content Planner\n-- Prefix: plg_marketing_\n-- Social accounts + content plans + posts (Notion-style markdown pages).\n\nCREATE TABLE IF NOT EXISTS public.plg_marketing_social_accounts (\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 handle text,\n platform text NOT NULL DEFAULT 'instagram'\n CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'facebook', 'linkedin')),\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_marketing_social_accounts ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_social_accounts_tenant ON public.plg_marketing_social_accounts(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_marketing_content_plans (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n account_id uuid NOT NULL REFERENCES public.plg_marketing_social_accounts(id) ON DELETE CASCADE,\n name text NOT NULL,\n status text NOT NULL DEFAULT 'active'\n CHECK (status IN ('draft', 'active', 'archived')),\n weeks_count integer NOT NULL DEFAULT 8,\n start_date date,\n objective text,\n tone text,\n pillars jsonb NOT NULL DEFAULT '[]'::jsonb,\n formats jsonb NOT NULL DEFAULT '[]'::jsonb,\n weekly_frequency integer NOT NULL DEFAULT 3,\n brief_md text NOT NULL DEFAULT '',\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_marketing_content_plans ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_content_plans_tenant ON public.plg_marketing_content_plans(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_content_plans_account ON public.plg_marketing_content_plans(account_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_marketing_content_posts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n plan_id uuid NOT NULL REFERENCES public.plg_marketing_content_plans(id) ON DELETE CASCADE,\n week_number integer NOT NULL DEFAULT 1,\n position integer NOT NULL DEFAULT 0,\n title text NOT NULL,\n format text NOT NULL DEFAULT 'reel'\n CHECK (format IN ('reel', 'static', 'carousel', 'story')),\n status text NOT NULL DEFAULT 'idea'\n CHECK (status IN ('idea', 'script', 'recording', 'editing', 'scheduled', 'published')),\n scheduled_date date,\n hook text,\n cta text,\n content_md text NOT NULL DEFAULT '',\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_marketing_content_posts ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_tenant ON public.plg_marketing_content_posts(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_plan ON public.plg_marketing_content_posts(plan_id, week_number, position);\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_status ON public.plg_marketing_content_posts(tenant_id, status);\n\n-- RLS policies for plg_marketing_social_accounts\nDROP POLICY IF EXISTS plg_marketing_social_accounts_select ON public.plg_marketing_social_accounts;\nDROP POLICY IF EXISTS plg_marketing_social_accounts_insert ON public.plg_marketing_social_accounts;\nDROP POLICY IF EXISTS plg_marketing_social_accounts_update ON public.plg_marketing_social_accounts;\nDROP POLICY IF EXISTS plg_marketing_social_accounts_delete ON public.plg_marketing_social_accounts;\nCREATE POLICY plg_marketing_social_accounts_select ON public.plg_marketing_social_accounts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_social_accounts_insert ON public.plg_marketing_social_accounts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_social_accounts_update ON public.plg_marketing_social_accounts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_social_accounts_delete ON public.plg_marketing_social_accounts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_social_accounts TO authenticated;\n\n-- RLS policies for plg_marketing_content_plans\nDROP POLICY IF EXISTS plg_marketing_content_plans_select ON public.plg_marketing_content_plans;\nDROP POLICY IF EXISTS plg_marketing_content_plans_insert ON public.plg_marketing_content_plans;\nDROP POLICY IF EXISTS plg_marketing_content_plans_update ON public.plg_marketing_content_plans;\nDROP POLICY IF EXISTS plg_marketing_content_plans_delete ON public.plg_marketing_content_plans;\nCREATE POLICY plg_marketing_content_plans_select ON public.plg_marketing_content_plans FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_content_plans_insert ON public.plg_marketing_content_plans FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_content_plans_update ON public.plg_marketing_content_plans FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_content_plans_delete ON public.plg_marketing_content_plans FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_content_plans TO authenticated;\n\n-- RLS policies for plg_marketing_content_posts\nDROP POLICY IF EXISTS plg_marketing_content_posts_select ON public.plg_marketing_content_posts;\nDROP POLICY IF EXISTS plg_marketing_content_posts_insert ON public.plg_marketing_content_posts;\nDROP POLICY IF EXISTS plg_marketing_content_posts_update ON public.plg_marketing_content_posts;\nDROP POLICY IF EXISTS plg_marketing_content_posts_delete ON public.plg_marketing_content_posts;\nCREATE POLICY plg_marketing_content_posts_select ON public.plg_marketing_content_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_content_posts_insert ON public.plg_marketing_content_posts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_content_posts_update ON public.plg_marketing_content_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_content_posts_delete ON public.plg_marketing_content_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_content_posts TO authenticated;\n";
3
3
  export declare const MIGRATION_002_MULTI_PLATFORM = "-- Marketing Plugin: multi-platform accounts + per-post targets.\n-- An account is a brand/profile that publishes to MANY platforms (Instagram,\n-- YouTube, TikTok, ...). Posts optionally narrow the target platforms; an\n-- empty array means \"inherit the account's platforms\".\n\nALTER TABLE public.plg_marketing_social_accounts\n ADD COLUMN IF NOT EXISTS platforms text[] NOT NULL DEFAULT '{instagram}';\n\n-- Backfill from the legacy single-platform column, then drop it (also drops\n-- its CHECK constraint \u2014 the platform list is UI-driven from here on).\nDO $$\nBEGIN\n IF EXISTS (\n SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = 'plg_marketing_social_accounts' AND column_name = 'platform'\n ) THEN\n UPDATE public.plg_marketing_social_accounts\n SET platforms = ARRAY[platform]\n WHERE platform IS NOT NULL AND platforms = '{instagram}';\n ALTER TABLE public.plg_marketing_social_accounts DROP COLUMN platform;\n END IF;\nEND $$;\n\nALTER TABLE public.plg_marketing_content_posts\n ADD COLUMN IF NOT EXISTS platforms text[] NOT NULL DEFAULT '{}';\n";
4
4
  export declare const MIGRATION_003_RECORDING_OPS = "-- Marketing Plugin: recording-day ops on posts.\n-- checklist: shooting checklist items [{ id, text, done }] \u2014 the on-set\n-- companion for recording day. media_url: uploaded asset (static posts get a\n-- caption + final art instead of a script).\n\nALTER TABLE public.plg_marketing_content_posts\n ADD COLUMN IF NOT EXISTS checklist jsonb NOT NULL DEFAULT '[]';\n\nALTER TABLE public.plg_marketing_content_posts\n ADD COLUMN IF NOT EXISTS media_url text;\n\n-- Public media bucket for content assets (mirrors the avatars-bucket pattern:\n-- public read, authenticated write). Path convention: content/{tenantId}/{postId}-{ts}.{ext}\nINSERT INTO storage.buckets (id, name, public)\n VALUES ('mkt-media', 'mkt-media', true)\n ON CONFLICT (id) DO NOTHING;\n\nDROP POLICY IF EXISTS plg_marketing_media_read ON storage.objects;\nDROP POLICY IF EXISTS plg_marketing_media_insert ON storage.objects;\nDROP POLICY IF EXISTS plg_marketing_media_update ON storage.objects;\nDROP POLICY IF EXISTS plg_marketing_media_delete ON storage.objects;\nCREATE POLICY plg_marketing_media_read ON storage.objects FOR SELECT TO public USING (bucket_id = 'mkt-media');\nCREATE POLICY plg_marketing_media_insert ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'mkt-media');\nCREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'mkt-media');\nCREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');\n";
5
+ export declare const MIGRATION_004_RANKLAYER = "-- Marketing Plugin: RankLayer integration (SEO content publishing).\n-- Control-plane state for the RankLayer connector rendered in Marketing \u2192\n-- Integra\u00E7\u00F5es. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync\n-- (edge function + RankLayer API calls) lands via an external PR \u2014 see\n-- src/integrations/ranklayer/RANKLAYER.md.\n\nCREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n api_key text,\n site_domain text,\n active boolean NOT NULL DEFAULT true,\n last_sync_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;\nCREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant\n ON public.plg_marketing_ranklayer_integrations(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,\n status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),\n message text,\n fetched integer NOT NULL DEFAULT 0,\n written integer NOT NULL DEFAULT 0,\n created_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant\n ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);\n\n-- RLS: authenticated tenant CRUD\nDROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;\nDROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;\nDROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;\nDROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;\nCREATE POLICY plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;\n\nDROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;\nDROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;\nCREATE POLICY plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;\n";
5
6
  export declare const MIGRATIONS: Array<{
6
7
  id: string;
7
8
  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,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAKzD,CAAA"}
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,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAMzD,CAAA"}
package/dist/presets.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface MarketingDomainModules {
7
7
  landingPages: boolean;
8
8
  /** Social content planner — accounts, week board, markdown post pages. */
9
9
  contentPlanner: boolean;
10
+ /** Blog backoffice — posts + categories CMS feeding @fayz-ai/plugin-blog. Off by default. */
11
+ blog: boolean;
10
12
  /** Outbound — reserved, off by default, implemented in a later milestone. */
11
13
  broadcasts: boolean;
12
14
  journeys: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../src/presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AASlE,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAA;AAE3D,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,EAAE,OAAO,CAAA;IAClB,MAAM,EAAE,OAAO,CAAA;IACf,YAAY,EAAE,OAAO,CAAA;IACrB,0EAA0E;IAC1E,cAAc,EAAE,OAAO,CAAA;IACvB,6EAA6E;IAC7E,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,eAAe,CAAA;IAC3B,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,OAAO,EAAE,sBAAsB,CAAA;CAChC;AA4DD,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,eAAe,EAAE,qBAAqB,CAI5E,CAAA;AAED,mEAAmE;AACnE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,eAAe,EAAE,MAAM,EAAE,CAsBjE,CAAA"}
1
+ {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../src/presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AASlE,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAA;AAE3D,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,EAAE,OAAO,CAAA;IAClB,MAAM,EAAE,OAAO,CAAA;IACf,YAAY,EAAE,OAAO,CAAA;IACrB,0EAA0E;IAC1E,cAAc,EAAE,OAAO,CAAA;IACvB,6FAA6F;IAC7F,IAAI,EAAE,OAAO,CAAA;IACb,6EAA6E;IAC7E,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,eAAe,CAAA;IAC3B,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,OAAO,EAAE,sBAAsB,CAAA;CAChC;AA4DD,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,eAAe,EAAE,qBAAqB,CAI5E,CAAA;AAED,mEAAmE;AACnE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,eAAe,EAAE,MAAM,EAAE,CAsBjE,CAAA"}
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ export declare function BlogAdminView({ navigate }: {
3
+ navigate: (view: string) => void;
4
+ }): React.JSX.Element;
5
+ //# sourceMappingURL=BlogAdminView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BlogAdminView.d.ts","sourceRoot":"","sources":["../../../src/views/blog/BlogAdminView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAgF3D,wBAAgB,aAAa,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,qBAkB/E"}
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import type { EntityDef } from '@fayz-ai/core';
3
+ export declare function BlogEntityForm<T extends {
4
+ id: string;
5
+ }>({ entity, editId, parentLabel, onDone, }: {
6
+ entity: EntityDef<T>;
7
+ editId?: string;
8
+ parentLabel: string;
9
+ onDone: () => void;
10
+ }): React.JSX.Element;
11
+ //# sourceMappingURL=BlogEntityForm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BlogEntityForm.d.ts","sourceRoot":"","sources":["../../../src/views/blog/BlogEntityForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAGzB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAU9C,wBAAgB,cAAc,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,EACvD,MAAM,EACN,MAAM,EACN,WAAW,EACX,MAAM,GACP,EAAE;IACD,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,IAAI,CAAA;CACnB,qBAoEA"}
@@ -0,0 +1,29 @@
1
+ import type { EntityDef } from '@fayz-ai/core';
2
+ interface ListQuery {
3
+ search?: string;
4
+ filters?: Record<string, unknown>;
5
+ page?: number;
6
+ pageSize?: number;
7
+ sortBy?: string;
8
+ sortDir?: 'asc' | 'desc';
9
+ }
10
+ /**
11
+ * Minimal store-less CRUD wiring for a plugin that owns its data + routing.
12
+ * Resolves the generic Supabase provider from the EntityDef's `data.table`
13
+ * (mock fallback when Supabase is not configured) and exposes list/get/save/
14
+ * remove. Used by the blog backoffice list + form views — the heavy list/form
15
+ * rendering stays in the shared CrudListView / CrudFormPage.
16
+ */
17
+ export declare function useEntityCrud<T extends {
18
+ id: string;
19
+ }>(entity: EntityDef<T>): {
20
+ items: T[] | null;
21
+ total: number;
22
+ fetch: (query?: ListQuery) => Promise<void>;
23
+ getById: (id: string) => Promise<T | null>;
24
+ create: (data: Record<string, unknown>) => Promise<T>;
25
+ update: (id: string, data: Record<string, unknown>) => Promise<T>;
26
+ remove: (id: string) => Promise<void>;
27
+ };
28
+ export {};
29
+ //# sourceMappingURL=useEntityCrud.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useEntityCrud.d.ts","sourceRoot":"","sources":["../../../src/views/blog/useEntityCrud.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,UAAU,SAAS;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,KAAK,GAAG,MAAM,CAAA;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC;;;oBAKlC,SAAS;kBAcV,MAAM,KAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;mBAK/B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;iBACzB,MAAM,QAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;iBACrC,MAAM;EAGvC"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.8.0",
6
+ "version": "0.8.1",
7
7
  "description": "Fayz SDK — marketing campaigns & broadcasts plugin",
8
8
  "type": "module",
9
9
  "main": "./dist/index.cjs",
@@ -35,9 +35,9 @@
35
35
  "@tanstack/react-table": "^8.20.0",
36
36
  "lucide-react": "^0.400.0",
37
37
  "zustand": "^4.5.0",
38
- "@fayz-ai/ui": "^0.8.0",
39
- "@fayz-ai/core": "^0.8.0",
40
- "@fayz-ai/saas": "^0.8.0",
38
+ "@fayz-ai/core": "^0.8.1",
39
+ "@fayz-ai/ui": "^0.8.1",
40
+ "@fayz-ai/saas": "^0.8.1",
41
41
  "@fayz-ai/db": "^0.8.0"
42
42
  },
43
43
  "devDependencies": {
@@ -15,6 +15,8 @@ export interface MarketingLabels {
15
15
  funnel: string
16
16
  landingPages: string
17
17
  content: string
18
+ blog: string
19
+ blogCategories: string
18
20
  settings: string
19
21
  }
20
22
 
@@ -19,6 +19,9 @@ import type { ContentPlannerUIState } from './views/content/contentStore'
19
19
  import { ContentView } from './views/content/ContentView'
20
20
  import { PostPage } from './views/content/PostPage'
21
21
  import { PlanBriefView } from './views/content/PlanBriefView'
22
+ import { BlogAdminView } from './views/blog/BlogAdminView'
23
+ import { BlogEntityForm } from './views/blog/BlogEntityForm'
24
+ import { buildBlogPostEntity, buildBlogCategoryEntity } from './data/blogEntities'
22
25
 
23
26
  function buildNav(
24
27
  config: ResolvedMarketingConfig,
@@ -51,6 +54,14 @@ function buildNav(
51
54
  onClick: () => navigate('content'),
52
55
  })
53
56
  }
57
+ if (config.modules.blog) {
58
+ items.push({
59
+ id: 'blog', label: config.labels.blog, icon: 'Newspaper',
60
+ active: view === 'blog' || view === 'blog-new' || view.startsWith('blog-post:')
61
+ || view === 'blog-cat-new' || view.startsWith('blog-cat:'),
62
+ onClick: () => navigate('blog'),
63
+ })
64
+ }
54
65
  return items
55
66
  }
56
67
 
@@ -63,8 +74,9 @@ export function MarketingPage({ config, provider, store, contentProvider, conten
63
74
  }) {
64
75
  const t = useTranslation()
65
76
  const { view, direction, navigate } = useModuleNavigation('/marketing', {
66
- overview: 0, channels: 0, campaigns: 0, funnel: 0, 'landing-pages': 0, content: 0,
77
+ overview: 0, channels: 0, campaigns: 0, funnel: 0, 'landing-pages': 0, content: 0, blog: 0,
67
78
  'channel-detail': 1, 'content-post': 1, 'content-brief': 1,
79
+ 'blog-new': 1, 'blog-post': 1, 'blog-cat-new': 1, 'blog-cat': 1,
68
80
  }, 'overview')
69
81
 
70
82
  React.useEffect(() => { void store.getState().load() }, [store])
@@ -72,6 +84,10 @@ export function MarketingPage({ config, provider, store, contentProvider, conten
72
84
  const isOverview = view === 'overview'
73
85
  const nav = buildNav(config, view, navigate)
74
86
 
87
+ // Blog backoffice entities — one shared EntityDef each drives the list + form.
88
+ const blogPostEntity = React.useMemo(() => buildBlogPostEntity(), [])
89
+ const blogCategoryEntity = React.useMemo(() => buildBlogCategoryEntity(), [])
90
+
75
91
  const quickActions = React.useMemo<PluginQuickAction[]>(() => [
76
92
  { id: 'new-campaign', label: t('marketing.campaigns.new'), icon: 'Megaphone', description: config.labels.campaigns, action: () => navigate('campaigns') },
77
93
  ], [t, config.labels.campaigns])
@@ -85,6 +101,11 @@ export function MarketingPage({ config, provider, store, contentProvider, conten
85
101
  { id: 'content', render: () => <ContentView onOpenPost={(id) => navigate(`content-post:${id}`)} /> },
86
102
  { id: 'content-post', render: ({ id }) => <PostPage postId={id!} onBack={() => navigate('content')} /> },
87
103
  { id: 'content-brief', render: () => <PlanBriefView onBack={() => navigate('content')} /> },
104
+ { id: 'blog', render: () => <BlogAdminView navigate={navigate} /> },
105
+ { id: 'blog-new', render: () => <BlogEntityForm entity={blogPostEntity} parentLabel={config.labels.blog} onDone={() => navigate('blog')} /> },
106
+ { id: 'blog-post', render: ({ id }) => <BlogEntityForm entity={blogPostEntity} editId={id!} parentLabel={config.labels.blog} onDone={() => navigate('blog')} /> },
107
+ { id: 'blog-cat-new', render: () => <BlogEntityForm entity={blogCategoryEntity} parentLabel={config.labels.blogCategories} onDone={() => navigate('blog')} /> },
108
+ { id: 'blog-cat', render: ({ id }) => <BlogEntityForm entity={blogCategoryEntity} editId={id!} parentLabel={config.labels.blogCategories} onDone={() => navigate('blog')} /> },
88
109
  { id: 'overview', render: () => <OverviewView /> },
89
110
  ], 'overview')
90
111
 
@@ -0,0 +1,91 @@
1
+ import type { EntityDef, FieldDef } from '@fayz-ai/core'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Blog backoffice entities — the single source of truth that drives BOTH the
5
+ // generic list (CrudListView columns + facets) and the create/edit form
6
+ // (CrudFormPage fields, incl. the markdown body + category relation).
7
+ //
8
+ // Persistence is the generic Supabase provider resolved from `data.table`
9
+ // (plg_blog_posts / plg_blog_categories, owned by @fayz-ai/plugin-blog's
10
+ // migrations). camelCase field keys map to snake_case columns automatically.
11
+ // The website reads the same rows through the anon v_public_blog_posts view.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export const BLOG_POSTS_TABLE = 'plg_blog_posts'
15
+ export const BLOG_CATEGORIES_TABLE = 'plg_blog_categories'
16
+
17
+ export interface BlogPostRow {
18
+ id: string
19
+ title: string
20
+ slug: string
21
+ excerpt: string
22
+ body: string
23
+ categoryId?: string
24
+ coverImage?: string
25
+ authorName?: string
26
+ authorRole?: string
27
+ authorAvatarUrl?: string
28
+ authorBio?: string
29
+ status: 'draft' | 'published'
30
+ readTime?: string
31
+ publishedAt?: string
32
+ }
33
+
34
+ export interface BlogCategoryRow {
35
+ id: string
36
+ name: string
37
+ slug: string
38
+ description?: string
39
+ }
40
+
41
+ export function buildBlogPostEntity(): EntityDef<BlogPostRow> {
42
+ const fields: FieldDef[] = [
43
+ { key: 'title', label: 'Title', type: 'text', required: true, searchable: true, showInTable: true, sortable: true },
44
+ { key: 'slug', label: 'Slug', type: 'text', required: true, searchable: true, showInTable: false, hint: 'URL identifier, e.g. "cannabis-and-oral-health"' },
45
+ { key: 'status', label: 'Status', type: 'select', options: ['draft', 'published'], showInTable: true, defaultValue: 'draft' },
46
+ { key: 'categoryId', label: 'Category', type: 'relation', relation: { table: BLOG_CATEGORIES_TABLE, valueField: 'id', labelField: 'name', tenantScoped: true }, showInTable: false },
47
+ { key: 'excerpt', label: 'Excerpt', type: 'textarea', span: 2, showInTable: false, placeholder: 'Short summary shown on cards' },
48
+ { key: 'body', label: 'Body', type: 'markdown', span: 2, showInTable: false },
49
+ { key: 'coverImage', label: 'Cover image', type: 'image', showInTable: false, placeholder: 'https://…' },
50
+ { key: 'readTime', label: 'Read time', type: 'text', showInTable: false, placeholder: '5 min' },
51
+ { key: 'publishedAt', label: 'Published at', type: 'date', showInTable: true, sortable: true },
52
+ { key: 'authorName', label: 'Author name', type: 'text', showInTable: false },
53
+ { key: 'authorRole', label: 'Author role', type: 'text', showInTable: false, placeholder: 'e.g. Cirurgião-Dentista' },
54
+ { key: 'authorAvatarUrl', label: 'Author avatar', type: 'image', showInTable: false, placeholder: 'https://…' },
55
+ { key: 'authorBio', label: 'Author bio', type: 'textarea', span: 2, showInTable: false },
56
+ ]
57
+ return {
58
+ name: 'Post',
59
+ namePlural: 'Posts',
60
+ icon: 'Newspaper',
61
+ fields,
62
+ displayField: 'title',
63
+ subtitleField: 'slug',
64
+ facets: [{ field: 'status', allLabel: 'All' }],
65
+ data: {
66
+ table: BLOG_POSTS_TABLE,
67
+ tenantScoped: true,
68
+ searchColumns: ['title', 'slug', 'excerpt'],
69
+ },
70
+ }
71
+ }
72
+
73
+ export function buildBlogCategoryEntity(): EntityDef<BlogCategoryRow> {
74
+ const fields: FieldDef[] = [
75
+ { key: 'name', label: 'Name', type: 'text', required: true, searchable: true, showInTable: true, sortable: true },
76
+ { key: 'slug', label: 'Slug', type: 'text', required: true, searchable: true, showInTable: true, hint: 'URL identifier' },
77
+ { key: 'description', label: 'Description', type: 'textarea', span: 2, showInTable: false },
78
+ ]
79
+ return {
80
+ name: 'Category',
81
+ namePlural: 'Categories',
82
+ icon: 'Tags',
83
+ fields,
84
+ displayField: 'name',
85
+ data: {
86
+ table: BLOG_CATEGORIES_TABLE,
87
+ tenantScoped: true,
88
+ searchColumns: ['name', 'slug'],
89
+ },
90
+ }
91
+ }
package/src/index.ts CHANGED
@@ -22,7 +22,8 @@ import { createMockContentPlannerProvider } from './data/contentMock'
22
22
  import { createSupabaseContentPlannerProvider } from './data/contentSupabase'
23
23
  import { createMarketingStore } from './store'
24
24
  import { createContentPlannerStore } from './views/content/contentStore'
25
- import { MIGRATION_000_PLG_RENAME, MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS } from './migrations'
25
+ import { MIGRATION_000_PLG_RENAME, MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS, MIGRATION_004_RANKLAYER } from './migrations'
26
+ import { ranklayerConnectorDef } from './integrations/ranklayer/connectorDef'
26
27
  import { DEFAULT_CURRENCY, type MarketingCurrency } from './format'
27
28
  import { MARKETING_PRESETS, type MarketingDomain, type MarketingDomainModules } from './presets'
28
29
  import type { AcquisitionChannel, ConversionModel } from './types'
@@ -74,6 +75,8 @@ const DEFAULT_LABELS: MarketingLabels = {
74
75
  funnel: 'Funnel',
75
76
  landingPages: 'Landing pages',
76
77
  content: 'Content',
78
+ blog: 'Blog',
79
+ blogCategories: 'Categorias',
77
80
  settings: 'Settings',
78
81
  }
79
82
 
@@ -130,6 +133,8 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
130
133
  subtitle: config.labels.pageSubtitle,
131
134
  generalSettings: React.createElement(SettingsView),
132
135
  registries: marketingRegistries,
136
+ // Enables the "Integrações" tab (renders manifest connectors for this host).
137
+ hostPluginId: 'marketing',
133
138
  routeBase: '/settings/marketing',
134
139
  }))
135
140
  SettingsComponent.displayName = 'MarketingSettings'
@@ -150,6 +155,7 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
150
155
  { id: 'marketing', label: config.labels.pageTitle, group: 'Engage' },
151
156
  ...(config.modules.landingPages ? [{ id: 'marketing.landing-pages', label: config.labels.landingPages, group: 'Engage' }] : []),
152
157
  ...(config.modules.contentPlanner ? [{ id: 'marketing.content', label: config.labels.content, group: 'Engage' }] : []),
158
+ ...(config.modules.blog ? [{ id: 'marketing.blog', label: config.labels.blog, group: 'Engage' }] : []),
153
159
  ],
154
160
  navigation: [
155
161
  {
@@ -204,7 +210,16 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
204
210
  sql: MIGRATION_003_RECORDING_OPS,
205
211
  description: 'Recording-day checklist + media asset URL on posts; content-media storage bucket',
206
212
  },
213
+ {
214
+ id: 'marketing-004-ranklayer',
215
+ version: '1.3.0',
216
+ sql: MIGRATION_004_RANKLAYER,
217
+ description: 'RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)',
218
+ },
207
219
  ],
220
+ // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
221
+ // (control plane only); the real sync lands via an external PR (RANKLAYER.md).
222
+ connectors: [ranklayerConnectorDef],
208
223
  aiTools: [
209
224
  {
210
225
  id: 'marketing.channel-performance',
@@ -0,0 +1,40 @@
1
+ # RankLayer connector
2
+
3
+ Surfaces in **Marketing → settings → Integrações** (rendered by the shared
4
+ `ConnectorsHub`). Follows the connector contract in
5
+ `packages/core/src/integrations/index.ts` and mirrors the PlugBank precedent
6
+ (`beauty-saas/src/plugins/openbanking`).
7
+
8
+ ## Status: SCAFFOLD
9
+
10
+ This ships the control plane only:
11
+
12
+ - `connectorDef.tsx` — the `ConnectorDefinition` (api-key form: API Key + site
13
+ domain, status badge, `ExtraPanel` with a "sync coming soon" note).
14
+ - `data/supabase.ts` — persists the connection row and reads the sync log.
15
+ - `../../migrations/004_ranklayer.sql` — `plg_marketing_ranklayer_integrations`
16
+ + `plg_marketing_ranklayer_sync_log` (tenant RLS).
17
+
18
+ `testConnection` currently accepts any non-empty API key and **no real sync
19
+ runs** — RankLayer has no public API wired yet.
20
+
21
+ ## What the RankLayer PR needs to fill in
22
+
23
+ 1. **Edge function** `functions/ranklayer-sync/index.ts` holding the RankLayer
24
+ API key server-side (move it out of the `api_key` column into a secret if the
25
+ provider requires it), with actions:
26
+ - `test_connection` — validate the key against RankLayer.
27
+ - `sync` — the agreed direction. RankLayer generates/publishes SEO pages and
28
+ articles, so the likely first flow is **inbound**: pull RankLayer-published
29
+ articles and upsert them as `plg_blog_posts` (status `published`) so they
30
+ appear on the site's `/blog`. Confirm direction with the RankLayer team.
31
+ 2. **Real `testConnection` / `sync`** in `data/supabase.ts` — replace the stubs
32
+ to invoke the edge function; write a `plg_marketing_ranklayer_sync_log` row
33
+ per run (the `SyncRun` audit shape).
34
+ 3. **ExtraPanel** — swap the "coming soon" note for a "Sync now" button + the
35
+ real history table (the history rendering is already wired).
36
+
37
+ The connector is already registered on the marketing manifest
38
+ (`connectors: [ranklayerConnectorDef]`) and the Integrações tab is enabled
39
+ (`hostPluginId: 'marketing'` on the settings panel), so no wiring changes are
40
+ needed — only the data-plane implementation above.
@@ -0,0 +1,98 @@
1
+ // RankLayer as a unified ConnectorDefinition, surfaced in Marketing → settings
2
+ // → Integrações (rendered by the shared ConnectorsHub). SCAFFOLD: the hub gives
3
+ // us the API-key form + Test/Save; the ExtraPanel below explains that automated
4
+ // SEO content sync is being prepared. The real sync lands via an external PR —
5
+ // see ./RANKLAYER.md.
6
+ import React, { useEffect, useState } from 'react'
7
+ import { Sparkles, History, CheckCircle2, AlertCircle } from 'lucide-react'
8
+ import type { ConnectorDefinition } from '@fayz-ai/core'
9
+ import { createRankLayerProvider } from './data/supabase'
10
+ import type { RankLayerIntegration, RankLayerSyncLogEntry } from './types'
11
+
12
+ const provider = createRankLayerProvider()
13
+
14
+ function RankLayerExtraPanel() {
15
+ const [integration, setIntegration] = useState<RankLayerIntegration | null>(null)
16
+ const [log, setLog] = useState<RankLayerSyncLogEntry[]>([])
17
+ const [loaded, setLoaded] = useState(false)
18
+
19
+ useEffect(() => {
20
+ let cancelled = false
21
+ ;(async () => {
22
+ try {
23
+ const found = await provider.getIntegration()
24
+ if (cancelled) return
25
+ setIntegration(found)
26
+ if (found) setLog(await provider.getSyncLog(found.id))
27
+ } catch {
28
+ /* not connected / no client — nothing to show */
29
+ } finally {
30
+ if (!cancelled) setLoaded(true)
31
+ }
32
+ })()
33
+ return () => { cancelled = true }
34
+ }, [])
35
+
36
+ if (!loaded || !integration) return null // nothing until the connection is saved
37
+
38
+ return (
39
+ <div className="space-y-4 border-t pt-4">
40
+ <div className="flex items-start gap-3 rounded-md border border-dashed p-3">
41
+ <Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
42
+ <div className="space-y-1">
43
+ <h4 className="text-sm font-semibold">Sincronização de conteúdo — em breve</h4>
44
+ <p className="text-xs text-muted-foreground">
45
+ A publicação automática de páginas e artigos de SEO com a RankLayer está em preparação.
46
+ {integration.siteDomain ? ` Domínio configurado: ${integration.siteDomain}.` : ''} Assim que a
47
+ integração estiver ativa, o histórico de sincronizações aparece aqui.
48
+ </p>
49
+ </div>
50
+ </div>
51
+
52
+ {log.length > 0 && (
53
+ <div className="space-y-2">
54
+ <div className="flex items-center gap-2">
55
+ <History className="h-4 w-4 text-muted-foreground" />
56
+ <h4 className="text-sm font-semibold">Histórico</h4>
57
+ </div>
58
+ <div className="divide-y rounded-md border text-sm">
59
+ {log.map((r) => (
60
+ <div key={r.id} className="flex items-center gap-3 px-3 py-2">
61
+ <span className="text-xs text-muted-foreground">{new Date(r.createdAt).toLocaleString('pt-BR')}</span>
62
+ <span className="ml-auto text-xs">{r.written}/{r.fetched} publicadas</span>
63
+ {r.status === 'success'
64
+ ? <CheckCircle2 className="h-3.5 w-3.5 text-success" />
65
+ : <AlertCircle className="h-3.5 w-3.5 text-warning" />}
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </div>
70
+ )}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ export const ranklayerConnectorDef: ConnectorDefinition = {
76
+ id: 'ranklayer',
77
+ hostPluginId: 'marketing',
78
+ name: 'RankLayer',
79
+ description: 'Publicação de conteúdo e páginas de SEO por serviço/bairro para ser encontrado no Google e em assistentes de IA.',
80
+ icon: 'Search',
81
+ authKind: 'api-key',
82
+ fields: [
83
+ { key: 'apiKey', label: 'API Key', type: 'password', placeholder: '••••••••' },
84
+ { key: 'siteDomain', label: 'Domínio do site', type: 'text', placeholder: 'exemplo.com.br' },
85
+ ],
86
+ async getStatus() {
87
+ try {
88
+ const found = await provider.getIntegration()
89
+ return { connected: !!found?.apiKey, detail: found?.siteDomain }
90
+ } catch {
91
+ return { connected: false }
92
+ }
93
+ },
94
+ testConnection: (values) => provider.testConnection({ apiKey: values.apiKey }),
95
+ saveConnection: async (values) => { await provider.saveIntegration({ apiKey: values.apiKey, siteDomain: values.siteDomain }) },
96
+ disconnect: () => provider.disconnect(),
97
+ ExtraPanel: RankLayerExtraPanel,
98
+ }