@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.
- package/dist/MarketingContext.d.ts +2 -0
- package/dist/MarketingContext.d.ts.map +1 -1
- package/dist/MarketingPage.d.ts.map +1 -1
- package/dist/data/blogEntities.d.ts +28 -0
- package/dist/data/blogEntities.d.ts.map +1 -0
- package/dist/index.cjs +509 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +515 -49
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
- package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
- package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
- package/dist/integrations/ranklayer/types.d.ts +16 -0
- package/dist/integrations/ranklayer/types.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/presets.d.ts +2 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/views/blog/BlogAdminView.d.ts +5 -0
- package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
- package/dist/views/blog/BlogEntityForm.d.ts +11 -0
- package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
- package/dist/views/blog/useEntityCrud.d.ts +29 -0
- package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/MarketingContext.tsx +2 -0
- package/src/MarketingPage.tsx +22 -1
- package/src/data/blogEntities.tsx +91 -0
- package/src/index.ts +16 -1
- package/src/integrations/ranklayer/RANKLAYER.md +40 -0
- package/src/integrations/ranklayer/connectorDef.tsx +98 -0
- package/src/integrations/ranklayer/data/supabase.ts +106 -0
- package/src/integrations/ranklayer/types.ts +16 -0
- package/src/migrations/004_ranklayer.sql +50 -0
- package/src/migrations/index.ts +54 -1
- package/src/presets.ts +5 -3
- package/src/views/blog/BlogAdminView.tsx +99 -0
- package/src/views/blog/BlogEntityForm.tsx +92 -0
- 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;
|
package/src/migrations/index.ts
CHANGED
|
@@ -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
|
+
}
|