From 611f4ed5a664a30821d0ff021ac20b57fcac464d Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 21 May 2026 19:08:04 -0400 Subject: [PATCH] feat(ops/campaigns): UI module for gift campaigns + GrapesJS template editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /campaigns section in the ops SPA, gated by manage_users (proxy until a dedicated manage_campaigns capability is added). Pages (apps/ops/src/modules/campaigns/pages/): - CampaignsListPage: table of all campaigns with status chip + progress bar (sent/total, with fail count), "Nouvelle campagne" + "Éditer le template" buttons. Empty state with onboarding copy. - CampaignNewPage: 3-step Quasar Stepper wizard. Step 1 — upload Map CSV + Giftbit CSV, configure params (name, amount, commitment_months, sender, throttle, multi-email handling). Step 2 — preview the matched send list from POST /campaigns/parse, with counters (matched/unmatched/excluded), per-row match-method chip, and exclude/include toggle. Banner warns when CSVs are mis-aligned (leftover gifts or contacts). Step 3 — confirmation recap with estimated send duration, then fire POST /campaigns + POST /campaigns/:id/send and redirect to the live detail page. - CampaignDetailPage: per-recipient table with status chips updated live via EventSource on the campaign: SSE topic. Counters bar (envoyés / cliqués / queued / échecs / non envoyés), progress bar, per-row customer-link badge with deep-link into /clients/. Auto-subscribes to SSE when status is draft|sending; "Lancer l'envoi" button for draft campaigns. - TemplateEditorPage: GrapesJS-based visual editor for the campaign templates. Three view modes (Visuel / HTML / Aperçu) — the HTML mode is the fallback for our table-heavy hand-crafted template that GrapesJS-preset-newsletter may parse imperfectly. Aperçu mode calls POST /campaigns/templates/:name/preview on the hub for live variable substitution. Custom GrapesJS blocks under "Variables" category for drag-drop insertion of {{firstname}}, {{amount}}, {{gift_url}}, {{description}}, {{expiry}}, {{commitment_months}}. Saves via PUT with hub-side backup of the previous version. Wiring: - api/campaigns.js: hubFetch wrapper, exports parseCsvs / createCampaign / listCampaigns / getCampaign / updateCampaign / sendCampaign + campaignSseUrl(id) for EventSource subscription, + listTemplates / getTemplate / saveTemplate / previewTemplate for the editor. - router/index.js: three new routes under /campaigns. The /campaigns/templates/:name? route is positioned ABOVE /campaigns/:id to prevent the wildcard from catching template paths. - config/nav.js + layouts/MainLayout.vue: "Campagnes" sidebar entry with Lucide Gift icon. - package.json: grapesjs + grapesjs-preset-newsletter dependencies. Co-Authored-By: Claude Opus 4.7 --- apps/ops/package-lock.json | 96 +++++++ apps/ops/package.json | 2 + apps/ops/src/api/campaigns.js | 105 +++++++ apps/ops/src/config/nav.js | 1 + apps/ops/src/layouts/MainLayout.vue | 4 +- .../campaigns/pages/CampaignDetailPage.vue | 194 +++++++++++++ .../campaigns/pages/CampaignNewPage.vue | 268 ++++++++++++++++++ .../campaigns/pages/CampaignsListPage.vue | 88 ++++++ .../campaigns/pages/TemplateEditorPage.vue | 232 +++++++++++++++ apps/ops/src/router/index.js | 7 + 10 files changed, 995 insertions(+), 2 deletions(-) create mode 100644 apps/ops/src/api/campaigns.js create mode 100644 apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue create mode 100644 apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue create mode 100644 apps/ops/src/modules/campaigns/pages/CampaignsListPage.vue create mode 100644 apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue 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 }, ], }, ]