diff --git a/apps/ops/package-lock.json b/apps/ops/package-lock.json index 0827aa2..bdf0ec1 100644 --- a/apps/ops/package-lock.json +++ b/apps/ops/package-lock.json @@ -12,6 +12,8 @@ "@twilio/voice-sdk": "^2.18.1", "chart.js": "^4.5.1", "cytoscape": "^3.33.2", + "grapesjs": "^0.22.16", + "grapesjs-preset-newsletter": "^1.0.2", "idb-keyval": "^6.2.1", "lucide-vue-next": "^1.0.0", "pinia": "^2.1.7", @@ -2661,6 +2663,16 @@ "node": ">= 12" } }, + "node_modules/@types/backbone": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.15.tgz", + "integrity": "sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==", + "license": "MIT", + "dependencies": { + "@types/jquery": "*", + "@types/underscore": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2781,6 +2793,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jquery": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.0.tgz", + "integrity": "sha512-Z+to+A2VkaHq1DfI2oSwsoCdhCHMpTSgjWzNcbNlRGYzksDBpPUgEcAL+RQjOBJRaLoEAOHXxqDGBVP+BblBwg==", + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2862,6 +2880,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/underscore": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", + "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3373,6 +3397,26 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/backbone": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.4.1.tgz", + "integrity": "sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==", + "license": "MIT", + "dependencies": { + "underscore": ">=1.8.3" + } + }, + "node_modules/backbone-undo": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/backbone-undo/-/backbone-undo-0.2.6.tgz", + "integrity": "sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "backbone": ">=1.0.0", + "underscore": ">=1.4.4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3870,6 +3914,18 @@ "node": ">=6" } }, + "node_modules/codemirror": { + "version": "5.63.0", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz", + "integrity": "sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==", + "license": "MIT" + }, + "node_modules/codemirror-formatting": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/codemirror-formatting/-/codemirror-formatting-1.0.0.tgz", + "integrity": "sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5770,6 +5826,28 @@ "dev": true, "license": "ISC" }, + "node_modules/grapesjs": { + "version": "0.22.16", + "resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.16.tgz", + "integrity": "sha512-kCfphgpC7pqJPuMYmIhMR6ueyB3+V67isdpMZOvmuGeWDMomkgzqRWOMH3matfdqIJW7LUivHZo9GeyVQAGmLw==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/backbone": "1.4.15", + "backbone": "1.4.1", + "backbone-undo": "0.2.6", + "codemirror": "5.63.0", + "codemirror-formatting": "1.0.0", + "html-entities": "~1.4.0", + "promise-polyfill": "8.3.0", + "underscore": "1.13.8" + } + }, + "node_modules/grapesjs-preset-newsletter": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/grapesjs-preset-newsletter/-/grapesjs-preset-newsletter-1.0.2.tgz", + "integrity": "sha512-z8KJ1ZrTXfASSJZ/tHOcnpcWu4AMr2F/ZfQit+QjimNi3UGowwl7+Yjefuh3R7lbDTrXMMaxhCannCaJo/kPJw==", + "license": "BSD-3-Clause" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -5871,6 +5949,12 @@ "node": ">= 0.4" } }, + "node_modules/html-entities": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", + "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==", + "license": "MIT" + }, "node_modules/html-minifier-terser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", @@ -7687,6 +7771,12 @@ "dev": true, "license": "MIT" }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -9220,6 +9310,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/apps/ops/package.json b/apps/ops/package.json index a246eb1..202153a 100644 --- a/apps/ops/package.json +++ b/apps/ops/package.json @@ -14,6 +14,8 @@ "@twilio/voice-sdk": "^2.18.1", "chart.js": "^4.5.1", "cytoscape": "^3.33.2", + "grapesjs": "^0.22.16", + "grapesjs-preset-newsletter": "^1.0.2", "idb-keyval": "^6.2.1", "lucide-vue-next": "^1.0.0", "pinia": "^2.1.7", diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js new file mode 100644 index 0000000..ddbfd8a --- /dev/null +++ b/apps/ops/src/api/campaigns.js @@ -0,0 +1,105 @@ +/** + * api/campaigns.js — Client for Hub /campaigns endpoints. + * + * Mirrors services/targo-hub/lib/campaigns.js. All gift-campaign requests + * go through the Hub which handles ERPNext auth + Mailjet send + SSE + * progress broadcast. + * + * Functions: + * parseCsvs({ map_csv, giftbit_csv, multi }) → preview matched send list + * createCampaign({ name, params, recipients }) → save + return id + * listCampaigns() → summaries + * getCampaign(id) → full detail + * updateCampaign(id, patch) → edit recipients/params + * sendCampaign(id) → fire background worker + * campaignSseUrl(id) → SSE URL for live updates + */ + +import { HUB_URL } from 'src/config/hub' + +async function hubFetch (path, { method = 'GET', body } = {}) { + const opts = { method, headers: { 'Content-Type': 'application/json' } } + if (body) opts.body = JSON.stringify(body) + const res = await fetch(`${HUB_URL}${path}`, opts) + const text = await res.text() + let data + try { data = text ? JSON.parse(text) : {} } + catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) } + if (!res.ok) { + const msg = data.error || `HTTP ${res.status}` + const err = new Error(msg) + err.status = res.status + throw err + } + return data +} + +export function parseCsvs ({ map_csv, giftbit_csv, multi = 'first' }) { + return hubFetch('/campaigns/parse', { + method: 'POST', + body: { map_csv, giftbit_csv, multi }, + }) +} + +export function createCampaign ({ name, params, recipients }) { + return hubFetch('/campaigns', { + method: 'POST', + body: { name, params, recipients }, + }) +} + +export function listCampaigns () { + return hubFetch('/campaigns').then(r => r.campaigns || []) +} + +export function getCampaign (id) { + return hubFetch(`/campaigns/${encodeURIComponent(id)}`) +} + +export function updateCampaign (id, patch) { + return hubFetch(`/campaigns/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: patch, + }) +} + +export function sendCampaign (id) { + return hubFetch(`/campaigns/${encodeURIComponent(id)}/send`, { + method: 'POST', + }) +} + +// ── Template editing (used by the GrapesJS editor page) ───────────────────── + +export function listTemplates () { + return hubFetch('/campaigns/templates').then(r => r.templates || []) +} + +export function getTemplate (name) { + return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`) +} + +export function saveTemplate (name, html) { + return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, { + method: 'PUT', + body: { html }, + }) +} + +export function previewTemplate (name, { html, vars } = {}) { + return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, { + method: 'POST', + body: { html, vars }, + }) +} + +/** + * Returns the URL for the SSE channel of one campaign. The Hub broadcasts on + * topic `campaign:` so we subscribe to that single topic. Use with: + * const es = new EventSource(campaignSseUrl(id)) + * es.addEventListener('recipient-update', ev => { ... }) + * es.addEventListener('campaign-done', ev => { ... }) + */ +export function campaignSseUrl (id) { + return `${HUB_URL}/sse?topics=campaign:${encodeURIComponent(id)}` +} diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js index d1efb58..137b376 100644 --- a/apps/ops/src/config/nav.js +++ b/apps/ops/src/config/nav.js @@ -7,6 +7,7 @@ export const navItems = [ { path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' }, { path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' }, { path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' }, + { path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' }, { path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' }, ] diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index 4dc4961..c909755 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -123,13 +123,13 @@ import { listDocs } from 'src/api/erp' import { navItems as allNavItems } from 'src/config/nav' import { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, - Settings, LogOut, PanelLeftOpen, PanelLeftClose, + Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, } from 'lucide-vue-next' import ConversationPanel from 'src/components/shared/ConversationPanel.vue' import { useConversations } from 'src/composables/useConversations' import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue' -const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Settings, LogOut, PanelLeftOpen, PanelLeftClose } +const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose } const { panelOpen, activeCount: convCount } = useConversations() function toggleConvPanel () { panelOpen.value = !panelOpen.value } diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue new file mode 100644 index 0000000..e34188c --- /dev/null +++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue @@ -0,0 +1,194 @@ + + + diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue new file mode 100644 index 0000000..741f0b2 --- /dev/null +++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue @@ -0,0 +1,268 @@ + + + diff --git a/apps/ops/src/modules/campaigns/pages/CampaignsListPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignsListPage.vue new file mode 100644 index 0000000..15a88e7 --- /dev/null +++ b/apps/ops/src/modules/campaigns/pages/CampaignsListPage.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue new file mode 100644 index 0000000..672025d --- /dev/null +++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js index 9956b24..3bb20a4 100644 --- a/apps/ops/src/router/index.js +++ b/apps/ops/src/router/index.js @@ -38,6 +38,13 @@ const routes = [ { path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') }, { path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') }, { path: 'network', component: () => import('src/pages/NetworkPage.vue') }, + // Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates + { path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') }, + { path: 'campaigns/new', component: () => import('src/modules/campaigns/pages/CampaignNewPage.vue') }, + // Template editor route must be ABOVE /campaigns/:id otherwise the + // ':id' wildcard captures 'templates/...' and shows the detail page. + { path: 'campaigns/templates/:name?', component: () => import('src/modules/campaigns/pages/TemplateEditorPage.vue'), props: true }, + { path: 'campaigns/:id', component: () => import('src/modules/campaigns/pages/CampaignDetailPage.vue'), props: true }, ], }, ]