Commit Graph

11 Commits

Author SHA1 Message Date
louispaulb
f9971e9113 feat(ops/campaigns): Phase 2 — switch editor page to easy-email iframe
Replace the broken GrapesJS-mjml integration with an iframe pointing to
the standalone email-editor microservice at editor.gigafibre.ca (created
in Phase 1).

What changed:

- Dropped all grapesjs* imports and ~250 lines of editor init/save/preview
  glue code. That logic now lives in the React app on the other side of
  the iframe.
- Page becomes a thin wrapper:
  • Top bar: back button, template selector, "saved" chip,
    "Aperçu inbox" button, "Envoyer un test" button, reload button.
  • Below: full-height iframe to editor.gigafibre.ca/?name=<template-name>.
- Template switching: bumping iframeKey forces a fresh iframe load so the
  new ?name= param takes effect. Route is updated via router.replace.
- postMessage listener: receives { type: 'email-editor:saved', ts }
  from the editor iframe and shows a positive toast + updates the
  "Sauvegardé · il y a Xs" chip. Origin-checked against EDITOR_BASE.
- Preview dialog: unchanged — fetches compiled HTML from hub's preview
  endpoint and renders in srcdoc iframe.
- Test-send dialog: unchanged from previous version.

Removed (now handled inside the iframe):
- Visual / HTML / Aperçu view-mode toggle (editor.gigafibre.ca handles
  all editing modes natively)
- "Vide" / "Réinitialiser" buttons (editor has its own)
- "Annuler" / "Enregistrer" buttons (editor saves itself on Cmd-S /
  toolbar button)
- spell-check on textarea (editor handles it)
- GrapesJS asset manager wiring (editor will use its own image picker
  in Phase 3)

