@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,106 @@
1
+ import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
+ import type { TestConnectionResult } from '@fayz-ai/core'
3
+ import type { RankLayerIntegration, RankLayerSyncLogEntry } from '../types'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // RankLayer connector — control-plane client (SCAFFOLD).
7
+ //
8
+ // Reads/writes the connection row (plg_marketing_ranklayer_integrations) and
9
+ // reads the sync log. `testConnection` and `sync` are stubbed: RankLayer has no
10
+ // public API wired yet, so the real work (an edge function calling RankLayer)
11
+ // lands via an external PR — see ../RANKLAYER.md. Everything here is safe to
12
+ // ship: it only persists the tenant's API key + domain and surfaces status.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const INTEGRATIONS = 'plg_marketing_ranklayer_integrations'
16
+ const SYNC_LOG = 'plg_marketing_ranklayer_sync_log'
17
+
18
+ function getClient() {
19
+ const supabase = getSupabaseClientOptional() as { from: (t: string) => any } | null
20
+ if (!supabase) throw new Error('[ranklayer] Supabase not initialized')
21
+ return supabase
22
+ }
23
+
24
+ function requireTenantId(): string {
25
+ const tenantId = getActiveTenantId()
26
+ if (!tenantId) throw new Error('[ranklayer] No active tenant')
27
+ return tenantId
28
+ }
29
+
30
+ function toIntegration(row: Record<string, any>): RankLayerIntegration {
31
+ return {
32
+ id: row.id,
33
+ apiKey: row.api_key ?? undefined,
34
+ siteDomain: row.site_domain ?? undefined,
35
+ active: row.active ?? true,
36
+ lastSyncAt: row.last_sync_at ?? undefined,
37
+ }
38
+ }
39
+
40
+ export function createRankLayerProvider() {
41
+ return {
42
+ async getIntegration(): Promise<RankLayerIntegration | null> {
43
+ const tenantId = getActiveTenantId()
44
+ if (!tenantId) return null
45
+ const { data } = await getClient()
46
+ .from(INTEGRATIONS)
47
+ .select('*')
48
+ .eq('tenant_id', tenantId)
49
+ .maybeSingle()
50
+ return data ? toIntegration(data) : null
51
+ },
52
+
53
+ async saveIntegration(values: { apiKey: string; siteDomain?: string }): Promise<RankLayerIntegration> {
54
+ const tenantId = requireTenantId()
55
+ const payload = {
56
+ tenant_id: tenantId,
57
+ api_key: values.apiKey,
58
+ site_domain: values.siteDomain ?? null,
59
+ active: true,
60
+ updated_at: new Date().toISOString(),
61
+ }
62
+ // Upsert on the unique tenant_id.
63
+ const { data, error } = await getClient()
64
+ .from(INTEGRATIONS)
65
+ .upsert(payload, { onConflict: 'tenant_id' })
66
+ .select('*')
67
+ .single()
68
+ if (error) throw error
69
+ return toIntegration(data)
70
+ },
71
+
72
+ async disconnect(): Promise<void> {
73
+ const tenantId = getActiveTenantId()
74
+ if (!tenantId) return
75
+ await getClient().from(INTEGRATIONS).delete().eq('tenant_id', tenantId)
76
+ },
77
+
78
+ // SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
79
+ // flow can be exercised. Replace with a real credential check in the PR.
80
+ async testConnection(values: { apiKey: string }): Promise<TestConnectionResult> {
81
+ if (!values.apiKey || values.apiKey.trim().length < 8) {
82
+ return { ok: false, message: 'Informe uma API Key válida da RankLayer.' }
83
+ }
84
+ return { ok: true, message: 'Chave aceita (validação real pendente — integração em preparação).' }
85
+ },
86
+
87
+ async getSyncLog(integrationId: string): Promise<RankLayerSyncLogEntry[]> {
88
+ const { data } = await getClient()
89
+ .from(SYNC_LOG)
90
+ .select('*')
91
+ .eq('integration_id', integrationId)
92
+ .order('created_at', { ascending: false })
93
+ .limit(20)
94
+ return (data ?? []).map((r: Record<string, any>) => ({
95
+ id: r.id,
96
+ createdAt: r.created_at,
97
+ status: r.status,
98
+ message: r.message ?? undefined,
99
+ fetched: r.fetched ?? 0,
100
+ written: r.written ?? 0,
101
+ }))
102
+ },
103
+ }
104
+ }
105
+
106
+ export type RankLayerProvider = ReturnType<typeof createRankLayerProvider>
@@ -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
+
9
+ export interface RankLayerSyncLogEntry {
10
+ id: string
11
+ createdAt: string
12
+ status: 'success' | 'partial' | 'error'
13
+ message?: string
14
+ fetched: number
15
+ written: number
16
+ }
@@ -0,0 +1,50 @@
1
+ -- Marketing Plugin: RankLayer integration (SEO content publishing).
2
+ -- Control-plane state for the RankLayer connector rendered in Marketing →
3
+ -- Integrações. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
4
+ -- (edge function + RankLayer API calls) lands via an external PR — see
5
+ -- src/integrations/ranklayer/RANKLAYER.md.
6
+
7
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
8
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
9
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
10
+ api_key text,
11
+ site_domain text,
12
+ active boolean NOT NULL DEFAULT true,
13
+ last_sync_at timestamptz,
14
+ created_at timestamptz NOT NULL DEFAULT now(),
15
+ updated_at timestamptz NOT NULL DEFAULT now()
16
+ );
17
+ ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
18
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
19
+ ON public.plg_marketing_ranklayer_integrations(tenant_id);
20
+
21
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
22
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
23
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
24
+ integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
25
+ status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
26
+ message text,
27
+ fetched integer NOT NULL DEFAULT 0,
28
+ written integer NOT NULL DEFAULT 0,
29
+ created_at timestamptz NOT NULL DEFAULT now()
30
+ );
31
+ ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
32
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
33
+ ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
34
+
35
+ -- RLS: authenticated tenant CRUD
36
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
37
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
38
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
39
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
40
+ CREATE 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()));
41
+ CREATE 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()));
42
+ CREATE 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()));
43
+ CREATE 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()));
44
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
45
+
46
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
47
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
48
+ CREATE 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()));
49
+ CREATE 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()));
50
+ GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql — regenerate with scripts/embed-migrations.mjs
1
+ // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql — regenerate with scripts/embed-migrations.mjs
2
2
  // SQL files are the source of truth; this inline copy lets the manifest declare
