@fayz-ai/plugin-marketing 0.2.1 → 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/README.md +2 -0
- 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/data/contentSupabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +6 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/index.cjs +630 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +636 -133
- 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 +9 -3
- 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/schema/index.d.ts +44 -44
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema.cjs +9 -9
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.js +9 -9
- package/dist/schema.js.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 +8 -5
- package/src/MarketingContext.tsx +2 -0
- package/src/MarketingPage.tsx +22 -1
- package/src/data/blogEntities.tsx +91 -0
- package/src/data/contentSupabase.ts +18 -17
- package/src/data/tables.ts +7 -0
- package/src/index.ts +27 -3
- 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/000_plg_rename.sql +15 -0
- package/src/migrations/001_content_planner.sql +48 -48
- package/src/migrations/002_multi_platform.sql +5 -5
- package/src/migrations/003_recording_ops.sql +10 -10
- package/src/migrations/004_ranklayer.sql +50 -0
- package/src/migrations/index.ts +152 -74
- package/src/presets.ts +5 -3
- package/src/schema/index.ts +9 -9
- package/src/views/blog/BlogAdminView.tsx +99 -0
- package/src/views/blog/BlogEntityForm.tsx +92 -0
- package/src/views/blog/useEntityCrud.ts +50 -0
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import React8, { useEffect } from 'react';
|
|
2
|
-
import { getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, useTranslation } from '@fayz-ai/core';
|
|
3
|
-
import { createPluginContext, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, useOrganizationStore } from '@fayz-ai/saas';
|
|
4
|
-
import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor,
|
|
5
|
-
import {
|
|
6
|
-
import { ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, MousePointerClick, Layers, NotebookPen, ArrowUpRight, ArrowDownRight, Settings2, Columns3, CalendarDays, List, ChevronLeft, Loader2, ImagePlus, X,
|
|
1
|
+
import React8, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
+
import { getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, useTranslation, resolveDataProvider } from '@fayz-ai/core';
|
|
3
|
+
import { createPluginContext, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, useOrganizationStore, CrudFormPage, CrudListView } from '@fayz-ai/saas';
|
|
4
|
+
import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, SegmentedControl, DashboardCanvas, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, SelectSeparator, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, Checkbox, toast, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
|
|
5
|
+
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
6
|
+
import { Sparkles, History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, MousePointerClick, Layers, NotebookPen, ArrowUpRight, ArrowDownRight, Settings2, Columns3, CalendarDays, List, ChevronLeft, Loader2, ImagePlus, X, Bike, Utensils, DoorOpen, MessageCircle, Instagram, Mail, Globe, Megaphone, Search, Link2, AtSign, Pin, Twitter, Linkedin, Facebook, Youtube, Music2, Repeat2, Heart, BarChart2, Share2, ThumbsUp, Send, MoreHorizontal, Bookmark, GripVertical, Image, Trophy } from 'lucide-react';
|
|
7
7
|
import { createStore } from 'zustand/vanilla';
|
|
8
8
|
|
|
9
9
|
// src/index.ts
|
|
@@ -213,11 +213,11 @@ function ChannelDetailView({ channelId, onBack }) {
|
|
|
213
213
|
const range = useMarketingStore((s) => s.range);
|
|
214
214
|
const channels = useMarketingStore((s) => s.channels);
|
|
215
215
|
const lookup = useChannelLookup();
|
|
216
|
-
const
|
|
216
|
+
const provider2 = useMarketingProvider();
|
|
217
217
|
const [campaigns, setCampaigns] = React8.useState([]);
|
|
218
218
|
React8.useEffect(() => {
|
|
219
|
-
void
|
|
220
|
-
}, [
|
|
219
|
+
void provider2.listCampaigns({ range, channelId }).then(setCampaigns);
|
|
220
|
+
}, [provider2, range, channelId]);
|
|
221
221
|
const perf = channels.find((c) => c.channelId === channelId);
|
|
222
222
|
const channel = lookup.get(channelId);
|
|
223
223
|
const columns = React8.useMemo(() => [
|
|
@@ -1962,7 +1962,7 @@ function toDraft2(post) {
|
|
|
1962
1962
|
}
|
|
1963
1963
|
function PostPage({ postId, onBack }) {
|
|
1964
1964
|
const t = useTranslation();
|
|
1965
|
-
const
|
|
1965
|
+
const provider2 = useContentPlannerProvider();
|
|
1966
1966
|
const storePost = useContentPlannerStore((s) => s.posts.find((p) => p.id === postId));
|
|
1967
1967
|
const savePost = useContentPlannerStore((s) => s.savePost);
|
|
1968
1968
|
const deletePost = useContentPlannerStore((s) => s.deletePost);
|
|
@@ -1979,13 +1979,13 @@ function PostPage({ postId, onBack }) {
|
|
|
1979
1979
|
setDraft(toDraft2(storePost));
|
|
1980
1980
|
return;
|
|
1981
1981
|
}
|
|
1982
|
-
void
|
|
1982
|
+
void provider2.getPost(postId).then((fetched) => {
|
|
1983
1983
|
if (fetched) {
|
|
1984
1984
|
setPost(fetched);
|
|
1985
1985
|
setDraft(toDraft2(fetched));
|
|
1986
1986
|
}
|
|
1987
1987
|
});
|
|
1988
|
-
}, [postId, storePost, post?.id,
|
|
1988
|
+
}, [postId, storePost, post?.id, provider2]);
|
|
1989
1989
|
if (!post || !draft) {
|
|
1990
1990
|
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-3xl space-y-5", children: [
|
|
1991
1991
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
@@ -2172,6 +2172,239 @@ function PostPage({ postId, onBack }) {
|
|
|
2172
2172
|
post.id
|
|
2173
2173
|
);
|
|
2174
2174
|
}
|
|
2175
|
+
|
|
2176
|
+
// src/data/blogEntities.tsx
|
|
2177
|
+
var BLOG_POSTS_TABLE = "plg_blog_posts";
|
|
2178
|
+
var BLOG_CATEGORIES_TABLE = "plg_blog_categories";
|
|
2179
|
+
function buildBlogPostEntity() {
|
|
2180
|
+
const fields = [
|
|
2181
|
+
{ key: "title", label: "Title", type: "text", required: true, searchable: true, showInTable: true, sortable: true },
|
|
2182
|
+
{ key: "slug", label: "Slug", type: "text", required: true, searchable: true, showInTable: false, hint: 'URL identifier, e.g. "cannabis-and-oral-health"' },
|
|
2183
|
+
{ key: "status", label: "Status", type: "select", options: ["draft", "published"], showInTable: true, defaultValue: "draft" },
|
|
2184
|
+
{ key: "categoryId", label: "Category", type: "relation", relation: { table: BLOG_CATEGORIES_TABLE, valueField: "id", labelField: "name", tenantScoped: true }, showInTable: false },
|
|
2185
|
+
{ key: "excerpt", label: "Excerpt", type: "textarea", span: 2, showInTable: false, placeholder: "Short summary shown on cards" },
|
|
2186
|
+
{ key: "body", label: "Body", type: "markdown", span: 2, showInTable: false },
|
|
2187
|
+
{ key: "coverImage", label: "Cover image", type: "image", showInTable: false, placeholder: "https://\u2026" },
|
|
2188
|
+
{ key: "readTime", label: "Read time", type: "text", showInTable: false, placeholder: "5 min" },
|
|
2189
|
+
{ key: "publishedAt", label: "Published at", type: "date", showInTable: true, sortable: true },
|
|
2190
|
+
{ key: "authorName", label: "Author name", type: "text", showInTable: false },
|
|
2191
|
+
{ key: "authorRole", label: "Author role", type: "text", showInTable: false, placeholder: "e.g. Cirurgi\xE3o-Dentista" },
|
|
2192
|
+
{ key: "authorAvatarUrl", label: "Author avatar", type: "image", showInTable: false, placeholder: "https://\u2026" },
|
|
2193
|
+
{ key: "authorBio", label: "Author bio", type: "textarea", span: 2, showInTable: false }
|
|
2194
|
+
];
|
|
2195
|
+
return {
|
|
2196
|
+
name: "Post",
|
|
2197
|
+
namePlural: "Posts",
|
|
2198
|
+
icon: "Newspaper",
|
|
2199
|
+
fields,
|
|
2200
|
+
displayField: "title",
|
|
2201
|
+
subtitleField: "slug",
|
|
2202
|
+
facets: [{ field: "status", allLabel: "All" }],
|
|
2203
|
+
data: {
|
|
2204
|
+
table: BLOG_POSTS_TABLE,
|
|
2205
|
+
tenantScoped: true,
|
|
2206
|
+
searchColumns: ["title", "slug", "excerpt"]
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
function buildBlogCategoryEntity() {
|
|
2211
|
+
const fields = [
|
|
2212
|
+
{ key: "name", label: "Name", type: "text", required: true, searchable: true, showInTable: true, sortable: true },
|
|
2213
|
+
{ key: "slug", label: "Slug", type: "text", required: true, searchable: true, showInTable: true, hint: "URL identifier" },
|
|
2214
|
+
{ key: "description", label: "Description", type: "textarea", span: 2, showInTable: false }
|
|
2215
|
+
];
|
|
2216
|
+
return {
|
|
2217
|
+
name: "Category",
|
|
2218
|
+
namePlural: "Categories",
|
|
2219
|
+
icon: "Tags",
|
|
2220
|
+
fields,
|
|
2221
|
+
displayField: "name",
|
|
2222
|
+
data: {
|
|
2223
|
+
table: BLOG_CATEGORIES_TABLE,
|
|
2224
|
+
tenantScoped: true,
|
|
2225
|
+
searchColumns: ["name", "slug"]
|
|
2226
|
+
}
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
function useEntityCrud(entity) {
|
|
2230
|
+
const provider2 = useMemo(() => resolveDataProvider(entity), [entity]);
|
|
2231
|
+
const [items, setItems] = useState(null);
|
|
2232
|
+
const [total, setTotal] = useState(0);
|
|
2233
|
+
const fetch = useCallback(async (query = {}) => {
|
|
2234
|
+
try {
|
|
2235
|
+
const res = await provider2.list(query);
|
|
2236
|
+
setItems(res.data);
|
|
2237
|
+
setTotal(res.total);
|
|
2238
|
+
} catch (err) {
|
|
2239
|
+
console.warn("[blog] list failed; showing empty. Apply the plugin-blog migration to this pool.", err);
|
|
2240
|
+
setItems([]);
|
|
2241
|
+
setTotal(0);
|
|
2242
|
+
}
|
|
2243
|
+
}, [provider2]);
|
|
2244
|
+
const getById = useCallback(async (id) => {
|
|
2245
|
+
const res = await provider2.list({ filters: { id }, page: 1, pageSize: 1 });
|
|
2246
|
+
return res.data[0] ?? null;
|
|
2247
|
+
}, [provider2]);
|
|
2248
|
+
const create = useCallback((data) => provider2.create(data), [provider2]);
|
|
2249
|
+
const update = useCallback((id, data) => provider2.update(id, data), [provider2]);
|
|
2250
|
+
const remove = useCallback((id) => provider2.remove(id), [provider2]);
|
|
2251
|
+
return { items, total, fetch, getById, create, update, remove };
|
|
2252
|
+
}
|
|
2253
|
+
function PostsList({ navigate }) {
|
|
2254
|
+
const entity = useMemo(() => buildBlogPostEntity(), []);
|
|
2255
|
+
const { items, total, fetch } = useEntityCrud(entity);
|
|
2256
|
+
const [search, setSearch] = useState("");
|
|
2257
|
+
const [statusFilter, setStatusFilter] = useState();
|
|
2258
|
+
const [loadedOnce, setLoadedOnce] = useState(false);
|
|
2259
|
+
useEffect(() => {
|
|
2260
|
+
let active = true;
|
|
2261
|
+
Promise.resolve(
|
|
2262
|
+
fetch({ search: search || void 0, filters: statusFilter ? { status: statusFilter } : void 0 })
|
|
2263
|
+
).finally(() => {
|
|
2264
|
+
if (active) setLoadedOnce(true);
|
|
2265
|
+
});
|
|
2266
|
+
return () => {
|
|
2267
|
+
active = false;
|
|
2268
|
+
};
|
|
2269
|
+
}, [search, statusFilter, fetch]);
|
|
2270
|
+
const facets = useMemo(() => [{
|
|
2271
|
+
field: "status",
|
|
2272
|
+
allLabel: "All",
|
|
2273
|
+
options: [{ value: "draft", label: "Draft" }, { value: "published", label: "Published" }]
|
|
2274
|
+
}], []);
|
|
2275
|
+
return /* @__PURE__ */ jsx(
|
|
2276
|
+
CrudListView,
|
|
2277
|
+
{
|
|
2278
|
+
entityDef: entity,
|
|
2279
|
+
items: loadedOnce ? items : null,
|
|
2280
|
+
total,
|
|
2281
|
+
search,
|
|
2282
|
+
onSearchChange: setSearch,
|
|
2283
|
+
searchPlaceholder: "Search posts\u2026",
|
|
2284
|
+
facets,
|
|
2285
|
+
activeFilters: { status: statusFilter },
|
|
2286
|
+
onFacetChange: (_field, value) => setStatusFilter(value),
|
|
2287
|
+
onNew: () => navigate("blog-new"),
|
|
2288
|
+
addLabel: "New post",
|
|
2289
|
+
onRowClick: (row) => navigate(`blog-post:${row.id}`)
|
|
2290
|
+
}
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
function CategoriesList({ navigate }) {
|
|
2294
|
+
const entity = useMemo(() => buildBlogCategoryEntity(), []);
|
|
2295
|
+
const { items, total, fetch } = useEntityCrud(entity);
|
|
2296
|
+
const [search, setSearch] = useState("");
|
|
2297
|
+
const [loadedOnce, setLoadedOnce] = useState(false);
|
|
2298
|
+
useEffect(() => {
|
|
2299
|
+
let active = true;
|
|
2300
|
+
Promise.resolve(fetch({ search: search || void 0 })).finally(() => {
|
|
2301
|
+
if (active) setLoadedOnce(true);
|
|
2302
|
+
});
|
|
2303
|
+
return () => {
|
|
2304
|
+
active = false;
|
|
2305
|
+
};
|
|
2306
|
+
}, [search, fetch]);
|
|
2307
|
+
return /* @__PURE__ */ jsx(
|
|
2308
|
+
CrudListView,
|
|
2309
|
+
{
|
|
2310
|
+
entityDef: entity,
|
|
2311
|
+
items: loadedOnce ? items : null,
|
|
2312
|
+
total,
|
|
2313
|
+
search,
|
|
2314
|
+
onSearchChange: setSearch,
|
|
2315
|
+
searchPlaceholder: "Search categories\u2026",
|
|
2316
|
+
onNew: () => navigate("blog-cat-new"),
|
|
2317
|
+
addLabel: "New category",
|
|
2318
|
+
onRowClick: (row) => navigate(`blog-cat:${row.id}`)
|
|
2319
|
+
}
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
function BlogAdminView({ navigate }) {
|
|
2323
|
+
const { labels } = useMarketingConfig();
|
|
2324
|
+
const [lens, setLens] = useState("posts");
|
|
2325
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
2326
|
+
/* @__PURE__ */ jsx(
|
|
2327
|
+
SegmentedControl,
|
|
2328
|
+
{
|
|
2329
|
+
"aria-label": "Blog section",
|
|
2330
|
+
options: [
|
|
2331
|
+
{ value: "posts", label: labels.blog },
|
|
2332
|
+
{ value: "categories", label: labels.blogCategories }
|
|
2333
|
+
],
|
|
2334
|
+
value: lens,
|
|
2335
|
+
onChange: (v) => setLens(v)
|
|
2336
|
+
}
|
|
2337
|
+
),
|
|
2338
|
+
lens === "posts" ? /* @__PURE__ */ jsx(PostsList, { navigate }) : /* @__PURE__ */ jsx(CategoriesList, { navigate })
|
|
2339
|
+
] });
|
|
2340
|
+
}
|
|
2341
|
+
function BlogEntityForm({
|
|
2342
|
+
entity,
|
|
2343
|
+
editId,
|
|
2344
|
+
parentLabel,
|
|
2345
|
+
onDone
|
|
2346
|
+
}) {
|
|
2347
|
+
const { getById, create, update, remove } = useEntityCrud(entity);
|
|
2348
|
+
const isEdit = !!editId;
|
|
2349
|
+
const namePlural = entity.namePlural ?? entity.name + "s";
|
|
2350
|
+
const [initialData, setInitialData] = React8.useState(isEdit ? null : {});
|
|
2351
|
+
React8.useEffect(() => {
|
|
2352
|
+
if (!editId) return;
|
|
2353
|
+
let cancelled = false;
|
|
2354
|
+
(async () => {
|
|
2355
|
+
const item = await getById(editId);
|
|
2356
|
+
if (!cancelled) setInitialData(item ?? {});
|
|
2357
|
+
})();
|
|
2358
|
+
return () => {
|
|
2359
|
+
cancelled = true;
|
|
2360
|
+
};
|
|
2361
|
+
}, [editId, getById]);
|
|
2362
|
+
async function save(values) {
|
|
2363
|
+
try {
|
|
2364
|
+
if (isEdit) await update(editId, values);
|
|
2365
|
+
else await create(values);
|
|
2366
|
+
toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`);
|
|
2367
|
+
onDone();
|
|
2368
|
+
} catch (err) {
|
|
2369
|
+
toast.error(`Failed to save ${entity.name.toLowerCase()}`);
|
|
2370
|
+
throw err;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
async function del() {
|
|
2374
|
+
if (!editId) return;
|
|
2375
|
+
try {
|
|
2376
|
+
await remove(editId);
|
|
2377
|
+
toast.success(`${entity.name} deleted`);
|
|
2378
|
+
onDone();
|
|
2379
|
+
} catch {
|
|
2380
|
+
toast.error(`Failed to delete ${entity.name.toLowerCase()}`);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
const title = isEdit ? initialData?.[entity.displayField ?? "name"] ?? entity.name : `New ${entity.name.toLowerCase()}`;
|
|
2384
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
2385
|
+
/* @__PURE__ */ jsx(
|
|
2386
|
+
SubpageHeader,
|
|
2387
|
+
{
|
|
2388
|
+
title: String(title),
|
|
2389
|
+
onBack: onDone,
|
|
2390
|
+
parentLabel,
|
|
2391
|
+
actions: isEdit ? /* @__PURE__ */ jsx(Button, { variant: "ghost", className: "text-destructive hover:text-destructive", onClick: del, children: "Delete" }) : void 0
|
|
2392
|
+
}
|
|
2393
|
+
),
|
|
2394
|
+
initialData !== null && /* @__PURE__ */ jsx(
|
|
2395
|
+
CrudFormPage,
|
|
2396
|
+
{
|
|
2397
|
+
entityDef: entity,
|
|
2398
|
+
mode: isEdit ? "edit" : "create",
|
|
2399
|
+
initialData,
|
|
2400
|
+
namePlural,
|
|
2401
|
+
onCancel: onDone,
|
|
2402
|
+
onSubmit: save,
|
|
2403
|
+
hideBreadcrumb: true
|
|
2404
|
+
}
|
|
2405
|
+
)
|
|
2406
|
+
] });
|
|
2407
|
+
}
|
|
2175
2408
|
function buildNav(config, view, navigate) {
|
|
2176
2409
|
const items = [
|
|
2177
2410
|
{ id: "overview", label: config.labels.overview, icon: "BarChart3", active: view === "overview", onClick: () => navigate("overview") }
|
|
@@ -2203,9 +2436,18 @@ function buildNav(config, view, navigate) {
|
|
|
2203
2436
|
onClick: () => navigate("content")
|
|
2204
2437
|
});
|
|
2205
2438
|
}
|
|
2439
|
+
if (config.modules.blog) {
|
|
2440
|
+
items.push({
|
|
2441
|
+
id: "blog",
|
|
2442
|
+
label: config.labels.blog,
|
|
2443
|
+
icon: "Newspaper",
|
|
2444
|
+
active: view === "blog" || view === "blog-new" || view.startsWith("blog-post:") || view === "blog-cat-new" || view.startsWith("blog-cat:"),
|
|
2445
|
+
onClick: () => navigate("blog")
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2206
2448
|
return items;
|
|
2207
2449
|
}
|
|
2208
|
-
function MarketingPage({ config, provider, store, contentProvider, contentStore }) {
|
|
2450
|
+
function MarketingPage({ config, provider: provider2, store, contentProvider, contentStore }) {
|
|
2209
2451
|
const t = useTranslation();
|
|
2210
2452
|
const { view, direction, navigate } = useModuleNavigation("/marketing", {
|
|
2211
2453
|
overview: 0,
|
|
@@ -2214,15 +2456,22 @@ function MarketingPage({ config, provider, store, contentProvider, contentStore
|
|
|
2214
2456
|
funnel: 0,
|
|
2215
2457
|
"landing-pages": 0,
|
|
2216
2458
|
content: 0,
|
|
2459
|
+
blog: 0,
|
|
2217
2460
|
"channel-detail": 1,
|
|
2218
2461
|
"content-post": 1,
|
|
2219
|
-
"content-brief": 1
|
|
2462
|
+
"content-brief": 1,
|
|
2463
|
+
"blog-new": 1,
|
|
2464
|
+
"blog-post": 1,
|
|
2465
|
+
"blog-cat-new": 1,
|
|
2466
|
+
"blog-cat": 1
|
|
2220
2467
|
}, "overview");
|
|
2221
2468
|
React8.useEffect(() => {
|
|
2222
2469
|
void store.getState().load();
|
|
2223
2470
|
}, [store]);
|
|
2224
2471
|
const isOverview = view === "overview";
|
|
2225
2472
|
const nav = buildNav(config, view, navigate);
|
|
2473
|
+
const blogPostEntity = React8.useMemo(() => buildBlogPostEntity(), []);
|
|
2474
|
+
const blogCategoryEntity = React8.useMemo(() => buildBlogCategoryEntity(), []);
|
|
2226
2475
|
const quickActions = React8.useMemo(() => [
|
|
2227
2476
|
{ id: "new-campaign", label: t("marketing.campaigns.new"), icon: "Megaphone", description: config.labels.campaigns, action: () => navigate("campaigns") }
|
|
2228
2477
|
], [t, config.labels.campaigns]);
|
|
@@ -2235,9 +2484,14 @@ function MarketingPage({ config, provider, store, contentProvider, contentStore
|
|
|
2235
2484
|
{ id: "content", render: () => /* @__PURE__ */ jsx(ContentView, { onOpenPost: (id) => navigate(`content-post:${id}`) }) },
|
|
2236
2485
|
{ id: "content-post", render: ({ id }) => /* @__PURE__ */ jsx(PostPage, { postId: id, onBack: () => navigate("content") }) },
|
|
2237
2486
|
{ id: "content-brief", render: () => /* @__PURE__ */ jsx(PlanBriefView, { onBack: () => navigate("content") }) },
|
|
2487
|
+
{ id: "blog", render: () => /* @__PURE__ */ jsx(BlogAdminView, { navigate }) },
|
|
2488
|
+
{ id: "blog-new", render: () => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogPostEntity, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
|
|
2489
|
+
{ id: "blog-post", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogPostEntity, editId: id, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
|
|
2490
|
+
{ id: "blog-cat-new", render: () => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
|
|
2491
|
+
{ id: "blog-cat", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, editId: id, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
|
|
2238
2492
|
{ id: "overview", render: () => /* @__PURE__ */ jsx(OverviewView, {}) }
|
|
2239
2493
|
], "overview");
|
|
2240
|
-
return /* @__PURE__ */ jsx(MarketingContextProvider, { config, provider, store, children: /* @__PURE__ */ jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsx(
|
|
2494
|
+
return /* @__PURE__ */ jsx(MarketingContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsx(
|
|
2241
2495
|
ModulePage,
|
|
2242
2496
|
{
|
|
2243
2497
|
title: config.labels.pageTitle,
|
|
@@ -2512,7 +2766,7 @@ var AGENCY = {
|
|
|
2512
2766
|
{ id: "referral", label: "Referral", icon: "Users", kind: "referral" },
|
|
2513
2767
|
{ id: "direct", label: "Direct", icon: "MousePointerClick", kind: "direct" }
|
|
2514
2768
|
],
|
|
2515
|
-
modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, broadcasts: false, journeys: false }
|
|
2769
|
+
modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
|
|
2516
2770
|
};
|
|
2517
2771
|
var BEAUTY = {
|
|
2518
2772
|
conversion: {
|
|
@@ -2530,7 +2784,7 @@ var BEAUTY = {
|
|
|
2530
2784
|
{ id: "whatsapp", label: "WhatsApp", icon: "MessageCircle", kind: "social" },
|
|
2531
2785
|
{ id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
|
|
2532
2786
|
],
|
|
2533
|
-
modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, broadcasts: false, journeys: false }
|
|
2787
|
+
modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, blog: false, broadcasts: false, journeys: false }
|
|
2534
2788
|
};
|
|
2535
2789
|
var RESTO = {
|
|
2536
2790
|
conversion: {
|
|
@@ -2548,7 +2802,7 @@ var RESTO = {
|
|
|
2548
2802
|
{ id: "instagram", label: "Instagram", icon: "Instagram", kind: "social" },
|
|
2549
2803
|
{ id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
|
|
2550
2804
|
],
|
|
2551
|
-
modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, broadcasts: false, journeys: false }
|
|
2805
|
+
modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
|
|
2552
2806
|
};
|
|
2553
2807
|
var MARKETING_PRESETS = {
|
|
2554
2808
|
agency: AGENCY,
|
|
@@ -3028,6 +3282,15 @@ function createMockContentPlannerProvider() {
|
|
|
3028
3282
|
}
|
|
3029
3283
|
};
|
|
3030
3284
|
}
|
|
3285
|
+
|
|
3286
|
+
// src/data/tables.ts
|
|
3287
|
+
var T = {
|
|
3288
|
+
socialAccounts: "plg_marketing_social_accounts",
|
|
3289
|
+
contentPlans: "plg_marketing_content_plans",
|
|
3290
|
+
contentPosts: "plg_marketing_content_posts"
|
|
3291
|
+
};
|
|
3292
|
+
|
|
3293
|
+
// src/data/contentSupabase.ts
|
|
3031
3294
|
function getClient() {
|
|
3032
3295
|
const supabase = getSupabaseClientOptional();
|
|
3033
3296
|
if (!supabase) throw new Error("Supabase not initialized");
|
|
@@ -3069,7 +3332,7 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3069
3332
|
async listAccounts() {
|
|
3070
3333
|
const tenantId = getActiveTenantId();
|
|
3071
3334
|
if (!tenantId) return [];
|
|
3072
|
-
const { data } = await getClient().from(
|
|
3335
|
+
const { data } = await getClient().from(T.socialAccounts).select("*").eq("tenant_id", tenantId).eq("is_active", true).order("created_at", { ascending: true });
|
|
3073
3336
|
return (data ?? []).map(toAccount);
|
|
3074
3337
|
},
|
|
3075
3338
|
async saveAccount(input) {
|
|
@@ -3079,11 +3342,11 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3079
3342
|
if (input.handle !== void 0) payload.handle = input.handle;
|
|
3080
3343
|
if (input.platforms !== void 0) payload.platforms = input.platforms;
|
|
3081
3344
|
if (input.isActive !== void 0) payload.is_active = input.isActive;
|
|
3082
|
-
const { data: data2, error: error2 } = await getClient().from(
|
|
3345
|
+
const { data: data2, error: error2 } = await getClient().from(T.socialAccounts).update(payload).eq("id", input.id).select("*").single();
|
|
3083
3346
|
if (error2) throw error2;
|
|
3084
3347
|
return toAccount(data2);
|
|
3085
3348
|
}
|
|
3086
|
-
const { data, error } = await getClient().from(
|
|
3349
|
+
const { data, error } = await getClient().from(T.socialAccounts).insert({
|
|
3087
3350
|
tenant_id: requireTenantId(),
|
|
3088
3351
|
name: input.name,
|
|
3089
3352
|
handle: input.handle ?? null,
|
|
@@ -3094,17 +3357,17 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3094
3357
|
return toAccount(data);
|
|
3095
3358
|
},
|
|
3096
3359
|
async deleteAccount(id) {
|
|
3097
|
-
const { error } = await getClient().from(
|
|
3360
|
+
const { error } = await getClient().from(T.socialAccounts).delete().eq("id", id);
|
|
3098
3361
|
if (error) throw error;
|
|
3099
3362
|
},
|
|
3100
3363
|
async listPlans(accountId) {
|
|
3101
3364
|
const tenantId = getActiveTenantId();
|
|
3102
3365
|
if (!tenantId) return [];
|
|
3103
|
-
const { data } = await getClient().from(
|
|
3366
|
+
const { data } = await getClient().from(T.contentPlans).select("*").eq("tenant_id", tenantId).eq("account_id", accountId).order("created_at", { ascending: false });
|
|
3104
3367
|
return (data ?? []).map(toPlan);
|
|
3105
3368
|
},
|
|
3106
3369
|
async getPlan(id) {
|
|
3107
|
-
const { data } = await getClient().from(
|
|
3370
|
+
const { data } = await getClient().from(T.contentPlans).select("*").eq("id", id).maybeSingle();
|
|
3108
3371
|
return data ? toPlan(data) : null;
|
|
3109
3372
|
},
|
|
3110
3373
|
async savePlan(input) {
|
|
@@ -3120,12 +3383,12 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3120
3383
|
if (input.formats !== void 0) payload.formats = input.formats;
|
|
3121
3384
|
if (input.weeklyFrequency !== void 0) payload.weekly_frequency = input.weeklyFrequency;
|
|
3122
3385
|
if (input.briefMd !== void 0) payload.brief_md = input.briefMd;
|
|
3123
|
-
const { data: data2, error: error2 } = await getClient().from(
|
|
3386
|
+
const { data: data2, error: error2 } = await getClient().from(T.contentPlans).update(payload).eq("id", input.id).select("*").single();
|
|
3124
3387
|
if (error2) throw error2;
|
|
3125
3388
|
return toPlan(data2);
|
|
3126
3389
|
}
|
|
3127
3390
|
if (!input.accountId) throw new Error("accountId is required");
|
|
3128
|
-
const { data, error } = await getClient().from(
|
|
3391
|
+
const { data, error } = await getClient().from(T.contentPlans).insert({
|
|
3129
3392
|
tenant_id: requireTenantId(),
|
|
3130
3393
|
account_id: input.accountId,
|
|
3131
3394
|
name: input.name ?? "Novo plano",
|
|
@@ -3143,14 +3406,14 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3143
3406
|
return toPlan(data);
|
|
3144
3407
|
},
|
|
3145
3408
|
async deletePlan(id) {
|
|
3146
|
-
await getClient().from(
|
|
3409
|
+
await getClient().from(T.contentPlans).delete().eq("id", id);
|
|
3147
3410
|
},
|
|
3148
3411
|
async listPosts(planId) {
|
|
3149
|
-
const { data } = await getClient().from(
|
|
3412
|
+
const { data } = await getClient().from(T.contentPosts).select("*").eq("plan_id", planId).order("week_number", { ascending: true }).order("position", { ascending: true });
|
|
3150
3413
|
return (data ?? []).map(toPost);
|
|
3151
3414
|
},
|
|
3152
3415
|
async getPost(id) {
|
|
3153
|
-
const { data } = await getClient().from(
|
|
3416
|
+
const { data } = await getClient().from(T.contentPosts).select("*").eq("id", id).maybeSingle();
|
|
3154
3417
|
return data ? toPost(data) : null;
|
|
3155
3418
|
},
|
|
3156
3419
|
async savePost(input) {
|
|
@@ -3168,15 +3431,15 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3168
3431
|
if (input.hook !== void 0) payload.hook = input.hook;
|
|
3169
3432
|
if (input.cta !== void 0) payload.cta = input.cta;
|
|
3170
3433
|
if (input.contentMd !== void 0) payload.content_md = input.contentMd;
|
|
3171
|
-
const { data: data2, error: error2 } = await getClient().from(
|
|
3434
|
+
const { data: data2, error: error2 } = await getClient().from(T.contentPosts).update(payload).eq("id", input.id).select("*").single();
|
|
3172
3435
|
if (error2) throw error2;
|
|
3173
3436
|
return toPost(data2);
|
|
3174
3437
|
}
|
|
3175
3438
|
if (!input.planId) throw new Error("planId is required");
|
|
3176
3439
|
const week = input.weekNumber ?? 1;
|
|
3177
|
-
const { data: maxData } = await getClient().from(
|
|
3440
|
+
const { data: maxData } = await getClient().from(T.contentPosts).select("position").eq("plan_id", input.planId).eq("week_number", week).order("position", { ascending: false }).limit(1);
|
|
3178
3441
|
const maxPos = maxData?.[0]?.position ?? -1;
|
|
3179
|
-
const { data, error } = await getClient().from(
|
|
3442
|
+
const { data, error } = await getClient().from(T.contentPosts).insert({
|
|
3180
3443
|
tenant_id: requireTenantId(),
|
|
3181
3444
|
plan_id: input.planId,
|
|
3182
3445
|
week_number: week,
|
|
@@ -3196,11 +3459,11 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3196
3459
|
return toPost(data);
|
|
3197
3460
|
},
|
|
3198
3461
|
async deletePost(id) {
|
|
3199
|
-
await getClient().from(
|
|
3462
|
+
await getClient().from(T.contentPosts).delete().eq("id", id);
|
|
3200
3463
|
}
|
|
3201
3464
|
};
|
|
3202
3465
|
}
|
|
3203
|
-
function createMarketingStore(
|
|
3466
|
+
function createMarketingStore(provider2) {
|
|
3204
3467
|
return createStore((set, get) => ({
|
|
3205
3468
|
range: "30d",
|
|
3206
3469
|
overview: null,
|
|
@@ -3213,11 +3476,11 @@ function createMarketingStore(provider) {
|
|
|
3213
3476
|
const range = get().range;
|
|
3214
3477
|
set({ loading: true });
|
|
3215
3478
|
const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3479
|
+
provider2.overview({ range }),
|
|
3480
|
+
provider2.channelPerformance({ range }),
|
|
3481
|
+
provider2.listCampaigns({ range }),
|
|
3482
|
+
provider2.funnel({ range }),
|
|
3483
|
+
provider2.landingPages({ range })
|
|
3221
3484
|
]);
|
|
3222
3485
|
set({ overview, channels, campaigns, funnel, landingPages, loading: false });
|
|
3223
3486
|
},
|
|
@@ -3226,21 +3489,21 @@ function createMarketingStore(provider) {
|
|
|
3226
3489
|
await get().load();
|
|
3227
3490
|
},
|
|
3228
3491
|
async saveCampaign(input) {
|
|
3229
|
-
const created = await
|
|
3492
|
+
const created = await provider2.saveCampaign(input);
|
|
3230
3493
|
await get().load();
|
|
3231
3494
|
return created;
|
|
3232
3495
|
},
|
|
3233
3496
|
async deleteCampaign(id) {
|
|
3234
|
-
await
|
|
3497
|
+
await provider2.deleteCampaign(id);
|
|
3235
3498
|
await get().load();
|
|
3236
3499
|
}
|
|
3237
3500
|
}));
|
|
3238
3501
|
}
|
|
3239
|
-
function createContentPlannerStore(
|
|
3502
|
+
function createContentPlannerStore(provider2) {
|
|
3240
3503
|
async function loadPlansFor(accountId) {
|
|
3241
|
-
const plans = await
|
|
3504
|
+
const plans = await provider2.listPlans(accountId);
|
|
3242
3505
|
const activePlan = plans.find((p) => p.status === "active") ?? plans[0] ?? null;
|
|
3243
|
-
const posts = activePlan ? await
|
|
3506
|
+
const posts = activePlan ? await provider2.listPosts(activePlan.id) : [];
|
|
3244
3507
|
return { plans, activePlan, posts };
|
|
3245
3508
|
}
|
|
3246
3509
|
return createStore((set, get) => ({
|
|
@@ -3260,7 +3523,7 @@ function createContentPlannerStore(provider) {
|
|
|
3260
3523
|
if (get().loading) return;
|
|
3261
3524
|
set({ loading: true });
|
|
3262
3525
|
try {
|
|
3263
|
-
const accounts = await
|
|
3526
|
+
const accounts = await provider2.listAccounts();
|
|
3264
3527
|
const current = get().activeAccountId;
|
|
3265
3528
|
const activeAccountId = (current && accounts.some((a) => a.id === current) ? current : accounts[0]?.id) ?? null;
|
|
3266
3529
|
const { plans, activePlan, posts } = activeAccountId ? await loadPlansFor(activeAccountId) : { plans: [], activePlan: null, posts: [] };
|
|
@@ -3291,19 +3554,19 @@ function createContentPlannerStore(provider) {
|
|
|
3291
3554
|
},
|
|
3292
3555
|
async selectPlan(planId) {
|
|
3293
3556
|
const plan = get().plans.find((p) => p.id === planId) ?? null;
|
|
3294
|
-
const posts = plan ? await
|
|
3557
|
+
const posts = plan ? await provider2.listPosts(plan.id) : [];
|
|
3295
3558
|
set({ activePlan: plan, posts });
|
|
3296
3559
|
},
|
|
3297
3560
|
async saveAccount(input) {
|
|
3298
|
-
const account = await
|
|
3299
|
-
const accounts = await
|
|
3561
|
+
const account = await provider2.saveAccount(input);
|
|
3562
|
+
const accounts = await provider2.listAccounts();
|
|
3300
3563
|
set({ accounts });
|
|
3301
3564
|
if (!get().activeAccountId) await get().setAccount(account.id);
|
|
3302
3565
|
return account;
|
|
3303
3566
|
},
|
|
3304
3567
|
async deleteAccount(id) {
|
|
3305
|
-
await
|
|
3306
|
-
const accounts = await
|
|
3568
|
+
await provider2.deleteAccount(id);
|
|
3569
|
+
const accounts = await provider2.listAccounts();
|
|
3307
3570
|
const wasActive = get().activeAccountId === id;
|
|
3308
3571
|
set({ accounts });
|
|
3309
3572
|
if (wasActive) {
|
|
@@ -3316,30 +3579,30 @@ function createContentPlannerStore(provider) {
|
|
|
3316
3579
|
}
|
|
3317
3580
|
},
|
|
3318
3581
|
async savePlan(input) {
|
|
3319
|
-
const plan = await
|
|
3582
|
+
const plan = await provider2.savePlan(input);
|
|
3320
3583
|
const accountId = get().activeAccountId;
|
|
3321
3584
|
if (accountId) {
|
|
3322
|
-
const plans = await
|
|
3585
|
+
const plans = await provider2.listPlans(accountId);
|
|
3323
3586
|
const activePlan = plans.find((p) => p.id === plan.id) ?? get().activePlan;
|
|
3324
|
-
const posts = activePlan ? await
|
|
3587
|
+
const posts = activePlan ? await provider2.listPosts(activePlan.id) : [];
|
|
3325
3588
|
set({ plans, activePlan, posts });
|
|
3326
3589
|
}
|
|
3327
3590
|
return plan;
|
|
3328
3591
|
},
|
|
3329
3592
|
async savePost(input) {
|
|
3330
|
-
const post = await
|
|
3593
|
+
const post = await provider2.savePost(input);
|
|
3331
3594
|
const planId = get().activePlan?.id;
|
|
3332
3595
|
if (planId && post.planId === planId) {
|
|
3333
|
-
const posts = await
|
|
3596
|
+
const posts = await provider2.listPosts(planId);
|
|
3334
3597
|
set({ posts });
|
|
3335
3598
|
}
|
|
3336
3599
|
return post;
|
|
3337
3600
|
},
|
|
3338
3601
|
async deletePost(id) {
|
|
3339
|
-
await
|
|
3602
|
+
await provider2.deletePost(id);
|
|
3340
3603
|
const planId = get().activePlan?.id;
|
|
3341
3604
|
if (planId) {
|
|
3342
|
-
const posts = await
|
|
3605
|
+
const posts = await provider2.listPosts(planId);
|
|
3343
3606
|
set({ posts });
|
|
3344
3607
|
}
|
|
3345
3608
|
}
|
|
@@ -3347,13 +3610,29 @@ function createContentPlannerStore(provider) {
|
|
|
3347
3610
|
}
|
|
3348
3611
|
|
|
3349
3612
|
// src/migrations/index.ts
|
|
3613
|
+
var MIGRATION_000_PLG_RENAME = `-- 000_plg_rename.sql \u2014 rename legacy marketing tables to plg_marketing_* for pools
|
|
3614
|
+
-- provisioned before the industry-pool rename. Guarded: fires only when legacy
|
|
3615
|
+
-- name exists and target does not, so fresh pools skip every branch.
|
|
3616
|
+
DO $$
|
|
3617
|
+
BEGIN
|
|
3618
|
+
IF to_regclass('public.mkt_social_accounts') IS NOT NULL AND to_regclass('public.plg_marketing_social_accounts') IS NULL THEN
|
|
3619
|
+
ALTER TABLE public.mkt_social_accounts RENAME TO plg_marketing_social_accounts;
|
|
3620
|
+
END IF;
|
|
3621
|
+
IF to_regclass('public.mkt_content_plans') IS NOT NULL AND to_regclass('public.plg_marketing_content_plans') IS NULL THEN
|
|
3622
|
+
ALTER TABLE public.mkt_content_plans RENAME TO plg_marketing_content_plans;
|
|
3623
|
+
END IF;
|
|
3624
|
+
IF to_regclass('public.mkt_content_posts') IS NOT NULL AND to_regclass('public.plg_marketing_content_posts') IS NULL THEN
|
|
3625
|
+
ALTER TABLE public.mkt_content_posts RENAME TO plg_marketing_content_posts;
|
|
3626
|
+
END IF;
|
|
3627
|
+
END $$;
|
|
3628
|
+
`;
|
|
3350
3629
|
var MIGRATION_001_CONTENT_PLANNER = `-- Marketing Plugin: Content Planner
|
|
3351
|
-
-- Prefix:
|
|
3630
|
+
-- Prefix: plg_marketing_
|
|
3352
3631
|
-- Social accounts + content plans + posts (Notion-style markdown pages).
|
|
3353
3632
|
|
|
3354
|
-
CREATE TABLE IF NOT EXISTS public.
|
|
3633
|
+
CREATE TABLE IF NOT EXISTS public.plg_marketing_social_accounts (
|
|
3355
3634
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3356
|
-
tenant_id uuid NOT NULL REFERENCES
|
|
3635
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
3357
3636
|
name text NOT NULL,
|
|
3358
3637
|
handle text,
|
|
3359
3638
|
platform text NOT NULL DEFAULT 'instagram'
|
|
@@ -3361,13 +3640,13 @@ CREATE TABLE IF NOT EXISTS public.mkt_social_accounts (
|
|
|
3361
3640
|
is_active boolean NOT NULL DEFAULT true,
|
|
3362
3641
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
3363
3642
|
);
|
|
3364
|
-
ALTER TABLE public.
|
|
3365
|
-
CREATE INDEX IF NOT EXISTS
|
|
3643
|
+
ALTER TABLE public.plg_marketing_social_accounts ENABLE ROW LEVEL SECURITY;
|
|
3644
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_social_accounts_tenant ON public.plg_marketing_social_accounts(tenant_id);
|
|
3366
3645
|
|
|
3367
|
-
CREATE TABLE IF NOT EXISTS public.
|
|
3646
|
+
CREATE TABLE IF NOT EXISTS public.plg_marketing_content_plans (
|
|
3368
3647
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3369
|
-
tenant_id uuid NOT NULL REFERENCES
|
|
3370
|
-
account_id uuid NOT NULL REFERENCES public.
|
|
3648
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
3649
|
+
account_id uuid NOT NULL REFERENCES public.plg_marketing_social_accounts(id) ON DELETE CASCADE,
|
|
3371
3650
|
name text NOT NULL,
|
|
3372
3651
|
status text NOT NULL DEFAULT 'active'
|
|
3373
3652
|
CHECK (status IN ('draft', 'active', 'archived')),
|
|
@@ -3382,14 +3661,14 @@ CREATE TABLE IF NOT EXISTS public.mkt_content_plans (
|
|
|
3382
3661
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
3383
3662
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
3384
3663
|
);
|
|
3385
|
-
ALTER TABLE public.
|
|
3386
|
-
CREATE INDEX IF NOT EXISTS
|
|
3387
|
-
CREATE INDEX IF NOT EXISTS
|
|
3664
|
+
ALTER TABLE public.plg_marketing_content_plans ENABLE ROW LEVEL SECURITY;
|
|
3665
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_plans_tenant ON public.plg_marketing_content_plans(tenant_id);
|
|
3666
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_plans_account ON public.plg_marketing_content_plans(account_id);
|
|
3388
3667
|
|
|
3389
|
-
CREATE TABLE IF NOT EXISTS public.
|
|
3668
|
+
CREATE TABLE IF NOT EXISTS public.plg_marketing_content_posts (
|
|
3390
3669
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3391
|
-
tenant_id uuid NOT NULL REFERENCES
|
|
3392
|
-
plan_id uuid NOT NULL REFERENCES public.
|
|
3670
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
3671
|
+
plan_id uuid NOT NULL REFERENCES public.plg_marketing_content_plans(id) ON DELETE CASCADE,
|
|
3393
3672
|
week_number integer NOT NULL DEFAULT 1,
|
|
3394
3673
|
position integer NOT NULL DEFAULT 0,
|
|
3395
3674
|
title text NOT NULL,
|
|
@@ -3404,49 +3683,50 @@ CREATE TABLE IF NOT EXISTS public.mkt_content_posts (
|
|
|
3404
3683
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
3405
3684
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
3406
3685
|
);
|
|
3407
|
-
ALTER TABLE public.
|
|
3408
|
-
CREATE INDEX IF NOT EXISTS
|
|
3409
|
-
CREATE INDEX IF NOT EXISTS
|
|
3410
|
-
CREATE INDEX IF NOT EXISTS
|
|
3411
|
-
|
|
3412
|
-
-- RLS policies for
|
|
3413
|
-
DROP POLICY IF EXISTS
|
|
3414
|
-
DROP POLICY IF EXISTS
|
|
3415
|
-
DROP POLICY IF EXISTS
|
|
3416
|
-
DROP POLICY IF EXISTS
|
|
3417
|
-
CREATE POLICY
|
|
3418
|
-
CREATE POLICY
|
|
3419
|
-
CREATE POLICY
|
|
3420
|
-
CREATE POLICY
|
|
3421
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.
|
|
3422
|
-
|
|
3423
|
-
-- RLS policies for
|
|
3424
|
-
DROP POLICY IF EXISTS
|
|
3425
|
-
DROP POLICY IF EXISTS
|
|
3426
|
-
DROP POLICY IF EXISTS
|
|
3427
|
-
DROP POLICY IF EXISTS
|
|
3428
|
-
CREATE POLICY
|
|
3429
|
-
CREATE POLICY
|
|
3430
|
-
CREATE POLICY
|
|
3431
|
-
CREATE POLICY
|
|
3432
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.
|
|
3433
|
-
|
|
3434
|
-
-- RLS policies for
|
|
3435
|
-
DROP POLICY IF EXISTS
|
|
3436
|
-
DROP POLICY IF EXISTS
|
|
3437
|
-
DROP POLICY IF EXISTS
|
|
3438
|
-
DROP POLICY IF EXISTS
|
|
3439
|
-
CREATE POLICY
|
|
3440
|
-
CREATE POLICY
|
|
3441
|
-
CREATE POLICY
|
|
3442
|
-
CREATE POLICY
|
|
3443
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.
|
|
3686
|
+
ALTER TABLE public.plg_marketing_content_posts ENABLE ROW LEVEL SECURITY;
|
|
3687
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_tenant ON public.plg_marketing_content_posts(tenant_id);
|
|
3688
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_plan ON public.plg_marketing_content_posts(plan_id, week_number, position);
|
|
3689
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_status ON public.plg_marketing_content_posts(tenant_id, status);
|
|
3690
|
+
|
|
3691
|
+
-- RLS policies for plg_marketing_social_accounts
|
|
3692
|
+
DROP POLICY IF EXISTS plg_marketing_social_accounts_select ON public.plg_marketing_social_accounts;
|
|
3693
|
+
DROP POLICY IF EXISTS plg_marketing_social_accounts_insert ON public.plg_marketing_social_accounts;
|
|
3694
|
+
DROP POLICY IF EXISTS plg_marketing_social_accounts_update ON public.plg_marketing_social_accounts;
|
|
3695
|
+
DROP POLICY IF EXISTS plg_marketing_social_accounts_delete ON public.plg_marketing_social_accounts;
|
|
3696
|
+
CREATE 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()));
|
|
3697
|
+
CREATE 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()));
|
|
3698
|
+
CREATE 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()));
|
|
3699
|
+
CREATE 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()));
|
|
3700
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_social_accounts TO authenticated;
|
|
3701
|
+
|
|
3702
|
+
-- RLS policies for plg_marketing_content_plans
|
|
3703
|
+
DROP POLICY IF EXISTS plg_marketing_content_plans_select ON public.plg_marketing_content_plans;
|
|
3704
|
+
DROP POLICY IF EXISTS plg_marketing_content_plans_insert ON public.plg_marketing_content_plans;
|
|
3705
|
+
DROP POLICY IF EXISTS plg_marketing_content_plans_update ON public.plg_marketing_content_plans;
|
|
3706
|
+
DROP POLICY IF EXISTS plg_marketing_content_plans_delete ON public.plg_marketing_content_plans;
|
|
3707
|
+
CREATE 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()));
|
|
3708
|
+
CREATE 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()));
|
|
3709
|
+
CREATE 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()));
|
|
3710
|
+
CREATE 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()));
|
|
3711
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_content_plans TO authenticated;
|
|
3712
|
+
|
|
3713
|
+
-- RLS policies for plg_marketing_content_posts
|
|
3714
|
+
DROP POLICY IF EXISTS plg_marketing_content_posts_select ON public.plg_marketing_content_posts;
|
|
3715
|
+
DROP POLICY IF EXISTS plg_marketing_content_posts_insert ON public.plg_marketing_content_posts;
|
|
3716
|
+
DROP POLICY IF EXISTS plg_marketing_content_posts_update ON public.plg_marketing_content_posts;
|
|
3717
|
+
DROP POLICY IF EXISTS plg_marketing_content_posts_delete ON public.plg_marketing_content_posts;
|
|
3718
|
+
CREATE 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()));
|
|
3719
|
+
CREATE 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()));
|
|
3720
|
+
CREATE 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()));
|
|
3721
|
+
CREATE 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()));
|
|
3722
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_content_posts TO authenticated;
|
|
3723
|
+
`;
|
|
3444
3724
|
var MIGRATION_002_MULTI_PLATFORM = `-- Marketing Plugin: multi-platform accounts + per-post targets.
|
|
3445
3725
|
-- An account is a brand/profile that publishes to MANY platforms (Instagram,
|
|
3446
3726
|
-- YouTube, TikTok, ...). Posts optionally narrow the target platforms; an
|
|
3447
3727
|
-- empty array means "inherit the account's platforms".
|
|
3448
3728
|
|
|
3449
|
-
ALTER TABLE public.
|
|
3729
|
+
ALTER TABLE public.plg_marketing_social_accounts
|
|
3450
3730
|
ADD COLUMN IF NOT EXISTS platforms text[] NOT NULL DEFAULT '{instagram}';
|
|
3451
3731
|
|
|
3452
3732
|
-- Backfill from the legacy single-platform column, then drop it (also drops
|
|
@@ -3455,26 +3735,27 @@ DO $$
|
|
|
3455
3735
|
BEGIN
|
|
3456
3736
|
IF EXISTS (
|
|
3457
3737
|
SELECT 1 FROM information_schema.columns
|
|
3458
|
-
WHERE table_schema = 'public' AND table_name = '
|
|
3738
|
+
WHERE table_schema = 'public' AND table_name = 'plg_marketing_social_accounts' AND column_name = 'platform'
|
|
3459
3739
|
) THEN
|
|
3460
|
-
UPDATE public.
|
|
3740
|
+
UPDATE public.plg_marketing_social_accounts
|
|
3461
3741
|
SET platforms = ARRAY[platform]
|
|
3462
3742
|
WHERE platform IS NOT NULL AND platforms = '{instagram}';
|
|
3463
|
-
ALTER TABLE public.
|
|
3743
|
+
ALTER TABLE public.plg_marketing_social_accounts DROP COLUMN platform;
|
|
3464
3744
|
END IF;
|
|
3465
3745
|
END $$;
|
|
3466
3746
|
|
|
3467
|
-
ALTER TABLE public.
|
|
3468
|
-
ADD COLUMN IF NOT EXISTS platforms text[] NOT NULL DEFAULT '{}'
|
|
3747
|
+
ALTER TABLE public.plg_marketing_content_posts
|
|
3748
|
+
ADD COLUMN IF NOT EXISTS platforms text[] NOT NULL DEFAULT '{}';
|
|
3749
|
+
`;
|
|
3469
3750
|
var MIGRATION_003_RECORDING_OPS = `-- Marketing Plugin: recording-day ops on posts.
|
|
3470
3751
|
-- checklist: shooting checklist items [{ id, text, done }] \u2014 the on-set
|
|
3471
3752
|
-- companion for recording day. media_url: uploaded asset (static posts get a
|
|
3472
3753
|
-- caption + final art instead of a script).
|
|
3473
3754
|
|
|
3474
|
-
ALTER TABLE public.
|
|
3755
|
+
ALTER TABLE public.plg_marketing_content_posts
|
|
3475
3756
|
ADD COLUMN IF NOT EXISTS checklist jsonb NOT NULL DEFAULT '[]';
|
|
3476
3757
|
|
|
3477
|
-
ALTER TABLE public.
|
|
3758
|
+
ALTER TABLE public.plg_marketing_content_posts
|
|
3478
3759
|
ADD COLUMN IF NOT EXISTS media_url text;
|
|
3479
3760
|
|
|
3480
3761
|
-- Public media bucket for content assets (mirrors the avatars-bucket pattern:
|
|
@@ -3483,14 +3764,213 @@ INSERT INTO storage.buckets (id, name, public)
|
|
|
3483
3764
|
VALUES ('mkt-media', 'mkt-media', true)
|
|
3484
3765
|
ON CONFLICT (id) DO NOTHING;
|
|
3485
3766
|
|
|
3486
|
-
DROP POLICY IF EXISTS
|
|
3487
|
-
DROP POLICY IF EXISTS
|
|
3488
|
-
DROP POLICY IF EXISTS
|
|
3489
|
-
DROP POLICY IF EXISTS
|
|
3490
|
-
CREATE POLICY
|
|
3491
|
-
CREATE POLICY
|
|
3492
|
-
CREATE POLICY
|
|
3493
|
-
CREATE POLICY
|
|
3767
|
+
DROP POLICY IF EXISTS plg_marketing_media_read ON storage.objects;
|
|
3768
|
+
DROP POLICY IF EXISTS plg_marketing_media_insert ON storage.objects;
|
|
3769
|
+
DROP POLICY IF EXISTS plg_marketing_media_update ON storage.objects;
|
|
3770
|
+
DROP POLICY IF EXISTS plg_marketing_media_delete ON storage.objects;
|
|
3771
|
+
CREATE POLICY plg_marketing_media_read ON storage.objects FOR SELECT TO public USING (bucket_id = 'mkt-media');
|
|
3772
|
+
CREATE POLICY plg_marketing_media_insert ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'mkt-media');
|
|
3773
|
+
CREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'mkt-media');
|
|
3774
|
+
CREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');
|
|
3775
|
+
`;
|
|
3776
|
+
var MIGRATION_004_RANKLAYER = `-- Marketing Plugin: RankLayer integration (SEO content publishing).
|
|
3777
|
+
-- Control-plane state for the RankLayer connector rendered in Marketing \u2192
|
|
3778
|
+
-- Integra\xE7\xF5es. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
|
|
3779
|
+
-- (edge function + RankLayer API calls) lands via an external PR \u2014 see
|
|
3780
|
+
-- src/integrations/ranklayer/RANKLAYER.md.
|
|
3781
|
+
|
|
3782
|
+
CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
|
|
3783
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3784
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
3785
|
+
api_key text,
|
|
3786
|
+
site_domain text,
|
|
3787
|
+
active boolean NOT NULL DEFAULT true,
|
|
3788
|
+
last_sync_at timestamptz,
|
|
3789
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
3790
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
3791
|
+
);
|
|
3792
|
+
ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
|
|
3793
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
|
|
3794
|
+
ON public.plg_marketing_ranklayer_integrations(tenant_id);
|
|
3795
|
+
|
|
3796
|
+
CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
|
|
3797
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3798
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
3799
|
+
integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
|
|
3800
|
+
status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
|
|
3801
|
+
message text,
|
|
3802
|
+
fetched integer NOT NULL DEFAULT 0,
|
|
3803
|
+
written integer NOT NULL DEFAULT 0,
|
|
3804
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
3805
|
+
);
|
|
3806
|
+
ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
|
|
3807
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
|
|
3808
|
+
ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
|
|
3809
|
+
|
|
3810
|
+
-- RLS: authenticated tenant CRUD
|
|
3811
|
+
DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
|
|
3812
|
+
DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
|
|
3813
|
+
DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
|
|
3814
|
+
DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
|
|
3815
|
+
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()));
|
|
3816
|
+
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()));
|
|
3817
|
+
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()));
|
|
3818
|
+
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()));
|
|
3819
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
|
|
3820
|
+
|
|
3821
|
+
DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
|
|
3822
|
+
DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
|
|
3823
|
+
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()));
|
|
3824
|
+
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()));
|
|
3825
|
+
GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
|
|
3826
|
+
`;
|
|
3827
|
+
var INTEGRATIONS = "plg_marketing_ranklayer_integrations";
|
|
3828
|
+
var SYNC_LOG = "plg_marketing_ranklayer_sync_log";
|
|
3829
|
+
function getClient2() {
|
|
3830
|
+
const supabase = getSupabaseClientOptional();
|
|
3831
|
+
if (!supabase) throw new Error("[ranklayer] Supabase not initialized");
|
|
3832
|
+
return supabase;
|
|
3833
|
+
}
|
|
3834
|
+
function requireTenantId2() {
|
|
3835
|
+
const tenantId = getActiveTenantId();
|
|
3836
|
+
if (!tenantId) throw new Error("[ranklayer] No active tenant");
|
|
3837
|
+
return tenantId;
|
|
3838
|
+
}
|
|
3839
|
+
function toIntegration(row) {
|
|
3840
|
+
return {
|
|
3841
|
+
id: row.id,
|
|
3842
|
+
apiKey: row.api_key ?? void 0,
|
|
3843
|
+
siteDomain: row.site_domain ?? void 0,
|
|
3844
|
+
active: row.active ?? true,
|
|
3845
|
+
lastSyncAt: row.last_sync_at ?? void 0
|
|
3846
|
+
};
|
|
3847
|
+
}
|
|
3848
|
+
function createRankLayerProvider() {
|
|
3849
|
+
return {
|
|
3850
|
+
async getIntegration() {
|
|
3851
|
+
const tenantId = getActiveTenantId();
|
|
3852
|
+
if (!tenantId) return null;
|
|
3853
|
+
const { data } = await getClient2().from(INTEGRATIONS).select("*").eq("tenant_id", tenantId).maybeSingle();
|
|
3854
|
+
return data ? toIntegration(data) : null;
|
|
3855
|
+
},
|
|
3856
|
+
async saveIntegration(values) {
|
|
3857
|
+
const tenantId = requireTenantId2();
|
|
3858
|
+
const payload = {
|
|
3859
|
+
tenant_id: tenantId,
|
|
3860
|
+
api_key: values.apiKey,
|
|
3861
|
+
site_domain: values.siteDomain ?? null,
|
|
3862
|
+
active: true,
|
|
3863
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3864
|
+
};
|
|
3865
|
+
const { data, error } = await getClient2().from(INTEGRATIONS).upsert(payload, { onConflict: "tenant_id" }).select("*").single();
|
|
3866
|
+
if (error) throw error;
|
|
3867
|
+
return toIntegration(data);
|
|
3868
|
+
},
|
|
3869
|
+
async disconnect() {
|
|
3870
|
+
const tenantId = getActiveTenantId();
|
|
3871
|
+
if (!tenantId) return;
|
|
3872
|
+
await getClient2().from(INTEGRATIONS).delete().eq("tenant_id", tenantId);
|
|
3873
|
+
},
|
|
3874
|
+
// SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
|
|
3875
|
+
// flow can be exercised. Replace with a real credential check in the PR.
|
|
3876
|
+
async testConnection(values) {
|
|
3877
|
+
if (!values.apiKey || values.apiKey.trim().length < 8) {
|
|
3878
|
+
return { ok: false, message: "Informe uma API Key v\xE1lida da RankLayer." };
|
|
3879
|
+
}
|
|
3880
|
+
return { ok: true, message: "Chave aceita (valida\xE7\xE3o real pendente \u2014 integra\xE7\xE3o em prepara\xE7\xE3o)." };
|
|
3881
|
+
},
|
|
3882
|
+
async getSyncLog(integrationId) {
|
|
3883
|
+
const { data } = await getClient2().from(SYNC_LOG).select("*").eq("integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
|
|
3884
|
+
return (data ?? []).map((r) => ({
|
|
3885
|
+
id: r.id,
|
|
3886
|
+
createdAt: r.created_at,
|
|
3887
|
+
status: r.status,
|
|
3888
|
+
message: r.message ?? void 0,
|
|
3889
|
+
fetched: r.fetched ?? 0,
|
|
3890
|
+
written: r.written ?? 0
|
|
3891
|
+
}));
|
|
3892
|
+
}
|
|
3893
|
+
};
|
|
3894
|
+
}
|
|
3895
|
+
var provider = createRankLayerProvider();
|
|
3896
|
+
function RankLayerExtraPanel() {
|
|
3897
|
+
const [integration, setIntegration] = useState(null);
|
|
3898
|
+
const [log, setLog] = useState([]);
|
|
3899
|
+
const [loaded, setLoaded] = useState(false);
|
|
3900
|
+
useEffect(() => {
|
|
3901
|
+
let cancelled = false;
|
|
3902
|
+
(async () => {
|
|
3903
|
+
try {
|
|
3904
|
+
const found = await provider.getIntegration();
|
|
3905
|
+
if (cancelled) return;
|
|
3906
|
+
setIntegration(found);
|
|
3907
|
+
if (found) setLog(await provider.getSyncLog(found.id));
|
|
3908
|
+
} catch {
|
|
3909
|
+
} finally {
|
|
3910
|
+
if (!cancelled) setLoaded(true);
|
|
3911
|
+
}
|
|
3912
|
+
})();
|
|
3913
|
+
return () => {
|
|
3914
|
+
cancelled = true;
|
|
3915
|
+
};
|
|
3916
|
+
}, []);
|
|
3917
|
+
if (!loaded || !integration) return null;
|
|
3918
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4 border-t pt-4", children: [
|
|
3919
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3 rounded-md border border-dashed p-3", children: [
|
|
3920
|
+
/* @__PURE__ */ jsx(Sparkles, { className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" }),
|
|
3921
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
|
|
3922
|
+
/* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Sincroniza\xE7\xE3o de conte\xFAdo \u2014 em breve" }),
|
|
3923
|
+
/* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
|
|
3924
|
+
"A publica\xE7\xE3o autom\xE1tica de p\xE1ginas e artigos de SEO com a RankLayer est\xE1 em prepara\xE7\xE3o.",
|
|
3925
|
+
integration.siteDomain ? ` Dom\xEDnio configurado: ${integration.siteDomain}.` : "",
|
|
3926
|
+
" Assim que a integra\xE7\xE3o estiver ativa, o hist\xF3rico de sincroniza\xE7\xF5es aparece aqui."
|
|
3927
|
+
] })
|
|
3928
|
+
] })
|
|
3929
|
+
] }),
|
|
3930
|
+
log.length > 0 && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
3931
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
3932
|
+
/* @__PURE__ */ jsx(History, { className: "h-4 w-4 text-muted-foreground" }),
|
|
3933
|
+
/* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Hist\xF3rico" })
|
|
3934
|
+
] }),
|
|
3935
|
+
/* @__PURE__ */ jsx("div", { className: "divide-y rounded-md border text-sm", children: log.map((r) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [
|
|
3936
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: new Date(r.createdAt).toLocaleString("pt-BR") }),
|
|
3937
|
+
/* @__PURE__ */ jsxs("span", { className: "ml-auto text-xs", children: [
|
|
3938
|
+
r.written,
|
|
3939
|
+
"/",
|
|
3940
|
+
r.fetched,
|
|
3941
|
+
" publicadas"
|
|
3942
|
+
] }),
|
|
3943
|
+
r.status === "success" ? /* @__PURE__ */ jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsx(AlertCircle, { className: "h-3.5 w-3.5 text-warning" })
|
|
3944
|
+
] }, r.id)) })
|
|
3945
|
+
] })
|
|
3946
|
+
] });
|
|
3947
|
+
}
|
|
3948
|
+
var ranklayerConnectorDef = {
|
|
3949
|
+
id: "ranklayer",
|
|
3950
|
+
hostPluginId: "marketing",
|
|
3951
|
+
name: "RankLayer",
|
|
3952
|
+
description: "Publica\xE7\xE3o de conte\xFAdo e p\xE1ginas de SEO por servi\xE7o/bairro para ser encontrado no Google e em assistentes de IA.",
|
|
3953
|
+
icon: "Search",
|
|
3954
|
+
authKind: "api-key",
|
|
3955
|
+
fields: [
|
|
3956
|
+
{ key: "apiKey", label: "API Key", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" },
|
|
3957
|
+
{ key: "siteDomain", label: "Dom\xEDnio do site", type: "text", placeholder: "exemplo.com.br" }
|
|
3958
|
+
],
|
|
3959
|
+
async getStatus() {
|
|
3960
|
+
try {
|
|
3961
|
+
const found = await provider.getIntegration();
|
|
3962
|
+
return { connected: !!found?.apiKey, detail: found?.siteDomain };
|
|
3963
|
+
} catch {
|
|
3964
|
+
return { connected: false };
|
|
3965
|
+
}
|
|
3966
|
+
},
|
|
3967
|
+
testConnection: (values) => provider.testConnection({ apiKey: values.apiKey }),
|
|
3968
|
+
saveConnection: async (values) => {
|
|
3969
|
+
await provider.saveIntegration({ apiKey: values.apiKey, siteDomain: values.siteDomain });
|
|
3970
|
+
},
|
|
3971
|
+
disconnect: () => provider.disconnect(),
|
|
3972
|
+
ExtraPanel: RankLayerExtraPanel
|
|
3973
|
+
};
|
|
3494
3974
|
|
|
3495
3975
|
// src/locales/en.ts
|
|
3496
3976
|
var en = {
|
|
@@ -3850,6 +4330,8 @@ var DEFAULT_LABELS = {
|
|
|
3850
4330
|
funnel: "Funnel",
|
|
3851
4331
|
landingPages: "Landing pages",
|
|
3852
4332
|
content: "Content",
|
|
4333
|
+
blog: "Blog",
|
|
4334
|
+
blogCategories: "Categorias",
|
|
3853
4335
|
settings: "Settings"
|
|
3854
4336
|
};
|
|
3855
4337
|
function resolveConfig(options) {
|
|
@@ -3871,9 +4353,9 @@ function createSafeProvider(config, domain) {
|
|
|
3871
4353
|
function createMarketingPlugin(options) {
|
|
3872
4354
|
registerTranslations(marketingLocales);
|
|
3873
4355
|
const { config, domain } = resolveConfig(options);
|
|
3874
|
-
const
|
|
3875
|
-
const store = createMarketingStore(
|
|
3876
|
-
const dashboardWidgets = createMarketingDashboardWidgets({ config, provider, store });
|
|
4356
|
+
const provider2 = options?.dataProvider ?? createSafeProvider(config, domain);
|
|
4357
|
+
const store = createMarketingStore(provider2);
|
|
4358
|
+
const dashboardWidgets = createMarketingDashboardWidgets({ config, provider: provider2, store });
|
|
3877
4359
|
const contentProvider = options?.contentPlanner?.dataProvider ?? createSafeDataProvider(
|
|
3878
4360
|
() => createSupabaseContentPlannerProvider(),
|
|
3879
4361
|
() => createMockContentPlannerProvider()
|
|
@@ -3881,8 +4363,8 @@ function createMarketingPlugin(options) {
|
|
|
3881
4363
|
const contentStore = createContentPlannerStore(contentProvider);
|
|
3882
4364
|
const PageComponent = () => React8.createElement(
|
|
3883
4365
|
MarketingContextProvider,
|
|
3884
|
-
{ config, provider, store },
|
|
3885
|
-
React8.createElement(MarketingPage, { config, provider, store, contentProvider, contentStore })
|
|
4366
|
+
{ config, provider: provider2, store },
|
|
4367
|
+
React8.createElement(MarketingPage, { config, provider: provider2, store, contentProvider, contentStore })
|
|
3886
4368
|
);
|
|
3887
4369
|
PageComponent.displayName = "MarketingPage";
|
|
3888
4370
|
const marketingRegistries = [
|
|
@@ -3891,12 +4373,14 @@ function createMarketingPlugin(options) {
|
|
|
3891
4373
|
];
|
|
3892
4374
|
const SettingsComponent = () => React8.createElement(
|
|
3893
4375
|
MarketingContextProvider,
|
|
3894
|
-
{ config, provider, store },
|
|
4376
|
+
{ config, provider: provider2, store },
|
|
3895
4377
|
React8.createElement(PluginSettingsPanel, {
|
|
3896
4378
|
title: config.labels.settings ?? config.labels.pageTitle,
|
|
3897
4379
|
subtitle: config.labels.pageSubtitle,
|
|
3898
4380
|
generalSettings: React8.createElement(SettingsView),
|
|
3899
4381
|
registries: marketingRegistries,
|
|
4382
|
+
// Enables the "Integrações" tab (renders manifest connectors for this host).
|
|
4383
|
+
hostPluginId: "marketing",
|
|
3900
4384
|
routeBase: "/settings/marketing"
|
|
3901
4385
|
})
|
|
3902
4386
|
);
|
|
@@ -3908,12 +4392,16 @@ function createMarketingPlugin(options) {
|
|
|
3908
4392
|
version: "1.0.0",
|
|
3909
4393
|
scope: options?.scope ?? "universal",
|
|
3910
4394
|
verticalId: options?.verticalId,
|
|
4395
|
+
// Admin content-planning surface (campaigns, content calendar) — targets the
|
|
4396
|
+
// saas/admin Panel world, not the ecommerce storefront/operator console.
|
|
4397
|
+
scaffolds: ["saas"],
|
|
3911
4398
|
defaultEnabled: true,
|
|
3912
4399
|
dependencies: [],
|
|
3913
4400
|
declaredFeatures: [
|
|
3914
4401
|
{ id: "marketing", label: config.labels.pageTitle, group: "Engage" },
|
|
3915
4402
|
...config.modules.landingPages ? [{ id: "marketing.landing-pages", label: config.labels.landingPages, group: "Engage" }] : [],
|
|
3916
|
-
...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : []
|
|
4403
|
+
...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : [],
|
|
4404
|
+
...config.modules.blog ? [{ id: "marketing.blog", label: config.labels.blog, group: "Engage" }] : []
|
|
3917
4405
|
],
|
|
3918
4406
|
navigation: [
|
|
3919
4407
|
{
|
|
@@ -3944,11 +4432,17 @@ function createMarketingPlugin(options) {
|
|
|
3944
4432
|
{ name: "marketing.content.post.updated", description: "A content post was updated" }
|
|
3945
4433
|
],
|
|
3946
4434
|
migrations: [
|
|
4435
|
+
{
|
|
4436
|
+
id: "marketing-000-plg-rename",
|
|
4437
|
+
version: "1.0.0",
|
|
4438
|
+
sql: MIGRATION_000_PLG_RENAME,
|
|
4439
|
+
description: "Rename legacy mkt_* tables to plg_marketing_* in-place for pools provisioned before the industry-pool rename (guarded no-op on fresh pools)"
|
|
4440
|
+
},
|
|
3947
4441
|
{
|
|
3948
4442
|
id: "marketing-001-content-planner",
|
|
3949
4443
|
version: "1.0.0",
|
|
3950
4444
|
sql: MIGRATION_001_CONTENT_PLANNER,
|
|
3951
|
-
description: "Create
|
|
4445
|
+
description: "Create plg_marketing_social_accounts, plg_marketing_content_plans and plg_marketing_content_posts"
|
|
3952
4446
|
},
|
|
3953
4447
|
{
|
|
3954
4448
|
id: "marketing-002-multi-platform",
|
|
@@ -3960,9 +4454,18 @@ function createMarketingPlugin(options) {
|
|
|
3960
4454
|
id: "marketing-003-recording-ops",
|
|
3961
4455
|
version: "1.2.0",
|
|
3962
4456
|
sql: MIGRATION_003_RECORDING_OPS,
|
|
3963
|
-
description: "Recording-day checklist + media asset URL on posts;
|
|
4457
|
+
description: "Recording-day checklist + media asset URL on posts; content-media storage bucket"
|
|
4458
|
+
},
|
|
4459
|
+
{
|
|
4460
|
+
id: "marketing-004-ranklayer",
|
|
4461
|
+
version: "1.3.0",
|
|
4462
|
+
sql: MIGRATION_004_RANKLAYER,
|
|
4463
|
+
description: "RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)"
|
|
3964
4464
|
}
|
|
3965
4465
|
],
|
|
4466
|
+
// RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
|
|
4467
|
+
// (control plane only); the real sync lands via an external PR (RANKLAYER.md).
|
|
4468
|
+
connectors: [ranklayerConnectorDef],
|
|
3966
4469
|
aiTools: [
|
|
3967
4470
|
{
|
|
3968
4471
|
id: "marketing.channel-performance",
|