feeae6eb40
27 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
feeae6eb40 |
feat(campaigns/templates): visible wrapper-expiry date in the email
Two new template variables are auto-derived from r.gift_expires_at at
render time (separately by the worker and the /view fallback to keep
them consistent):
{{expires_at_date}} locale-formatted FR/EN long date — "21 août 2026"
/ "August 21, 2026". Empty when no wrapper token.
{{expires_in_days}} remaining days as string (rounded up). Useful
for tight deadlines where a date is too distant
to convey urgency.
Templates: a small centered badge appears between the CTA button and
the prorata disclaimer, wrapped in a Mustache section so it disappears
cleanly on campaigns that pre-date the wrapper feature.
⏰ Cadeau valide jusqu'au <strong>21 août 2026</strong>
⏰ Gift valid until <strong>August 21, 2026</strong>
Editor merge-tag panel updated so authors can drop these into custom
copy without remembering the exact variable names. The legacy
{{expiry}} field stays — it's still the right tool for promotion-end
dates that don't track the gift link's own deadline.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
d529019106 |
feat(campaigns): gifts inventory page + expiry presets
Wizard: gift_expiry_days now lives behind a preset toggle (15/30/60/90/180 + Custom) instead of a naked number input. Operator clicks a chip; the value flows back into the existing campaign param. Inventory page (/campaigns/gifts): - Cross-campaign view of every wrapper token with status taxonomy (active / redeemed / expired / revoked / pending). Each card on the counters strip is a click-to-filter shortcut. - "Réassignables" highlighted in amber when > 0 — these are gifts whose wrapper expired or was revoked but the Giftbit URL is still unredeemed, ready for a fresh recipient. - Search across name/email/url/token; per-status and per-campaign filter dropdowns. - One-click copy on the Giftbit URL with a tailored toast that walks the operator through the reassignment workflow (paste into manual- add dialog of a new campaign). - Revoke action with confirmation; explicit about what survives (the Giftbit URL stays valid on their side) vs what changes (our wrapper stops redirecting). Backend: - GET /campaigns/gifts flattens every recipient with a gift across every campaign — single-shot, no pagination yet (we're under 10k gifts total). - POST /campaigns/:id/recipients/:row/revoke sets gift_revoked=true and broadcasts the recipient-update SSE event. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
c0ca5feb6f |
feat(campaigns): gift redirect wrapper — own expiry + reusable links
Each campaign recipient now gets a short opaque token (10 base64url chars, ~60 bits entropy). The email contains https://msg.gigafibre.ca/g/<token> which 302-redirects to the underlying Giftbit shortlink — but ONLY if the recipient hasn't passed our own expires_at and we haven't revoked the token. This gives us two new operational capabilities: 1. End-date control independent of Giftbit. The wizard now has a "Expiration interne (jours)" field (default 90) that sets our own deadline. Useful when the Giftbit gift is valid 12 months but the campaign offer should expire in 30 days. 2. Reuse of unredeemed gifts. After our expiry, the old wrapper stops working but the Giftbit URL is still valid on their side. Pasting that same gift_url into a new campaign (via the manual-add dialog) generates a NEW token pointing to the same Giftbit gift — the original recipient's old wrapper URL says "expired", the new recipient gets a fresh window. Per-recipient new fields: - gift_token short ID used in the wrapper URL - gift_expires_at ISO timestamp of our cutoff - gift_revoked manual kill-switch (false by default) - gift_redirected_count clicks that successfully reached Giftbit - gift_first_redirected_at first successful redirect timestamp Routing: - GET /g/:token — public, validates and 302s (or expired-page) - Mailjet click event handler updated to recognise wrapper URLs alongside legacy gft.link/giftbit.com URLs. - /view (browser fallback for in-email rendering) also wraps the gift link so expiry/revoke is honoured consistently. Bootstrap rebuilds the in-memory token→recipient index by scanning all campaign JSONs on startup — no separate index file to keep in sync. CSV report adds gift_token, gift_expires_at, gift_revoked, gift_redirected_count, gift_first_redirected_at. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
1c5241df69 |
fix(campaigns/wizard): template dropdowns now show non-suffixed templates + refresh
Two issues with the per-language template dropdowns: 1. Strict filter — only -fr / -en templates appeared. Anyone naming a template gift-email-test or gift-email-es (no recognized language suffix) saw nothing show up in either dropdown. 2. Loaded once on mount — creating a template in another tab and switching back to a wizard already open kept showing the stale list. Fix: - Templates without a -fr / -en suffix are added to BOTH dropdowns with a "· sans suffixe de langue" tag so they're discoverable but visually distinct from the recommended ones. - Sort: matching-suffix templates first, then alphabetical. - @popup-show triggers a refresh on every dropdown open. - Visible "refresh" icon in the dropdown's append slot for manual triggering without having to close/reopen the popup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
5330fecf43 |
feat(campaigns/wizard): per-language template override
Two new dropdowns in Step 1 ("Template français" / "Template anglais")
populated from /campaigns/templates filtered by suffix (-fr / -en).
Selection is stored on campaign.params.template_fr / .template_en
and the worker resolves the actual path via a new resolveTemplatePath
helper:
1. params.template_<lang> (per-lang override, set here)
2. params.template_path (legacy single-template campaign override)
3. templateForLanguage() (default gift-email-<lang>.html)
Defensive name regex inside resolveTemplatePath blocks path traversal —
operator can pick any *-fr / *-en template that exists, nothing else.
The Step 3 summary list now shows which template will actually ship
per language so the operator can sanity-check before launch.
Use cases: seasonal variants (gift-email-2026-summer-fr), A/B tests,
draft templates that aren't ready to be the default yet.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
9450dd34db |
feat(campaigns): delete campaign from the list
DELETE /campaigns/:id removes the JSON from /opt/targo-hub/data/campaigns/. The Giftbit shortlinks already issued for that campaign live on Giftbit's side and are unaffected — this is purely about clearing internal tracking records (typically test runs cluttering the list). Refuses (409) while the send worker is active for that id so we never yank the file out from under saveCampaign(). Defensive id regex (in campaignPath) blocks path-traversal attempts before unlink runs. UI: red trash icon on each row, disabled while status=sending. Confirmation dialog spells out what survives the deletion (Giftbit links) vs what's lost (tracking, opens/clicks, CSV report) so the operator isn't surprised. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
9fb6fab88e |
feat(campaigns): distinguish gift-CTA click from generic email click
Mailjet's click event includes the actual URL the recipient clicked. We previously bumped every click — CTA button, mailto support, footer link — to status='clicked' indiscriminately. Now we additionally flag clicks on the Giftbit shortlink (matched by r.gift_url prefix, fallback to gft.link or giftbit.com host) as the high-signal "gift_link_clicked" event. Adds: - recipient.gift_link_clicked (bool) + gift_clicked_at (ISO timestamp), set on first matching click; later non-gift clicks don't unset - counters.gift_clicked aggregated alongside existing status counters - "Cadeau cliqué" counter card on detail page (deep-purple, redeem icon) - 🎁 redeem icon next to status chip when the recipient engaged - CSV report: new gift_link_clicked + gift_clicked_at columns Why this matters: "opened" is noisy (Apple Mail Privacy Protection, image proxies prefetch). A click on the CTA is the only reliable indicator that the offer landed and the recipient is engaging. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
10d3745b31 |
feat(campaigns/editor): "Variables" button — visible merge-tag reference
The previous discoverability path was clic-text → floating toolbar → {}
icon, which assumes the user already knows how to invoke Unlayer's merge
tag UI. A direct "Variables" button now opens a dialog listing all 9
placeholders grouped by category (Client / Offre / Système) with their
sample value and a click-to-copy action. Reads from the same mergeTags
config Unlayer consumes — single source of truth, no drift risk.
Banner inside hints at the upcoming CSV-driven custom variable feature.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
bf1253ac58 |
fix(campaigns/list): "Envois" column counted only status=sent
After Mailjet's Event API webhook moves rows from 'sent' to 'opened' or 'clicked', the counters.sent bucket empties and the list page showed 0/N even though every email had successfully landed. Use the same sent+opened+clicked sum as the detail page so the list reflects "emails that left our SMTP" rather than "emails still flagged sent". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
2bc9715485 |
feat(campaigns): CSV report, manual recipients, template polish
Ops UI - CampaignDetailPage: "CSV" button — downloads per-recipient report (shortlinks, status, opened/clicked timestamps, mailjet UUID) - CampaignNewPage: "Saisie manuelle (sans CSV)" on Step 1 and "Ajouter manuellement" on Step 2 — both open the same dialog with firstname / email / gift_url / city / postal_code / language / amount override. Indigo "manuel" chip in the recipients table. - New "Ville" column shows city OR postal_code as fallback. Hub - GET /campaigns/:id/report.csv — RFC 4180 CSV with UTF-8 BOM so Excel auto-detects encoding. 20 columns including new "city". - Worker honours per-recipient amount override: r.amount > derive from r.gift_value_cents > params.amount > "50 $". Fixes manual-add showing campaign default instead of typed value. - Default subject "Un cadeau pour toi" (tutoyer). Templates - Order: Intro → ✅ Option 1 → 🎁 marques → CTA → prorata → ⏭️ Option 2. - New EN intro (manifesto): "Thank you for choosing local. Your support helps keep our community connected. / Because great connections aren't just about fiber — they're about people too." - Amazon logo removed (incongruent with "achat local" framing). - Body paragraphs: text-align justify (greeting/labels stay left). - Support line: "N'hésite pas à nous écrire / Feel free to email us" + dash format 514-448-0773, drop "Support 7j/7" overpromise. - Logo style fix: inline width:32px to beat Unlayer canvas CSS that was rendering brand pills full-width. Ignore template converter .bak-*.json backups. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
a6cd6ee453 | fix(ops/campaigns): v-pre on translation dialog hint to avoid Vue parser crash on {{ '{{...}}' }} | ||
|
|
1b399f65eb |
feat(campaigns): AI template translator via Gemini Flash
New "Traduire (AI)" button in the template editor toolbar. One click
translates the current template's HTML to the opposite language
(detected from the -fr/-en suffix), writing the translated content as
the matching companion template.
Backend (lib/campaigns.js):
- New endpoint: POST /campaigns/templates/:name/translate-to/:targetName
- Reads source .html, calls lib/ai.js aiCall() with Gemini Flash
- System prompt enforces 7 strict preservation rules:
1. Byte-preserve all HTML tags/attributes/styles/Outlook conditionals
2. Don't translate Mustache {{vars}}
3. Preserve URLs/emails/phones/hex colors/CSS/brand names (TARGO,
Gigafibre, Giftbit, Amazon, IGA, Tim Hortons, etc.)
4. Preserve emojis (🎁 ⚡ 🤝 🪂 ✅ ⏭️ ⏰)
5. Keep the warm informal tone (tu in FR, you in EN)
6. Translate only visible text inside elements (paragraphs, buttons,
alt attributes, link text)
7. Output full HTML doc only, no markdown wrapping
- temperature=0.2 for stable output, maxTokens=32768 to fit ~35 KB HTML
- Sanity validates output isn't truncated (>50% of source size)
- Strips defensive markdown fences if AI ignored rule 7
- Auto-backs up existing target before overwrite
- Regenerates Unlayer design JSON from the translated HTML so the
editor can reload the translated template visually
- Requires { override: true } in body to overwrite existing target
(409 Conflict otherwise — protects against accidental clobber)
API client (apps/ops/src/api/campaigns.js):
- translateTemplate(srcName, targetName, { override })
Frontend (TemplateEditorPage.vue):
- "Traduire (AI)" button (purple, icon=translate) in toolbar — disabled
when current template has no -fr/-en suffix
- aiTranslateTargetName computed: detects source lang from suffix,
flips to opposite (-fr → -en, -en → -fr)
- Confirmation dialog:
• Shows source → target template names
• Info banner explaining what's preserved (HTML, vars, brands, emojis)
• Amber banner + toggle if target exists (must confirm override)
- On success: positive notification with byte counts +
"Open" action button to jump to the translated template
- Refreshes templates list after translation so the new file appears
in the selector dropdown
UX: replaces the previous manual translation workflow (where the user
or I had to maintain two parallel templates). One click now does the
whole round-trip. User reviews + adjusts wording in the EN editor if
the AI translation needs polish.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
73e4118901 |
feat(campaigns): create new templates from UI + enable Unlayer template library
Two improvements to the template editor:
1. "+ Nouveau" button + creation dialog
Users can now create new templates from the editor UI without us
re-deploying the hub. Click "Nouveau" next to the template selector,
pick a name + prefix + starter (blank or copy from existing), submit.
The hub PUTs the new template (existing endpoint, no new code needed
on the backend — just relaxed validation).
Form:
• Type (prefix): gift-email / newsletter / transactional
• Name suffix: lowercase letters/digits/dashes (e.g. summer-2026)
• Starter: "Vide" or "Copier depuis <existing template>"
On submit:
• If starter != blank: GET source template's html + design
• PUT new template name with that content
• Refresh templates list + switch editor to the new one
2. Backend: replace hardcoded EDITABLE_TEMPLATES allow-list with
regex-validated prefix matching + disk scan
• EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-',
'transactional-'] — bounds what categories users can create
• TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ — prevents path traversal
• isValidTemplateName() validates both regex + prefix membership
• scanEditableTemplates() returns all matching .html/.mjml files
currently on disk (excludes .bak-* and .legacy-* variants)
• listEditableTemplates() now scans disk instead of a static list,
so newly-created templates appear automatically in the dropdown
3. Enable Unlayer's built-in panels
• templates: true — exposes Unlayer's template library (limited
free-tier selection but ~10-20 starters available without a
projectId)
• stockImages: true — Unsplash search built into image picker
• imageEditor: true — basic crop/resize on inserted images
• undoRedo: true — history navigation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
4acb18c7df |
fix(ops/campaigns): drop loadBlank() call + force explicit editor dimensions
Two bugs from the first prod test of the Unlayer editor:
1. `editor.value.loadBlank is not a function` — the loadBlank() method
exists in newer Unlayer versions but NOT in vue-email-editor 2.2
which wraps an older Unlayer. When no design is stored yet, just let
the editor render its default empty state ("No content here. Drag
content from left.") and show a Quasar notification telling the
user how to start. No explicit load call needed.
2. Editor renders cramped/small — the EmailEditor component's nested
iframe doesn't inherit dimensions from Quasar's q-page wrapper.
Wrap the EmailEditor in an explicit-sized container:
<div style="height: calc(100vh - 60px); width: 100%; overflow: hidden;">
Plus pass style="height: 100%; width: 100%" to the EmailEditor itself.
This gives the editor a full viewport-minus-toolbar canvas.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
9dcd32ef6a |
feat(ops/campaigns): group merge tags by category + add toolbar hint
Improvements to the variable insertion UX in the Unlayer editor:
1. Reorganized mergeTags from a flat object into 3 logical groups so
Unlayer's "Merge Tags" dropdown shows them under sub-headers
instead of a long flat list:
• Client (firstname, lastname, email, description)
• Offre (amount, gift_url, expiry, commitment_months)
• Système (year)
Format switched from { id: {name, value} } to grouped array
format (Unlayer accepts both, but groups give better UX).
2. Added `sample` field to each merge tag — Unlayer renders these
as the visible content while editing, so the canvas shows
"Louis Tremblay" / "60 $" / "https://gft.link/abc" instead of
literal "{{firstname}} {{lastname}}". Makes the live preview
look like real content during edit. Substitution still happens
server-side at send time via Mustache.
3. New toolbar hint button (code icon, grey) explaining where to
find merge tags in the Unlayer UI:
"Insertion : clic dans un texte → barre flottante → icône {}
Merge Tags. Marche aussi dans les champs URL (boutons, images,
mailto)."
This addresses a common discoverability issue: users don't
always realize variables work in URL fields too (e.g. setting
a button's "Action URL" to {{gift_url}} so each recipient gets
their own Giftbit link).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
a11fe5a115 |
feat(ops/campaigns): pivot template editor to Unlayer (vue-email-editor)
After honest acknowledgment that easy-email-standard is abandoned and
limited (Chrome-only, no responsive preview, no AMP, no Unsplash, no
file manager), pivoted to Unlayer's vue-email-editor — a Vue 3 native
component giving all the features the user listed for free (internal
use; a small "Powered by Unlayer" badge shows in the sidebar but NOT
in sent emails).
Why drop MJML alongside:
• MJML was our SERVER-SIDE compilation step because we hand-wrote
templates. With a visual editor that outputs email-safe HTML
directly (responsive media queries, Outlook MSO fallbacks, AMP
where used), the compilation step is redundant.
• One fewer dependency on the hub (mjml package no longer needed).
• One fewer file format to persist (.mjml dropped, only .html
canonical + .json design).
Storage simplification:
Before: .mjml (source) + .html (compiled) + .json (editor state)
After: .html (canonical) + .json (Unlayer design tree)
The hub's send-worker reads .html as before — no changes to send
logic.
Architecture wins:
• Vue 3 native — zero iframe friction, no postMessage choreography
• No separate microservice — easy-email container decommissioned
(docker compose down, code kept under /opt/email-editor/ in case
of rollback)
• DNS editor.gigafibre.ca retained but unused — can be removed via
Cloudflare API cleanup later
• The editor's mergeTags option exposes our {{firstname}}, {{amount}},
{{gift_url}}, etc. in Unlayer's native "Merge tags" panel — same
pattern, more polished UI
• Features now native: responsive preview (mobile/tablet/desktop
breakpoints), Unsplash search, file manager, dark mode, design
history, undo/redo, layers panel, content blocks library
Frontend (TemplateEditorPage.vue):
• Imports EmailEditor from vue-email-editor
• onReady() callback: fetch template + loadDesign() to restore canvas
• saveTemplate(): exportHtml() → PUT { html, design } to hub
• Top bar kept: template selector, saved chip, preview, test-send,
save button
• Removed: iframe-related glue (postMessage listener, iframeKey,
EDITOR_BASE constant, Cmd-S handling that lived in the iframe)
API client (apps/ops/src/api/campaigns.js):
• saveTemplate() now accepts opts.design (Unlayer JSON tree) alongside
content. Legacy opts.format='mjml' still works for backward compat.
Hub (services/targo-hub/lib/campaigns.js):
• GET /campaigns/templates/:name unconditionally returns
{ name, format, html, design } (+ mjml when format=mjml for
legacy templates). The design field is null when no .json file
exists yet.
• PUT /campaigns/templates/:name HTML save path now accepts
body.design alongside body.html and persists both with backups.
• MJML save path (legacy) preserved for any callers using the old
contract.
Container decommissioned on prod: email-editor container stopped +
removed. The Vue editor lives inside the ops SPA, served from
erp.gigafibre.ca/ops as a normal route.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|