DNS prerequisite handled separately: editor.gigafibre.ca → 96.125.196.67
created via Cloudflare API (proxied=false to match the existing pattern
that lets Traefik handle Let's Encrypt directly).

Container running on prod via /opt/email-editor/docker-compose.yml,
Traefik routing to Host(`editor.gigafibre.ca`). HTTPS verified live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:01:28 -04:00
louispaulb
79ae38db60 feat(campaigns): MJML canonical templates + test-send button
Two big moves:

1. Promote MJML to the canonical template format
   - Move gift-email-fr-mjml.{mjml,html} → gift-email-fr.{mjml,html}
   - Create gift-email-en.mjml (English translation of FR MJML)
   - Compile EN MJML → gift-email-en.html
   - Remove obsolete variants:
     • gift-email-fr-simple.html (now replaced by MJML)
     • gift-email-en-simple.html (same)
     • gift-email-fr-mjml.* (renamed to canonical)
   - The old gift-email-fr.html (rich-with-merchant-grid version) is
     backed up as gift-email-fr.legacy-rich.html.bak — kept on disk
     for reference but not in the editable list.
   - EDITABLE_TEMPLATES is now just ['gift-email-fr', 'gift-email-en'],
     both backed by .mjml source + .html auto-compiled output.

2. Add "Envoyer un test" feature
   Backend:
   - POST /campaigns/templates/:name/test-send accepts { to, vars,
     from?, subject? }. Reads compiled .html, renders Mustache vars,
     sends via Mailjet through email.sendEmail with X-MJ-CustomID
     "test-send:<name>:<timestamp>" so webhook events for tests are
     identifiable. Returns { sent, to, from, message_id, bytes }.
   - Default vars are sensible: firstname="Louis", amount="60 $",
     gift_url="https://gft.link/TEST123", etc. User overrides any
     via the request body.

   Frontend (TemplateEditorPage):
   - Toolbar button "Envoyer un test" (orange) — opens a dialog.
   - Dialog has email input + subject + 7 variable inputs
     (firstname, lastname, amount, commitment_months, gift_url,
     description, expiry) with sensible defaults.
   - "Dirty" banner warning: if the user has unsaved changes, the
     test will use the LAST SAVED version (so save first to test the
     latest). Mentions explicitly in card footer.
   - On send: live notification with the message_id + byte count.
     Errors surface clearly.

Verified live in prod:
  POST /campaigns/templates/gift-email-fr/test-send → 200, message_id
  returned, ~32 KB rendered MJML→HTML output, sent from
  TARGO <support@targointernet.com> (Mailjet-validated sender).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:36:35 -04:00
louispaulb
b37270c11d feat(campaigns/editor): MJML mode — proper email-focused visual builder
Pivot the template editor toward email-marketing-grade visual editing
by replacing grapesjs-preset-newsletter (permissive HTML, fails to parse
nested table structures) with grapesjs-mjml (the industry-standard
email markup language used by Mailchimp/Sendgrid/Twilio).

Why MJML: it was specifically designed to solve the "visual editor +
email-safe HTML" problem. You write semantic <mj-section>, <mj-column>,
<mj-button>, <mj-image> components — MJML compiles them to the gnarly
email-safe HTML with Outlook fallbacks + responsive media queries
auto-generated. Source is 3x more compact than hand-written HTML and
parses cleanly in visual editors.

Backend (lib/campaigns.js):

- Add `mjml` (v5, async) dependency. Compilation happens server-side
  at SAVE time only; the send-worker reads pre-compiled .html (no
  per-recipient compile cost).
- Each template can now be in 'mjml' or 'html' format. Detection by
  file extension on disk: .mjml present → format='mjml', otherwise
  format='html'. Source of truth for MJML templates = .mjml file;
  .html is the auto-compiled output kept alongside for the send-worker.
- GET /campaigns/templates → returns { name, format, size } per template.
- GET /campaigns/templates/:name → returns { format, mjml?, html }
  (mjml field present only when format=mjml; html always present).
- PUT /campaigns/templates/:name accepts:
    { mjml: "<mjml>..." }  → compile to HTML, save both .mjml + .html
    { html: "..." }        → save .html only (legacy path, unchanged)
  Compilation errors return 400 with details (MJML validation soft mode).
  Both files backed up as .bak-<ts>.<ext> before overwrite.

Frontend (TemplateEditorPage.vue):

- Detect format from API response on load.
- For format='mjml': swap grapesjs-preset-newsletter for grapesjs-mjml
  plugin. Editor's getHtml() returns MJML source (not compiled HTML);
  Save POSTs the MJML, hub compiles + persists both files.
- For format='html': existing behavior unchanged.
- Editor is destroyed + reinitialized when format changes (different
  plugin sets).
- Custom variable blocks ({{firstname}}, {{amount}}, etc.) work for
  both formats — they're text content, format-agnostic.

API client (apps/ops/src/api/campaigns.js):

- saveTemplate(name, content, { format }) routes to the right PUT body
  shape based on format param.

Prototype: gift-email-fr-mjml — full MJML conversion of the simple
variant, ~7.5 KB MJML source compiling to ~32 KB email-safe HTML with
0 validation errors. All 6 Mustache variables preserved through
compilation (firstname, amount, gift_url, description, commitment_months,
year). User compares the MJML editor experience to the existing HTML
templates and decides whether to migrate the others.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:29:42 -04:00
louispaulb
4a4d145465 feat(campaigns/assets): self-hosted image upload + GrapesJS asset manager
Background: existing Mailjet-hosted brand logos in the gift email templates
stay as-is — those URLs are stable and live on Mailjet's CDN. This change
adds infrastructure for ADDITIONAL images the user wants to drop into the
editor going forward (event photos, custom illustrations, technician
photos for service campaigns, etc.) without uploading to Mailjet first.

Why self-hosted: avoids vendor lock-in for new assets, gives us control
over retention + immutable URLs, integrates natively with our GrapesJS
editor's AssetManager. The cost is ~5 MB max per image and one new bind
mount on the hub.

Backend (lib/campaigns.js):

- Storage at services/targo-hub/uploads/ (new bind mount, RW, mounted into
  the container at /app/uploads). Files named by SHA-256 of content for:
  • Automatic dedup (same image twice → same URL, no extra disk)
  • Immutable URLs (content never changes for a given filename)
  • Path-traversal defence (regex-locked filename pattern)

- POST /campaigns/assets/upload — accepts JSON { name, data } where data
  is a data:image/...;base64,... URL. Decodes, validates MIME against
  allow-list (png/jpg/gif/webp/svg), enforces 5 MB cap, hashes, persists,
  returns { url, filename, size, content_type, data: [...] }. The `data`
  array shape matches what GrapesJS' AssetManager expects on upload
  success. Using base64-in-JSON avoids pulling a multipart parser
  dependency — the ~33% encoding overhead is fine for ≤5 MB images.

- GET /campaigns/assets — list all uploaded assets with metadata
  (filename, url, size, modified, content_type).

- GET /campaigns/assets/:hash.<ext> — serve image bytes with
  Content-Type matching the extension + Cache-Control:
  public, max-age=31536000, immutable. The 1-year cache is safe because
  filename = content hash → URL never serves different bytes. Aligns
  with how Gmail's image proxy and Outlook's caching work.

- DELETE /campaigns/assets/:hash.<ext> — admin removal from disk.

- Helpers (persistUpload / readUpload / deleteUpload) live at module
  scope so they can call `path.join` (otherwise shadowed by the `path`
  URL parameter inside handle()).

API client (apps/ops/src/api/campaigns.js):

- listAssets()  → GET /campaigns/assets
- uploadAsset(file) → reads file via FileReader, posts base64 JSON
- deleteAsset(filename) → DELETE the hash-named file

GrapesJS editor (TemplateEditorPage.vue):

- assetManager config with custom uploadFile callback that bypasses
  GrapesJS' built-in multipart uploader. Drag-drop or file-picker
  triggers our base64 upload, on success the URL is added to the
  AssetManager library so it appears in the editor sidebar for reuse.

- onMounted: preload all previously-uploaded assets via listAssets()
  so the user sees their image library immediately when opening the
  editor (no need to re-upload images used in past campaigns).

End-to-end verified live in prod:
  POST /campaigns/assets/upload   → 200 (with data URL JSON body)
  GET  /campaigns/assets          → 200 (list)
  GET  /campaigns/assets/:hash    → 200 (serves PNG bytes)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:53:01 -04:00
louispaulb
d897bcedb4 feat(campaigns): auto-clean first/last names (QC accents + compound split)
The Map CSV migrated from the legacy ERP carries names with two common
defects: missing French accents (Stephane, Andre, Frederic), and
compound first names that were typed without a separator (Marcandre,
Mariejosee, Jeanphilippe). Sending an email "Bonjour stephane," instead
of "Bonjour Stéphane," reads as sloppy automation. Fix both at parse
time so the user sees the corrected names in Step 2 and can override
inline if the auto-cleaner got it wrong.

Backend (lib/campaigns.js):

- FR_NAME_FIXES — 100+ entry dictionary mapping lowercase no-accent
  Québec first names to their canonical accented form (André, Stéphane,
  Frédéric, Geneviève, Hélène, Joséée, etc.). Sourced from MIQ baby
  names + older-generation curation.

- COMPOUND_PARTS — list of common name parts (jean, marie, anne, marc,
  philippe, françois, etc.) that combine into QC compound first names.
  When two parts appear concatenated with no separator, the cleaner
  splits and hyphenates them. Example: "Marcandre" → ["marc","andre"]
  → "Marc-André" (dictionary then applies accent).

- titleCaseToken — proper Title Case respecting apostrophes (O'Brien,
  L'Heureux) and hyphens (Marie-Ève). Uses \p{L} Unicode class so it
  works on accented chars correctly.

- cleanName(raw) — full pipeline: trim → Title Case → dictionary
  lookup per word → compound split fallback. Applied to firstname AND
  lastname in parseMapCsv.

- nameWarning(name) — heuristic flag for cases the cleaner couldn't
  confidently handle: digit in name, single letter, abnormally long
  without separator (likely two stuck names not in COMPOUND_PARTS).
  Returns a short FR description for the UI tooltip.

- parseMapCsv now returns firstname/lastname (cleaned) + firstname_raw/
  lastname_raw (original from CSV) + cleaned_changed bool + name_warnings
  per recipient. UI uses these to show before/after + flags.

UI (CampaignNewPage Step 2):

- New counter card "Noms à vérifier" — count of recipients with at least
  one nameWarning. Only renders if > 0.

- Info banner above the recipients table:
  "X nom(s) auto-corrigés (...)  Y nom(s) suspects (...)"

- Per-row icons in the firstname + lastname columns:
  • ⚠ amber WARNING — cleaner flagged this name as suspicious
    (tooltip shows the reason: "deux prénoms collés", "contient un
    chiffre", etc.)
  •  green AUTO_FIX_HIGH — auto-cleaner changed something at parse
    time (tooltip shows the original raw value)
  Both icons are tooltip-only — no action required.

- Click any name cell → q-popup-edit opens an inline input. Type the
  correction, Enter saves. ESC cancels. This is the manual override
  path for any name the auto-cleaner mishandled.

Tests (manual via end-to-end smoke against prod):
  STEPHANE TREMBLAY     → Stéphane Tremblay     ✓ accent + Title Case
  marie tremblay        → Marie Tremblay        ✓ Title Case only
  Marcandre Boileau     → Marc-André Boileau    ✓ compound + accent
  Jean Francois Lebrun  → Jean François Lebrun  ✓ accent only
  Mariejosee Lapierre   → Marie-Josée Lapierre  ✓ compound + double accent
  Andre LAPRISE         → André Laprise         ✓ both fixed
  Helene St-Pierre      → Hélène St-Pierre      ✓ accent, hyphen preserved
  Frederic O'Brien      → Frédéric O'Brien      ✓ accent, apostrophe preserved

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:33:10 -04:00
louispaulb
2b85735006 fix(ops/campaigns): clarify Step 2 actions + add inline preview + jump-to-editor
User confusion: the "Approuver — 3 à envoyer" button at the end of Step 2
had a send icon, suggesting it fired emails immediately. It actually
just navigated to Step 3 (the confirmation step). The current flow has
two consent moments (Step 2 approve → Step 3 launch) but the UI made
them look like one.

Three changes to address this:

1. Step 2 navigation button:
   - Icon changed from 'send' to 'arrow_forward' — clearly "next step"
   - Label changed from "Approuver — N à envoyer" to "Continuer — N prêts"
   - Added tooltip explaining the send only happens at Step 3

2. Inline preview dialog:
   - New "Aperçu du courriel" button in Step 2 (and Step 3)
   - Opens a maximized dialog with an iframe rendering the actual template
     via POST /campaigns/templates/:name/preview, using the first sendable
     recipient's real data + the campaign params (amount, expiry, etc.)
   - FR/EN toggle inside the dialog so the user can verify both templates
     before launching a mixed-language campaign
   - Defaults to the recipient's own language for first view
   - Non-destructive — fires zero emails

3. Always-accessible "Éditer le template" link:
   - Persistent button in the page header (visible all 3 steps)
   - Plus secondary buttons in Step 2 + Step 3 action rows
   - Opens the template editor in a NEW TAB so the wizard's state
     (uploaded CSVs, parsed recipients) stays intact in the original
     tab — the user can tweak the template, save, switch back, click
     "Aperçu" to see the change, then continue with the send

4. Step 3 confirmation hardening:
   - Banner color escalated from amber to red (this IS the point of no
     return for actual delivery)
   - Wrap the launch button click in a Quasar confirm dialog ("Envoyer
     N courriel(s) maintenant ? Pas annulable.") — adds a third friction
     point against accidental clicks
   - Launch button is red (negative) — visually distinct from the green
     navigation primaries to signal "destructive action ahead"
   - Back-to-Step 2 button renamed "Retour modifier" with arrow_back
     icon for clarity

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:01:40 -04:00
louispaulb
d6096fe1f8 feat(campaigns): apply real TARGO brand + auto-route FR/EN by Customer.language
Brand audit against the official guide (Feb 2026 v1.0) caught several
inconsistencies in the email template:

- Wrong primary green: was #019547, should be #00C853 (Targo Green from
  brand palette). Globally replaced.
- Wrong gradient: was #019547→#06a04d, should be 135deg #00C853→#005026
  (the official Gradient Targo from the brand). Now using Outlook-safe
  background-image + bgcolor fallback for solid green on Outlook desktop.
- Wrong contact info: facturation@targointernet.com / 514 242-1500 →
  support@targo.ca / 514 448-0773 / 1 855 888-2746 (per §11 of guide).
- Wrong website: targointernet.com + gigafibre.ca → www.targo.ca.
- Missing slogan + green dot: footer now ends with the trademark
  tagline "Services de confiance, tout-en-un, près de chez vous." with
  the obligatory green period (always FR — it's the trademark, not a
  marketing line, so stays untranslated in EN template too).
- Missing brand fonts: added Space Grotesk (display) + Plus Jakarta
  Sans (body) via Google Fonts. Wrapped in MSO conditional comments so
  Outlook desktop skips the request and falls back to Helvetica via
  the explicit font-family stack on every element.
- Wrong body bg / text colors: now #F5FAF7 (Muted) / #1B2E24
  (Foreground) per brand semantic palette.
- Wrong info-pill bg: was #f3f4f3 → #F5FAF7 (Muted).
- Added official dark footer band #1C1E26 (Targo Dark) with white
  inverted wordmark, slogan, address, copyright.

Multilang routing (FR/EN):

- lib/campaigns.js matchCustomer now fetches Customer.language
  (14k FR / 1k EN distribution confirmed on prod). Default 'fr' for
  unmatched contacts.
- New templateForLanguage(lang) helper picks gift-email-<lang>.html,
  falls back to FR. Resolves 'fr-CA' → 'fr' etc.
- sendCampaignAsync pre-loads templates per recipient with an in-memory
  cache to avoid re-reading from disk on every send.
- gift-email-en.html created — English translation of the full FR
  template, keeping the slogan in French (it's the trademark tagline).
- year variable now injected (replaces hardcoded © year).

UI (CampaignNewPage):

- New "Langue" column in the Step 2 recipient table. Shows a clickable
  chip (FR primary green / EN blue-grey) that toggles language inline,
  so a campaign manager can override the ERPNext-resolved language
  per recipient.
- Step 3 recap now shows "Répartition par langue: 145 × FR, 12 × EN"
  before confirming the send.

Spell-check:

- TemplateEditorPage HTML mode now has spellcheck="true" + dynamic
  lang attribute on the textarea, picked from the template name suffix
  (gift-email-fr → fr, gift-email-en → en). Browser's native dictionary
  flags typos in real time. AI-grade rewrites deferred to the future
  /campaigns/ai/rewrite endpoint discussed previously.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:50:56 -04:00
louispaulb
8d9e190c21 feat(ops/campaigns): explicit contact↔shortlink pairing review before approve
Step 2 of the new-campaign wizard previously dropped unpaired contacts
silently (Math.min(contacts, gifts) iteration) — if you uploaded 5
contacts and 3 gift links, you got 3 recipients in the table with no
visible signal that 2 contacts were left out. Step 1 only showed
"contacts skipped: N" in a small banner, easy to miss.

Surface the imbalance explicitly so the user can decide before sending:

Backend (POST /campaigns/parse):
- Return unpaired_contacts[] and unused_gifts[] arrays (with row_index
  for source-CSV cross-reference), in addition to the existing
  recipients[]. Old leftover_gifts / leftover_contacts counters kept
  for backward compat.

UI (CampaignNewPage Step 2):
- New columns in the recipients table:
  • # (row index from the source CSVs)
  • Lien-cadeau (truncated shortlink, clickable to verify)
  These let the user eyeball the contact↔link pairing line by line.
- New counter strip:
  Paires / À envoyer / Client lié / Sans client / Sans lien / Liens surplus
- "Sans lien" and "Liens surplus" counters appear only when relevant.
- Explicit warning banner explaining what unpaired/unused means
  (acquire more links and re-upload, or proceed knowing N won't get).
- Expansion panel listing each unpaired contact with their row_index +
  details, so the user can verify which specific contacts will be
  excluded before approving.
- Expansion panel listing each unused gift URL (extra capacity).
- "Approuver" button now shows the exact send count: "Approuver — N à
  envoyer". Disabled when 0. Step 3 recap also reflects sendableCount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:31:44 -04:00
louispaulb
0186a7318e fix(ops/campaigns): correct row counts in Step 1 — Link Order CSV had no header
The Step 1 file-upload widgets displayed `(newlines) - 1` for both CSVs,
assuming both files have a header row to discount. This breaks for the
Giftbit Link Order export which is headerless (one URL per line): a
3-URL file was showing "2 cartes-cadeaux" because the parser ate URL #1
as a fake header.

The backend parser was already correct (detects Link Order vs Campaign
format by inspecting the first line). The bug was UI-only — the count
display reused the same arithmetic for both formats.

Fix: introduce countMapRows / countGiftRows helpers that mirror the
backend's format detection. Map CSV subtracts 2 (preamble + header).
Gift CSV subtracts 0 for Link Order (headerless) or 1 for Campaign
export (with header). Plus a "(format: Link Order)" hint next to the
count so the user sees which detection path was taken.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:27:22 -04:00
louispaulb
ff629a6a85 feat(campaigns): support Giftbit Link Order CSV + add blank-canvas editor mode
Two issues spotted during first real-data test:

1. parseGiftbitCsv only handled the Campaign-export format (header row
   + columns firstname/lastname/email/gift_url/uuid/...). The Link Order
   product Giftbit ships when you pre-buy N links exports a different
   format: headerless, one URL per line. Detect this by checking the
   first non-empty line: if it starts with http(s):// and has no
   comma/pipe/tab separators, treat the whole file as bare URLs. Each
   URL maps to one recipient (row-order matching, same as before).

2. The template editor was hard-coded to load the existing
   gift-email-fr.html into GrapesJS on mount. Hand-crafted email HTML
   with deeply nested tables doesn't parse cleanly into GrapesJS
   components, so the visual canvas often renders blank. Two new
   toolbar actions to address this:

   • "Vide" — clears the canvas to a minimal table-based skeleton.
     For composing brand-new templates from scratch in the visual
     editor without inheriting the existing template's structure.
     Confirms before resetting, then sets dirty=true so the next Save
     overwrites the on-disk template (with hub-side backup).

   • "Réinitialiser" — reloads the last on-disk version, discarding
     any unsaved canvas state. Confirms if dirty.

   Plus an amber banner in visual mode (auto-hidden when blank-canvas
   is active) explaining that Visual mode is for new templates and
   the existing template should be edited in HTML mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:14:29 -04:00
louispaulb
611f4ed5a6 feat(ops/campaigns): UI module for gift campaigns + GrapesJS template editor
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:<id> 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/<id>.
  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 <noreply@anthropic.com>
2026-05-21 19:08:04 -04:00