3
3
  // migrations as data. Do not edit by hand — run the embed script instead.
4
4
 
@@ -169,9 +169,62 @@ CREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authen
169
169
  CREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');
170
170
  `
171
171
 
172
+ export const MIGRATION_004_RANKLAYER = `-- Marketing Plugin: RankLayer integration (SEO content publishing).
173
+ -- Control-plane state for the RankLayer connector rendered in Marketing →
174
+ -- Integrações. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
175
+ -- (edge function + RankLayer API calls) lands via an external PR — see
176
+ -- src/integrations/ranklayer/RANKLAYER.md.
177
+
178
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
179
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
180
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
181
+ api_key text,
182
+ site_domain text,
183
+ active boolean NOT NULL DEFAULT true,
184
+ last_sync_at timestamptz,
185
+ created_at timestamptz NOT NULL DEFAULT now(),
186
+ updated_at timestamptz NOT NULL DEFAULT now()
187
+ );
188
+ ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
189
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
190
+ ON public.plg_marketing_ranklayer_integrations(tenant_id);
191
+
192
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
193
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
194
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
195
+ integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
196
+ status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
197
+ message text,
198
+ fetched integer NOT NULL DEFAULT 0,
199
+ written integer NOT NULL DEFAULT 0,
200
+ created_at timestamptz NOT NULL DEFAULT now()
201
+ );
202
+ ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
203
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
204
+ ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
205
+
206
+ -- RLS: authenticated tenant CRUD
207
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
208
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
209
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
210
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
211
+ CREATE 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()));
212
+ CREATE 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()));
213
+ CREATE 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()));
214
+ CREATE 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()));
215
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
216
+
217
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
218
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
219
+ CREATE 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()));
220
+ CREATE 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()));
221
+ GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
222
+ `
223
+
172
224
  export const MIGRATIONS: Array<{ id: string; sql: string }> = [
173
225
  { id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
174
226
  { id: "001_content_planner", sql: MIGRATION_001_CONTENT_PLANNER },
175
227
  { id: "002_multi_platform", sql: MIGRATION_002_MULTI_PLATFORM },
176
228
  { id: "003_recording_ops", sql: MIGRATION_003_RECORDING_OPS },
229
+ { id: "004_ranklayer", sql: MIGRATION_004_RANKLAYER },
177
230
  ]
package/src/presets.ts CHANGED
@@ -16,6 +16,8 @@ export interface MarketingDomainModules {
16
16
  landingPages: boolean
17
17
  /** Social content planner — accounts, week board, markdown post pages. */
18
18
  contentPlanner: boolean
19
+ /** Blog backoffice — posts + categories CMS feeding @fayz-ai/plugin-blog. Off by default. */
20
+ blog: boolean
19
21
  /** Outbound — reserved, off by default, implemented in a later milestone. */
20
22
  broadcasts: boolean
21
23
  journeys: boolean
@@ -44,7 +46,7 @@ const AGENCY: MarketingDomainPreset = {
44
46
  { id: 'referral', label: 'Referral', icon: 'Users', kind: 'referral' },
45
47
  { id: 'direct', label: 'Direct', icon: 'MousePointerClick', kind: 'direct' },
46
48
  ],
47
- modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, broadcasts: false, journeys: false },
49
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, blog: false, broadcasts: false, journeys: false },
48
50
  }
49
51
 
50
52
  const BEAUTY: MarketingDomainPreset = {
@@ -63,7 +65,7 @@ const BEAUTY: MarketingDomainPreset = {
63
65
  { id: 'whatsapp', label: 'WhatsApp', icon: 'MessageCircle', kind: 'social' },
64
66
  { id: 'walkin', label: 'Walk-in', icon: 'DoorOpen', kind: 'direct' },
65
67
  ],
66
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, broadcasts: false, journeys: false },
68
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, blog: false, broadcasts: false, journeys: false },
67
69
  }
68
70
 
69
71
  const RESTO: MarketingDomainPreset = {
@@ -82,7 +84,7 @@ const RESTO: MarketingDomainPreset = {
82
84
  { id: 'instagram', label: 'Instagram', icon: 'Instagram', kind: 'social' },
83
85
  { id: 'walkin', label: 'Walk-in', icon: 'DoorOpen', kind: 'direct' },
84
86
  ],
85
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, broadcasts: false, journeys: false },
87
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, blog: false, broadcasts: false, journeys: false },
86
88
  }
87
89
 
88
90
  export const MARKETING_PRESETS: Record<MarketingDomain, MarketingDomainPreset> = {
@@ -0,0 +1,99 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { CrudListView } from '@fayz-ai/saas'
3
+ import { SegmentedControl } from '@fayz-ai/ui'
4
+ import { useMarketingConfig } from '../../MarketingContext'
5
+ import { buildBlogPostEntity, buildBlogCategoryEntity } from '../../data/blogEntities'
6
+ import { useEntityCrud } from './useEntityCrud'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Blog backoffice — an in-page marketing tab with two lenses (Posts /
10
+ // Categories), each a generic CrudListView backed by the blog tables. Follows
11
+ // the content-planner pattern: this plugin owns the data + routing; create/edit
12
+ // deep-link through the marketing module nav (navigate('blog-post:id') etc.).
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function PostsList({ navigate }: { navigate: (view: string) => void }) {
16
+ const entity = useMemo(() => buildBlogPostEntity(), [])
17
+ const { items, total, fetch } = useEntityCrud(entity)
18
+ const [search, setSearch] = useState('')
19
+ const [statusFilter, setStatusFilter] = useState<string | undefined>()
20
+ const [loadedOnce, setLoadedOnce] = useState(false)
21
+
22
+ useEffect(() => {
23
+ let active = true
24
+ Promise.resolve(
25
+ fetch({ search: search || undefined, filters: statusFilter ? { status: statusFilter } : undefined }),
26
+ ).finally(() => { if (active) setLoadedOnce(true) })
27
+ return () => { active = false }
28
+ }, [search, statusFilter, fetch])
29
+
30
+ const facets = useMemo(() => [{
31
+ field: 'status',
32
+ allLabel: 'All',
33
+ options: [{ value: 'draft', label: 'Draft' }, { value: 'published', label: 'Published' }],
34
+ }], [])
35
+
36
+ return (
37
+ <CrudListView
38
+ entityDef={entity}
39
+ items={loadedOnce ? items : null}
40
+ total={total}
41
+ search={search}
42
+ onSearchChange={setSearch}
43
+ searchPlaceholder="Search posts…"
44
+ facets={facets}
45
+ activeFilters={{ status: statusFilter }}
46
+ onFacetChange={(_field, value) => setStatusFilter(value)}
47
+ onNew={() => navigate('blog-new')}
48
+ addLabel="New post"
49
+ onRowClick={(row) => navigate(`blog-post:${(row as { id: string }).id}`)}
50
+ />
51
+ )
52
+ }
53
+
54
+ function CategoriesList({ navigate }: { navigate: (view: string) => void }) {
55
+ const entity = useMemo(() => buildBlogCategoryEntity(), [])
56
+ const { items, total, fetch } = useEntityCrud(entity)
57
+ const [search, setSearch] = useState('')
58
+ const [loadedOnce, setLoadedOnce] = useState(false)
59
+
60
+ useEffect(() => {
61
+ let active = true
62
+ Promise.resolve(fetch({ search: search || undefined })).finally(() => { if (active) setLoadedOnce(true) })
63
+ return () => { active = false }
64
+ }, [search, fetch])
65
+
66
+ return (
67
+ <CrudListView
68
+ entityDef={entity}
69
+ items={loadedOnce ? items : null}
70
+ total={total}
71
+ search={search}
72
+ onSearchChange={setSearch}
73
+ searchPlaceholder="Search categories…"
74
+ onNew={() => navigate('blog-cat-new')}
75
+ addLabel="New category"
76
+ onRowClick={(row) => navigate(`blog-cat:${(row as { id: string }).id}`)}
77
+ />
78
+ )
79
+ }
80
+
81
+ export function BlogAdminView({ navigate }: { navigate: (view: string) => void }) {
82
+ const { labels } = useMarketingConfig()
83
+ const [lens, setLens] = useState<'posts' | 'categories'>('posts')
84
+
85
+ return (
86
+ <div className="space-y-4">
87
+ <SegmentedControl
88
+ aria-label="Blog section"
89
+ options={[
90
+ { value: 'posts', label: labels.blog },
91
+ { value: 'categories', label: labels.blogCategories },
92
+ ]}
93
+ value={lens}
94
+ onChange={(v) => setLens(v as 'posts' | 'categories')}
95
+ />
96
+ {lens === 'posts' ? <PostsList navigate={navigate} /> : <CategoriesList navigate={navigate} />}
97
+ </div>
98
+ )
99
+ }
@@ -0,0 +1,92 @@
1
+ import React from 'react'
2
+ import { SubpageHeader, Button, toast } from '@fayz-ai/ui'
3
+ import { CrudFormPage } from '@fayz-ai/saas'
4
+ import type { EntityDef } from '@fayz-ai/core'
5
+ import { useEntityCrud } from './useEntityCrud'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Generic create/edit for a blog entity (post or category), rendered through
9
+ // the shared CrudFormPage (declarative EntityDef → fields, incl. the markdown
10
+ // body + category relation). Persistence flows through the generic Supabase
11
+ // provider; this only wires load / save / delete + the back link.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function BlogEntityForm<T extends { id: string }>({
15
+ entity,
16
+ editId,
17
+ parentLabel,
18
+ onDone,
19
+ }: {
20
+ entity: EntityDef<T>
21
+ editId?: string
22
+ parentLabel: string
23
+ onDone: () => void
24
+ }) {
25
+ const { getById, create, update, remove } = useEntityCrud(entity)
26
+ const isEdit = !!editId
27
+ const namePlural = entity.namePlural ?? entity.name + 's'
28
+ const [initialData, setInitialData] = React.useState<Record<string, any> | null>(isEdit ? null : {})
29
+
30
+ React.useEffect(() => {
31
+ if (!editId) return
32
+ let cancelled = false
33
+ ;(async () => {
34
+ const item = await getById(editId)
35
+ if (!cancelled) setInitialData((item as Record<string, any>) ?? {})
36
+ })()
37
+ return () => { cancelled = true }
38
+ }, [editId, getById])
39
+
40
+ async function save(values: Record<string, any>) {
41
+ try {
42
+ if (isEdit) await update(editId!, values)
43
+ else await create(values)
44
+ toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`)
45
+ onDone()
46
+ } catch (err) {
47
+ toast.error(`Failed to save ${entity.name.toLowerCase()}`)
48
+ throw err
49
+ }
50
+ }
51
+
52
+ async function del() {
53
+ if (!editId) return
54
+ try {
55
+ await remove(editId)
56
+ toast.success(`${entity.name} deleted`)
57
+ onDone()
58
+ } catch {
59
+ toast.error(`Failed to delete ${entity.name.toLowerCase()}`)
60
+ }
61
+ }
62
+
63
+ const title = isEdit
64
+ ? (initialData?.[entity.displayField ?? 'name'] ?? entity.name)
65
+ : `New ${entity.name.toLowerCase()}`
66
+
67
+ return (
68
+ <div className="space-y-4">
69
+ <SubpageHeader
70
+ title={String(title)}
71
+ onBack={onDone}
72
+ parentLabel={parentLabel}
73
+ actions={isEdit ? (
74
+ <Button variant="ghost" className="text-destructive hover:text-destructive" onClick={del}>
75
+ Delete
76
+ </Button>
77
+ ) : undefined}
78
+ />
79
+ {initialData !== null && (
80
+ <CrudFormPage
81
+ entityDef={entity}
82
+ mode={isEdit ? 'edit' : 'create'}
83
+ initialData={initialData}
84
+ namePlural={namePlural}
85
+ onCancel={onDone}
86
+ onSubmit={save}
87
+ hideBreadcrumb
88
+ />
89
+ )}
90
+ </div>
91
+ )
92
+ }
@@ -0,0 +1,50 @@
1
+ import { useMemo, useState, useCallback } from 'react'
2
+ import { resolveDataProvider } from '@fayz-ai/core'
3
+ import type { EntityDef } from '@fayz-ai/core'
4
+
5
+ interface ListQuery {
6
+ search?: string
7
+ filters?: Record<string, unknown>
8
+ page?: number
9
+ pageSize?: number
10
+ sortBy?: string
11
+ sortDir?: 'asc' | 'desc'
12
+ }
13
+
14
+ /**
15
+ * Minimal store-less CRUD wiring for a plugin that owns its data + routing.
16
+ * Resolves the generic Supabase provider from the EntityDef's `data.table`
17
+ * (mock fallback when Supabase is not configured) and exposes list/get/save/
18
+ * remove. Used by the blog backoffice list + form views — the heavy list/form
19
+ * rendering stays in the shared CrudListView / CrudFormPage.
20
+ */
21
+ export function useEntityCrud<T extends { id: string }>(entity: EntityDef<T>) {
22
+ const provider = useMemo(() => resolveDataProvider<T>(entity), [entity])
23
+ const [items, setItems] = useState<T[] | null>(null)
24
+ const [total, setTotal] = useState(0)
25
+
26
+ const fetch = useCallback(async (query: ListQuery = {}) => {
27
+ try {
28
+ const res = await provider.list(query as any)
29
+ setItems(res.data)
30
+ setTotal(res.total)
31
+ } catch (err) {
32
+ // Table not provisioned yet (migration not applied to this pool) or a
33
+ // transient error — surface an empty list instead of a perpetual skeleton.
34
+ console.warn('[blog] list failed; showing empty. Apply the plugin-blog migration to this pool.', err)
35
+ setItems([])
36
+ setTotal(0)
37
+ }
38
+ }, [provider])
39
+
40
+ const getById = useCallback(async (id: string): Promise<T | null> => {
41
+ const res = await provider.list({ filters: { id }, page: 1, pageSize: 1 } as any)
42
+ return res.data[0] ?? null
43
+ }, [provider])
44
+
45
+ const create = useCallback((data: Record<string, unknown>) => provider.create(data as any), [provider])
46
+ const update = useCallback((id: string, data: Record<string, unknown>) => provider.update(id, data as any), [provider])
47
+ const remove = useCallback((id: string) => provider.remove(id), [provider])
48
+
49
+ return { items, total, fetch, getById, create, update, remove }
50
+ }