Compare commits

..

37 Commits

Author SHA1 Message Date
louispaulb
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>
2026-05-22 09:09:07 -04:00
louispaulb
40a2e4e8f2 fix(campaigns/templates): drop 'sans engagement' claim from Option 2
The 'sans engagement ni carte-cadeau' wording in Option 2 was confusing
for customers with an existing multi-month commitment — implies their
subscription is commitment-free, which contradicts their actual contract.

Reworded to make zero claims about the customer's commitment status,
just describes what happens if they ignore the email:

FR:
  before — 'Ne rien faire. Ton abonnement mensuel se poursuit normalement,
            sans engagement ni carte-cadeau.'
  after  — 'Ne rien faire. Aucun changement à ton abonnement actuel.'

EN (Gemini copywriter version was different from earlier templates):
  before — 'Just kick back! Your monthly subscription will continue as
            usual, with no commitment and no gift card.'
  after  — 'Do nothing. No changes to your current subscription.'

Benefits:
  • No false claim about engagement status
  • Shorter, more direct
  • Still preserves the explicit consent UX (customer knows ignoring
    is a valid choice without consequence)
  • No mention of 'no gift card' — that's implicit from not clicking
    the CTA, doesn't need to be stated

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:09:39 -04:00
louispaulb
8df17c823a fix(campaigns/templates): inject inline logos into EN template too
The previous logo injection commit (d76a922) only matched the FR anchor —
EN was missed because Gemini's copywriter-mode translation rewrote
'at hundreds of brands' as 'to spend at hundreds of your favorite stores'.
Patched EN with the correct anchor + 'and more' caption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:03:54 -04:00
louispaulb
d76a922777 feat(campaigns/templates): inline merchant logos via simple <img> sequence
The previous attempt (commit 3f72608) tried to inject a nested <table>
of logos, but the anchor selection logic looked for a </p> that didn't
exist — Unlayer renders the amount line inside a <div>, not <p>. As a
result the logos never made it into the templates.

This commit fixes it with a simpler approach: directly append the logo
images to the existing amount-text string. No table nesting, no anchor
hunting — just plain inline-block <img> tags right after the
"🎁 {{amount}} chez des centaines de marques" text.

Markup pattern (inserted right after the amount line, before the
closing </div>):

  <br><br>
  <span style="display:inline-block;">
    <img src="...amazon..." width="32" alt="Amazon" ...>
    <img src="...timhortons..." width="32" alt="Tim Hortons" ...>
    <img src="...walmart..." width="32" alt="Walmart" ...>
    <img src="...homedepot..." width="32" alt="Home Depot" ...>
    <img src="...iga..." width="32" alt="IGA" ...>
    <img src="...homehardware..." width="32" alt="Home Hardware" ...>
    <span style="font-size:13px;color:#64748B;">et plus</span>
  </span>

Each <img> uses display:inline-block + vertical-align:middle so they
sit on the same horizontal line. width=32 attribute set for Outlook;
height:auto in style preserves aspect ratio. margin-right:6px provides
spacing between logos. Caption ("et plus" / "and more") at the end.

Width math (inside 484px-wide pill): 6 × (32 + 6) = 228 px + caption
~50 px = 278 px. Fits with margin to spare.

EN translation auto-detected the equivalent anchor and inserted
"and more" instead of "et plus".

Live test-send verified for both FR + EN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:00:44 -04:00
louispaulb
3f72608a2f feat(campaigns/templates): inline merchant logos + objective prorata phrasing
Two refinements per user feedback:

1. Objective/factual prorata disclaimer (shorter, conditions-of-service tone)
   FR:
     before — "Si tu annules avant {{commitment_months}} mois, tu rembourses
              seulement au prorata des mois restants."
     after  — "Annulation avant {{commitment_months}} mois : seulement à
              rembourser au prorata des mois restants."
   EN:
     before — "If you cancel before {{commitment_months}} months, you only
              refund the prorated amount for the remaining months."
     after  — "Cancellation before {{commitment_months}} months: only the
              prorated amount for the remaining months is refundable."

   The colon-prefixed structure ("X : Y") reads like a T&C bullet rather
   than a marketing sentence — clearer, less wordy, no subject pronoun.

2. Inline row of 6 merchant logos in the offer info pill
   Inserted between the "60 $ chez des centaines de marques" line and the
   "Instant activation" line. 6 most recognizable QC brands at 32px wide:
     Amazon · Tim Hortons · Walmart · Home Depot · IGA · Home Hardware
   Followed by "et plus" / "and more" caption.

   Uses the existing Mailjet-hosted brand logos (same URLs as the 4×3 grid
   in the older rich variant). 32px width fits comfortably on one line
   (~280px total in a 484px-wide pill). Email-safe single-row table layout
   with vertical-align middle, padding-right 8px for spacing.

   Visual effect: instant recognition for the reader — they see the brands
   they'd actually redeem at, without dropping the full 12-logo grid that
   bloated the previous design.

Applied to .html + .json (both FR + EN) via anchor-based injection:
finds the "🎁 {{amount}} chez/at hundreds of marques/brands" paragraph,
inserts the logo table immediately after its closing </p>. Both files
remain valid + the Unlayer editor will pick up the new table next load.

Verified live via test-send (35-37 KB output, recipient queue ok).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:58:55 -04:00
louispaulb
5327112717 fix(campaigns/templates): clearer prorata phrasing — "mois restants" / "remaining months"
Iterating on the prorata disclaimer per user feedback. The previous
version ("tu rembourses le prorata non utilisé (20 $/mois)") still
read ambiguously — "non utilisé" could mean "the portion you haven't
spent" which is conceptually confusing for a one-time gift card, and
the hardcoded "$20/month" tied the template to the specific
$60/3-month campaign.

New phrasing makes the math explicit: refund only for the months
you're NOT staying.

FR:
  before — "Si tu résilies avant {{commitment_months}} mois,
            tu rembourses le prorata non utilisé (20 $/mois)."
  after  — "Si tu annules avant {{commitment_months}} mois,
            tu rembourses seulement au prorata des mois restants."

EN:
  before — "If you cancel before {{commitment_months}} months,
            you refund the unused pro-rated amount ($20/month)."
  after  — "If you cancel before {{commitment_months}} months,
            you only refund the prorated amount for the remaining months."

Wins:
  • Subject ("tu" / "you") explicit — no ambiguity on who refunds
  • Logic clarified — refund == months NOT STAYED, not "unused
    portion of money" (which doesn't quite map to a one-time gift)
  • Generic over campaign params — no hardcoded "$20/month" so the
    template works at any gift amount + commitment combination
  • "annules" (more common in QC consumer-facing) instead of
    "résilies" (slightly more formal/legal-sounding)

Applied via direct find/replace on .html + .json (FR + EN). Live
test-send queued to confirm rendering.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:50:51 -04:00
louispaulb
f37b1d2803 fix(campaigns/templates): clarify who refunds whom for early cancellation
User feedback: the prorata disclaimer was semantically wrong — both FR
("le prorata du montant est remboursable") and EN ("we'll refund the
pro-rated amount") read as if TARGO would refund the customer, when
actually the customer needs to refund the unused portion of the gift
they received if they cancel within the commitment period.

Plus: add the explicit per-month rate ($20/month at $60 / 3 months) so
the customer knows exactly what they'd owe at any cancellation date.

FR:
  before — "🪂 En cas de départ avant {{commitment_months}} mois,
            le prorata du montant est remboursable."
  after  — "🪂 Si tu résilies avant {{commitment_months}} mois,
            tu rembourses le prorata non utilisé (20 $/mois)."

EN:
  before — "🪂 If you decide to leave before {{commitment_months}}
            months, we'll refund the pro-rated amount."
  after  — "🪂 If you cancel before {{commitment_months}} months,
            you refund the unused pro-rated amount ($20/month)."

Both changes:
  • Subject clarified: customer refunds, not TARGO
  • Added explicit per-month value for transparency
  • Kept warm tone (informal "tu" / "you")
  • Mustache {{commitment_months}} preserved

Applied directly to .html + .json via string substitution (preserves
the Unlayer design tree intact except for that one phrase). The
"$20/month" figure is hardcoded for the current $60/3-month campaign;
a future {{monthly_prorata}} computed variable would generalize but
isn't needed yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:39:01 -04:00
louispaulb
2c47d3269e feat(campaigns/translate): switch from literal to copywriter-mode AI prompt
User feedback: previous prompt produced robotic word-for-word output that
lost the marketing impact. The system prompt was too restrictive on
preservation, suppressing Gemini's natural rephrasing ability.

Rewrote the system prompt with three structural changes:

1. FRAME shifted from "translator" to "senior marketing copywriter"
   The opening line now says "You are NOT translating words — you are
   rewriting marketing copy that lands the same way for a different
   audience." This unlocks idiomatic rephrasing, sentence reorganization,
   active/passive switching, and cultural metaphor adaptation.

2. FEW-SHOT EXAMPLES showing the desired style
   4 FR→EN pairs in the prompt itself, with explicit "NOT: <literal>"
   anti-examples to show what to avoid:
     • "loyauté envers l'achat local" → "keeping it local"
       (not "loyalty to local shopping")
     • "connexions stables et relations durables" →
       "steady connections — both the fiber kind and the human kind"
       (not "stable connections and lasting relationships")
     • "On est juste à côté" → "We're right next door"
     • "Avec l'arrivée de l'été" → "Summer's here"
   These ground Gemini in the brand voice with concrete examples.

3. TONE constraint explicit
   "Warm, conversational, slightly playful. Like a neighbor explaining
   something — never corporate, never stiff." Use of contractions
   ("we're", "you'll") encouraged.

Plus: temperature bumped from 0.2 → 0.7 so Gemini actually exercises
creative rephrasing instead of staying glued to source word order.

Structural preservation rules (HTML, Mustache vars, brand names, emojis,
URLs, technical values like "3.5 Gbit/s") kept as HARD CONSTRAINTS but
clearly separated from the creative freedom on text content.

Live re-translation of gift-email-fr → gift-email-en applied:
  • 51s response time (similar to literal version)
  • 35,934 → 36,067 bytes (slight expansion, normal for EN)
  • Output markers confirm idiomatic phrasing landed:
    "Thanks for keeping it local", "steady connections — both kinds",
    "right next door", "lending a hand", "Summer's here"
  • Mustache vars + brand names + HTML preserved (verified)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:29:16 -04:00
louispaulb
a6cd6ee453 fix(ops/campaigns): v-pre on translation dialog hint to avoid Vue parser crash on {{ '{{...}}' }} 2026-05-22 07:23:35 -04:00
louispaulb
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>
2026-05-22 07:21:45 -04:00
louispaulb
d716e69ef6 feat(campaigns/templates): mirror user's FR edits to EN + drop legacy .mjml
User edited gift-email-fr in the Unlayer editor with richer marketing
copy (loyalty thanks, brand manifesto, 3.5 Gbit/s upsell, helpful CTA).
Mirror those edits to the EN template via a one-shot translation script
so the bilingual pair stays in sync for the next campaign send.

Translation strategy: plain-string find/replace mapping with FR
phrases in longest-first order to avoid partial matches. Applied to
BOTH the rendered .html (what the recipient sees) AND the .json
(Unlayer design tree — so re-opening the EN editor preserves the
matching structure).

Mapping coverage:
  • Intro paragraphs (greeting, gift announcement, loyalty thanks,
    brand manifesto, speed upsell, "we're around the corner")
  • Offer info pill (amount, instant activation, commitment)
  • CTA button labels (Activer → Redeem, Choisir → Pick)
  • Prorata refund disclaimer
  • Option 2 "do nothing" text
  • Signature ("Merci de faire rouler" → "Thanks for helping...thrive")
  • Footer contact info + "Tous droits réservés" → "All rights reserved"
  • <html lang="fr"> → <html lang="en">

23/28 translation rules matched; the 5 unused ones were for legacy
phrasing not present in the user's latest save (e.g. the old "Tu
choisis local" line that was replaced by the current intro).

Also: drop the obsolete .mjml source files. Now that Unlayer is the
canonical editor, the MJML→HTML compile pipeline is no longer used
on save (Unlayer outputs HTML directly). The .mjml files were stale
copies from the previous MJML-based editor. Removed from disk on
prod and from git history; rollback via git revert if needed.

Verified live: GET /campaigns/templates/gift-email-en returns the
translated content (9 EN markers detected in HTML). Test-send to
louis@targo.ca queued via Mailjet for visual QA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:07:05 -04:00
louispaulb
2f1ebae587 fix(hub): templates volume mount must be RW for editor saves
When the Unlayer editor calls PUT /campaigns/templates/:name to save a
design, the hub writes:
  • templates/<name>.html   (compiled email-safe HTML)
  • templates/<name>.json   (Unlayer design tree for editor restore)
  • templates/<name>.bak-<ts>.html  (backup of previous version)

All three need write access to /app/templates inside the container.
The mount was previously declared as :ro, which made these writes
fail with EROFS (read-only filesystem) once the editor was wired up.

Two changes:
  1. Local docker-compose.yml: add ./templates:/app/templates (without
     :ro) and ./uploads:/app/uploads (which was already RW on prod but
     missing from the committed file — local was out of sync).
  2. Prod docker-compose.yml: hot-patched via sed on prod to drop the
     :ro flag, then `docker compose down + up -d` to apply the mount
     change. PUT verified working (returns 200 with size + design_size).

The /app/lib, /app/server.js, /app/public, /app/package.json mounts stay
:ro since the hub never writes to those — keeping the read-only flag
there is defense-in-depth against compromised code overwriting itself.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:49:48 -04:00
louispaulb
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>
2026-05-22 06:39:26 -04:00
louispaulb
448e62177e feat(campaigns): convert existing HTML templates to Unlayer JSON designs
Solve the "editor starts blank" problem by writing a one-time converter
that wraps each compiled .html template into a minimal Unlayer design
JSON (one Custom HTML block containing the entire body content). On
next editor load, Unlayer reads .json and renders the template in the
canvas — instant visual fidelity without manual reconstruction.

Strategy choice: Unlayer's "Import HTML" is a Pro-only feature. Building
a real HTML→Unlayer-blocks parser is several days of work. The minimal-
viable conversion (1 row + 1 Custom HTML block) gets the user 90% there
immediately:

  • Canvas shows the template visually (Unlayer renders the HTML)
  • Variables ({{firstname}}, {{gift_url}}, etc.) preserved as text
  • User can edit the HTML directly via the block's side panel
  • User can incrementally REPLACE the HTML block with native Unlayer
    blocks (Text, Image, Button) for any section they want decomposed —
    on their own schedule, not blocking the campaign send

New file: services/targo-hub/scripts/convert-html-to-unlayer.js
  • CLI: node scripts/convert-html-to-unlayer.js <template-name>
  • Reads templates/<name>.html, extracts <body> inner content, detects
    preheader from a hidden <div style="display:none">, builds Unlayer
    design JSON with brand-appropriate body.values (Targo Green link
    color #00C853, Plus Jakarta Sans font, F5FAF7 page background).
  • Backs up existing .json before overwriting.

Generated outputs (committed):
  templates/gift-email-fr.json — 34 KB (30 KB inner HTML + Unlayer chrome)
  templates/gift-email-en.json — 33 KB

Live verification: GET /campaigns/templates/gift-email-fr now returns
{ design: {...Unlayer JSON...} } alongside html. The editor's
onReady() callback in TemplateEditorPage detects data.design and calls
editor.loadDesign(design) → canvas populated immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:22:47 -04:00
louispaulb
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>
2026-05-22 06:18:27 -04:00
louispaulb
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>
2026-05-22 06:16:16 -04:00
louispaulb
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>
2026-05-22 06:14:06 -04:00
louispaulb
bb88a27b90 feat(email-editor): persist easy-email JSON state for instant restore on reload
Phase 2.5 — close the load/save loop so the editor isn't broken by a page
refresh.

Problem: easy-email doesn't ship an MJML→JSON parser, so loading an
existing MJML template into the editor canvas isn't possible. First-time
load = empty canvas. Without this fix, every page reload would also reset
to empty (even after saving), making the editor useless past one session.

Solution: persist easy-email's raw JSON tree (editor state) as a third
companion file alongside .mjml + .html. Editor reads .json on load when
present, falls back to empty otherwise.

Three files per template now:
  gift-email-fr.mjml   — MJML source (rendered by send-worker → already done)
  gift-email-fr.html   — compiled HTML (cached output, sent to recipients)
  gift-email-fr.json   — easy-email editor state (UI restoration only)

Backend (lib/campaigns.js):
- New templateJsonPath() helper + EDITABLE_TEMPLATES checks
- GET /campaigns/templates/:name returns { format, mjml, html, json }
  when format=mjml (json null until first easy-email save)
- PUT /campaigns/templates/:name now accepts body.json alongside body.mjml
  (writes both .mjml + .html [compiled] + .json [editor state])
- Backup hook extended to also backup .json before overwrite

Editor (EmailEditorApp.tsx):
- On load: prefer data.json → parse and seed initialValues. If json
  missing but mjml present, show explanatory error banner + empty canvas
  (user reconstructs once; that save fixes future loads).
- On save: send BOTH mjml (compiled via JsonToMjml) AND raw values
  object as json. Hub persists all three artifacts.

First UX flow on next user visit:
  1. Open editor → empty canvas + banner "MJML exists but no JSON
     editor-state yet; reconstruct once to save a JSON snapshot"
  2. User drag-drops blocks to rebuild the template visually
  3. Click save → MJML + HTML + JSON all persist
  4. Subsequent reloads load from JSON instantly with full editor state

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:04:48 -04:00
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
0b6377fa58 feat(email-editor): Phase 1 — scaffold easy-email microservice for visual template editing
GrapesJS-mjml proved broken on our content (plugin v1.0.8 incompatible
with MJML v5 — canvas stays empty on load). Pivot to easy-email, a
mature OSS WYSIWYG email editor (MJML-based, MIT license, 4k stars).

Architecture: standalone React+Vite microservice deployed at
editor.gigafibre.ca, iframed from the ops UI's
/campaigns/templates/:name page. Talks to the hub's existing REST
endpoints (/campaigns/templates/*) for load + save. The hub stays the
source of truth — easy-email is purely the editing UI.

Scaffold delivered in this commit (Phase 1):

- services/email-editor/ — new top-level service directory
- package.json: React 18 + easy-email-{core,editor,extensions} 4.16.x
  + Vite 5 + TypeScript 5
- vite.config.ts: standard dev/build config, port 5173 in dev
- tsconfig.json: strict-false to keep iteration fast
- index.html: loads easy-email CSS bundles from unpkg (extensions, editor,
  arco theme)
- src/main.tsx: React entry, mounts EmailEditorApp on #root
- src/EmailEditorApp.tsx:
  • Reads template name from ?name=... URL param (defaults gift-email-fr)
  • GET ${VITE_HUB_URL}/campaigns/templates/:name on mount
  • Renders <EmailEditorProvider> + <StandardLayout> with our merge tags
    map (firstname, amount, gift_url, description, expiry, etc.) so the
    Variables panel shows our Mustache placeholders
  • On save: JsonToMjml() converts easy-email's JSON → MJML, PUT to hub
    → hub compiles to HTML and persists both files
  • postMessage({type: 'email-editor:saved', ...}) to parent window so
    the iframing ops UI knows to refresh
- Dockerfile: multi-stage (Vite build → nginx alpine serve). SPA fallback
  in nginx config so all routes return index.html.
- docker-compose.yml: container behind Traefik at editor.gigafibre.ca
  with Let's Encrypt TLS via the shared proxy network.
- README.md documents the arch, URL params, postMessage protocol, dev
  workflow, and the Phase 1 limitation (no MJML→JSON importer — editor
  starts from empty page until Phase 3).
- .gitignore: standard node/vite/dist exclusions.

Build verified locally: 83 modules transformed, ~2.8 MB bundle (840 KB
gzipped) — large but acceptable since easy-email packages the full
email builder + drag-drop canvas.

Phase 2 (next): Docker deploy on prod + replace GrapesJS in the ops UI
TemplateEditorPage with an iframe pointing here.
Phase 3 (later): MJML → easy-email JSON parser so existing templates
auto-import into the canvas instead of starting blank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:52:31 -04:00
louispaulb
2fe8d3f50e feat(campaigns/templates): richer 4-block intro (greeting, hook, gift, upsell)
Expanded the email intro from 3 short paragraphs into 4 semantic blocks,
restoring the marketing-friendly "Tu choisis local..." line that earlier
edits had dropped, plus adding new content about the 3.5 Gbit/s plans
and a "we're right around the corner" CTA framing.

FR intro structure now:
  1. "Bonjour {{firstname}},"
  2. "Tu choisis local, on veut te remercier. / Comme toi, on aime les
     connexions stables et les relations durables." (paired manifesto)
  3. "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible
     pour un temps limité."
  4. "Nous offrons maintenant de nouveaux forfaits, jusqu'à 3.5 Gbit/s.
     Que tu souhaites plus de vitesse, battre une autre offre ou juste
     nous jaser, on est juste à côté."

EN translation mirrors the same 4-block structure.

Editorial rationale for block grouping in MJML:
- Each block is its own <mj-text> for independent drag-drop in GrapesJS
- Lines that always travel together (manifesto pair, upsell + CTA pair)
  share one <mj-text> joined with <br/> to reduce component clutter
- Different styles per block (greeting smaller/secondary, manifesto
  larger/bolder, body paragraphs normal) require separate <mj-text>
  components anyway since MJML inherits styling per-block

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:44:16 -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
1af8b3a029 feat(campaigns/templates): add gift-email-{fr,en}-simple variants
Flat single-table-per-section structure (max 1 level of nesting) so that
GrapesJS' preset-newsletter parser can recognize each section as an
editable component. Same brand visuals + content as the rich variants,
but: dropped the 12-logo merchant grid (heaviest part for the editor),
compacted the three info pills into one consolidated card.

Sections (top-level <table width="600">):
  1. Header logo
  2. Greeting + brand-line + offer intro
  3. Compact info card (was 3 pills)
  4. Option 1 chip
  5. Big green CTA button
  6. Prorata refund disclaimer
  7. Option 2 chip + text
  8. Optional expiry notice (Mustache conditional)
  9. Signature
 10. Contact info (outside card)
 11. Dark footer band (logo + address + copyright)

Each section is a standalone <table role="presentation" width="600">
sharing the same #ffffff background. The first and last get the rounded
border-radius, middle sections have no rounding. Result: visually one
unified card, structurally many editable blocks.

Registered both new variants in EDITABLE_TEMPLATES whitelist so the
ops UI editor picks them up. Rich variants gift-email-fr.html and
gift-email-en.html are unchanged — both styles coexist. User compares
in the editor and picks which to standardize on per campaign.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:04:39 -04:00
louispaulb
bbd2b31761 feat(campaigns/templates): new opening line + logo image in dark footer
Per user feedback after seeing the rendered preview:

1. Opening line replaced:
   FR: "Tu choisis local, on veut te remercier." →
       "Comme toi, on aime les connexions stables et les relations durables."
   EN: "You went local — we want to say thanks." →
       "Just like you, we love stable connections and lasting relationships."
   The new line ties the Internet service (stable connections) to the
   relationship framing (lasting), which reads more naturally than the
   previous "we want to thank you" phrasing.

2. Dark footer band cleanup:
   • Removed the CSS-styled TARGO. wordmark (with green dot)
   • Removed the official slogan line "Services de confiance, ..."
   • Replaced with the actual TARGO logo image (img tag at 120px wide)
   The wordmark is now ALWAYS the logo image, never a text styling —
   keeps the brand mark consistent across header and footer.

TODO marker left in the HTML pointing to the white-variant logo: the
brand guide §1 specifies targo-logo-white.svg for dark backgrounds, but
we only have the green variant uploaded on Mailjet (UUID eed4d18c-...).
The green logo on the #1C1E26 Targo Dark bg is readable but not
pixel-perfect with the brand. To fix, upload the white variant via the
new /campaigns/assets/upload endpoint and swap the src in both
templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:56:17 -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
d694d889a1 feat(campaigns/templates): replace placehold.co with real Mailjet logos for rows 2-3
User pasted the full HTML block from their Mailjet Passport editor —
extracted the 8 missing CDN URLs for the merchant grid bottom rows and
swapped them into both FR and EN templates.

Final 12-logo grid is now 100% real Mailjet-hosted assets matching the
user's brand-approved visuals (no more placehold.co rectangles):

  Row 1: Amazon, IGA, Tim Hortons, $1 Plus           (already real)
  Row 2: Pizza Pizza, Home Depot, Best Buy, Walmart  (NEW)
  Row 3: Petro-Canada, Esso, Home Hardware, Sobeys   (NEW)

URL pattern: https://xqy3m.mjt.lu/img2/xqy3m/<UUID>/content
Width normalized to 95px (consistent with row 1) instead of the source
template's 300px since our 600px-wide email card means each 25% column
is ~140px effective — 95px image fits with proper margins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:36:51 -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
0f78fbe27e fix(hub/campaigns): move /templates routes above the /:id wildcard
The /campaigns/:id GET handler uses a wildcard regex /^\/campaigns\/([^/]+)$/
which captures "templates" as a fake campaign id and returns 404 before the
fixed /campaigns/templates routes get a chance to match.

Reorder the handle() chain so the fixed paths (/templates, /webhook) come
first, then the wildcard :id routes. Add a comment block calling out the
ordering requirement so future endpoints don't reintroduce the bug.

Verified live: GET /campaigns/templates returns the editable list,
GET /campaigns/templates/gift-email-fr still returns the HTML.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:15:04 -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
louispaulb
5d763f12ff feat(hub): gift-campaign module — CSV parse, customer match, async send + webhook
- lib/campaigns.js (new): full backend for the gift campaign flow.
  • Two CSV parsers: parseMapCsv handles the pipe-delimited legacy export
    with title preamble; parseGiftbitCsv auto-detects the URL column.
  • Multi-strategy customer match against ERPNext: email → phone → civic
    + postal_code on Service Location. Returns confidence score (1.0 /
    0.9 / 0.8) and match method. Addresses the 25%-match limitation of
    the legacy_delivery_id approach by fanning out to address-based
    lookup when email/phone miss.
  • Storage: JSON files at data/campaigns/<id>.json with embedded
    recipients array. Counters auto-recomputed from recipient statuses
    on every save (single source of truth).
  • Async send worker: setImmediate fire-and-forget loop, throttle
    configurable, broadcasts recipient-update events over SSE topic
    campaign:<id> for live UI progress.
  • Mailjet webhook handler at POST /campaigns/webhook: matches events
    to recipients via X-MJ-CustomID = "<campaign-id>:<recipient-index>"
    for O(1) lookup, falls back to MessageID scan if CustomID absent.
  • Template CRUD endpoints (GET/PUT /campaigns/templates/:name) with
    automatic timestamped backups before overwrite. Path-traversal
    guarded by an allow-list (only gift-email-fr editable).
  • Mustache section renderer ({{#var}}...{{/var}}) shared with the CLI.

- lib/email.js: accept opts.from override (campaign sender differs from
  default MAIL_FROM) and opts.headers passthrough (needed for the
  X-MJ-CustomID header that drives webhook → recipient correlation).
  Return the nodemailer info object on success instead of a bare bool so
  callers can capture info.messageId — legacy truthy checks still work.

- server.js: register /campaigns/* route on the hub router.

- templates/gift-email-fr.html: bundled copy of the campaign template
  inside the hub so it's deployable without scripts/ on the path. Kept
  in sync manually with scripts/campaigns/templates/gift-email-fr.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:07:40 -04:00
louispaulb
9f2b37939d feat(campaigns): TARGO rebrand + Mustache sections + Mailjet webhook setup
- Template gift-email-fr.html: switch from Gigafibre indigo to TARGO green
  (#019547), use real Mailjet-hosted TARGO logo, adopt retention-offer
  layout from the latest mockup (tutoiement, Option 1/Option 2 split,
  prorata-refund disclaimer, "L'équipe TARGO" signature). Row 1 of the
  merchant grid uses real Mailjet logos (Amazon, IGA, Tim Hortons, $1
  Plus); rows 2-3 are placehold.co until URLs are shared.

- send_gift_campaign.js: add {{#var}}...{{/var}} Mustache section support
  to the renderer so the optional expiry block disappears cleanly when
  --expiry is omitted (was rendering literal tags before). Add new
  --commitment-months CLI flag (default 3) for the "Rester encore X mois
  ou +" wording.

- setup_mailjet_webhook.js (new): one-shot Node script to register the
  Hub callback URL with Mailjet's /v3/REST/eventcallbackurl. Defaults
  to a safe event subset (open/click/spam/unsub) that doesn't conflict
  with the WP-Mail-SMTP integration already owning sent/bounce/blocked.
  --all forces full takeover with a conflict guard requiring
  --force-takeover to overwrite existing records. Supports --list and
  --delete for inspection / rollback.

- package.json (new): nodemailer dependency for SMTP send.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:07:20 -04:00
43 changed files with 14898 additions and 107 deletions

4
.gitignore vendored
View File

@ -43,3 +43,7 @@ scripts/migration/ref_invoice.pdf
# Playwright snapshots # Playwright snapshots
.playwright-mcp/ .playwright-mcp/
# Auto-generated backups from scripts/convert-html-to-unlayer.js
services/targo-hub/templates/*.bak-*.json
services/targo-hub/templates/*.bak-*.html

View File

@ -12,6 +12,9 @@
"@twilio/voice-sdk": "^2.18.1", "@twilio/voice-sdk": "^2.18.1",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cytoscape": "^3.33.2", "cytoscape": "^3.33.2",
"grapesjs": "^0.22.16",
"grapesjs-mjml": "^1.0.8",
"grapesjs-preset-newsletter": "^1.0.2",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
@ -19,6 +22,7 @@
"sip.js": "^0.21.2", "sip.js": "^0.21.2",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-email-editor": "^2.2.0",
"vue-router": "^4.3.0", "vue-router": "^4.3.0",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
@ -2661,6 +2665,16 @@
"node": ">= 12" "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": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@ -2781,6 +2795,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -2788,6 +2808,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/mjml": {
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/@types/mjml/-/mjml-4.7.4.tgz",
"integrity": "sha512-vyi1vzWgMzFMwZY7GSZYX0GU0dmtC8vLHwpgk+NWmwbwRSrlieVyJ9sn5elodwUfklJM7yGl0zQeet1brKTWaQ==",
"license": "MIT",
"dependencies": {
"@types/mjml-core": "*"
}
},
"node_modules/@types/mjml-core": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-5.0.0.tgz",
"integrity": "sha512-E1Rho2ZfVEqZekQoESDuPAw7C3MrzdUvS6YAiEPGdhQQqAchMXfdChXlSi6ly9YhZgUP026ujrRlEGJn9o/zAg==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
@ -2862,6 +2897,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@ -2869,6 +2910,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/@unlayer/types": {
"version": "1.413.0",
"resolved": "https://registry.npmjs.org/@unlayer/types/-/types-1.413.0.tgz",
"integrity": "sha512-pOE9lKvP7ofnmfWZN+PTizw2GrwZNtePiMH3Yl8OSt/nYQL52X7N4SHd7dDd2c7ecJwWVWo8MfPY8QTon+44lw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "2.3.4", "version": "2.3.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.3.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.3.4.tgz",
@ -3373,6 +3420,26 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -3870,6 +3937,18 @@
"node": ">=6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5770,6 +5849,38 @@
"dev": true, "dev": true,
"license": "ISC" "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-mjml": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/grapesjs-mjml/-/grapesjs-mjml-1.0.8.tgz",
"integrity": "sha512-cgaKwuGcBVgFCyqqK39kraPfw5DiA8qIyHKsDoMpOUqPY+ubMznx6U2M1NC9UKwD+gDCy/VVctyhb/aJAgyPNw==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/mjml": "^4.7.4",
"mjml-browser": "^4.18.0"
}
},
"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": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@ -5871,6 +5982,12 @@
"node": ">= 0.4" "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": { "node_modules/html-minifier-terser": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
@ -7174,6 +7291,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/mjml-browser": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/mjml-browser/-/mjml-browser-4.18.0.tgz",
"integrity": "sha512-Y3kpr3IFBk3Wm7AwONZ5vDAX7FxAaMk+RKbcKKewsuGI9oNCOSM2dWNcWVFdzZ9PF7awoaCgBSQudnJaJbUiBA==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -7687,6 +7810,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -9220,6 +9349,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/undici-types": {
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@ -9478,6 +9613,17 @@
} }
} }
}, },
"node_modules/vue-email-editor": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/vue-email-editor/-/vue-email-editor-2.2.0.tgz",
"integrity": "sha512-aEXm0OHjZgeQqGsssfukqJm7kubfGBOPo9ddwGHMXLbzegJDZ0ou2h7NmRvPR+XaoRGYHdZXf9p7zVae5ACgWA==",
"dependencies": {
"@unlayer/types": "^1.394.0"
},
"peerDependencies": {
"vue": "^3.2.13"
}
},
"node_modules/vue-eslint-parser": { "node_modules/vue-eslint-parser": {
"version": "9.4.3", "version": "9.4.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",

View File

@ -14,6 +14,9 @@
"@twilio/voice-sdk": "^2.18.1", "@twilio/voice-sdk": "^2.18.1",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cytoscape": "^3.33.2", "cytoscape": "^3.33.2",
"grapesjs": "^0.22.16",
"grapesjs-mjml": "^1.0.8",
"grapesjs-preset-newsletter": "^1.0.2",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
@ -21,6 +24,7 @@
"sip.js": "^0.21.2", "sip.js": "^0.21.2",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-email-editor": "^2.2.0",
"vue-router": "^4.3.0", "vue-router": "^4.3.0",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },

View File

@ -0,0 +1,165 @@
/**
* 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',
})
}
// Build the URL the browser hits to download the per-recipient CSV report
// (giftbit shortlink ↔ email ↔ name ↔ status). The hub serves it with the
// proper Content-Disposition so an <a download> click triggers a save.
export function campaignReportCsvUrl (id) {
return `${HUB_URL}/campaigns/${encodeURIComponent(id)}/report.csv`
}
// ── Image assets (self-hosted on the hub, for GrapesJS asset manager) ───────
export function listAssets () {
return hubFetch('/campaigns/assets').then(r => r.assets || [])
}
// Upload a File / Blob from the browser via base64-encoded JSON. Bypasses
// multipart parsing on the hub side (zero new deps) at the cost of ~33%
// payload overhead. Acceptable for the ≤5 MB images we permit.
export async function uploadAsset (file) {
const dataUrl = await new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result)
r.onerror = () => reject(new Error('FileReader failed'))
r.readAsDataURL(file)
})
return hubFetch('/campaigns/assets/upload', {
method: 'POST',
body: { name: file.name, data: dataUrl },
})
}
export function deleteAsset (filename) {
return hubFetch(`/campaigns/assets/${encodeURIComponent(filename)}`, {
method: 'DELETE',
})
}
// ── 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)}`)
}
// saveTemplate(name, content, opts) — content is HTML by default.
// Optional opts.design = Unlayer design JSON (persisted alongside HTML so the
// editor can re-load the visual state on next open).
// Legacy opts.format = 'mjml' still supported for older callers (sends mjml).
export function saveTemplate (name, content, { format = 'html', design = null } = {}) {
const body = format === 'mjml' ? { mjml: content } : { html: content }
if (design) body.design = design
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, {
method: 'PUT',
body,
})
}
export function previewTemplate (name, { html, vars } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, {
method: 'POST',
body: { html, vars },
})
}
// Translate the source template to a target language via Gemini.
// targetName must match the source's prefix (e.g. gift-email-fr → gift-email-en).
// override=true required if the target already exists.
export function translateTemplate (srcName, targetName, { override = false } = {}) {
return hubFetch(
`/campaigns/templates/${encodeURIComponent(srcName)}/translate-to/${encodeURIComponent(targetName)}`,
{ method: 'POST', body: { override } },
)
}
// Send ONE rendered email to a specific address for visual QA.
// Pass { to, vars, from?, subject? } — defaults filled in server-side.
export function testSendTemplate (name, { to, vars, from, subject } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
method: 'POST',
body: { to, vars, from, subject },
})
}
/**
* Returns the URL for the SSE channel of one campaign. The Hub broadcasts on
* topic `campaign:<id>` 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)}`
}

View File

@ -7,6 +7,7 @@ export const navItems = [
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' }, { path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' }, { path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' }, { 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' }, { path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
] ]

View File

@ -123,13 +123,13 @@ import { listDocs } from 'src/api/erp'
import { navItems as allNavItems } from 'src/config/nav' import { navItems as allNavItems } from 'src/config/nav'
import { import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
Settings, LogOut, PanelLeftOpen, PanelLeftClose, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import ConversationPanel from 'src/components/shared/ConversationPanel.vue' import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
import { useConversations } from 'src/composables/useConversations' import { useConversations } from 'src/composables/useConversations'
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue' 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() const { panelOpen, activeCount: convCount } = useConversations()
function toggleConvPanel () { panelOpen.value = !panelOpen.value } function toggleConvPanel () { panelOpen.value = !panelOpen.value }

View File

@ -0,0 +1,203 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<div class="text-h5">{{ campaign?.name || id }}</div>
<q-chip dense class="q-ml-md" :color="statusColor(campaign?.status)" text-color="white" :label="statusLabel(campaign?.status)" />
<q-space />
<q-btn
v-if="campaign?.recipients?.length"
flat dense icon="file_download" label="CSV" class="q-mr-sm"
:href="reportCsvUrl" download
>
<q-tooltip>Télécharger le rapport (shortlinks Giftbit, emails, statuts d'envoi)</q-tooltip>
</q-btn>
<q-btn v-if="campaign?.status === 'draft'" unelevated color="primary" icon="send" label="Lancer l'envoi"
:loading="resending" @click="relaunch" />
</div>
<!-- Counters bar -->
<div class="row q-col-gutter-sm q-mb-md" v-if="campaign">
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5">{{ campaign.counters?.total || campaign.recipients?.length || 0 }}</div>
<div class="text-caption text-grey-7">Total</div>
</q-card-section></q-card>
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5 text-positive">{{ counterFor('sent') + counterFor('opened') + counterFor('clicked') }}</div>
<div class="text-caption text-grey-7">Envoyés</div>
</q-card-section></q-card>
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5 text-blue">{{ counterFor('clicked') }}</div>
<div class="text-caption text-grey-7">Cliqués</div>
</q-card-section></q-card>
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5 text-orange">{{ counterFor('queued') }}</div>
<div class="text-caption text-grey-7">En attente</div>
</q-card-section></q-card>
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5 text-negative">{{ counterFor('failed') + counterFor('bounced') }}</div>
<div class="text-caption text-grey-7">Échecs</div>
</q-card-section></q-card>
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5 text-grey-7">{{ counterFor('pending') }}</div>
<div class="text-caption text-grey-7">Non envoyés</div>
</q-card-section></q-card>
</div>
<q-linear-progress
v-if="campaign && (campaign.status === 'sending' || campaign.status === 'completed')"
:value="sentRatio" :color="campaign.counters?.failed ? 'orange' : 'positive'" size="8px" class="q-mb-md"
/>
<q-table
v-if="campaign"
:rows="campaign.recipients || []"
:columns="columns" row-key="email"
flat bordered dense
:pagination="{ rowsPerPage: 50 }"
:rows-per-page-options="[25, 50, 100, 0]"
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
</q-td>
</template>
<template v-slot:body-cell-customer="props">
<q-td :props="props">
<span v-if="props.row.customer_id">
<q-icon name="person" size="14px" class="q-mr-xs" />
<a :href="`/#/clients/${props.row.customer_id}`" target="_blank" style="color:var(--q-primary)">
{{ props.row.customer_name || props.row.customer_id }}
</a>
<q-chip dense size="xs" outline class="q-ml-xs">{{ props.row.match_method }}</q-chip>
</span>
<span v-else class="text-grey-6"></span>
</q-td>
</template>
<template v-slot:body-cell-gift_url="props">
<q-td :props="props">
<a :href="props.row.gift_url" target="_blank" class="text-grey-7" style="font-family:monospace; font-size:0.78rem">
{{ shortLink(props.row.gift_url) }}
</a>
</q-td>
</template>
<template v-slot:body-cell-error="props">
<q-td :props="props">
<span v-if="props.row.error" class="text-negative text-caption">{{ props.row.error }}</span>
</q-td>
</template>
</q-table>
<div v-if="!campaign && !loading" class="text-center q-pa-xl text-grey-7">
<q-icon name="error_outline" size="48px" />
<div class="text-h6 q-mt-md">Campagne introuvable</div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { getCampaign, sendCampaign, campaignSseUrl, campaignReportCsvUrl } from 'src/api/campaigns'
const route = useRoute()
const $q = useQuasar()
const id = route.params.id
const campaign = ref(null)
const loading = ref(true)
const resending = ref(false)
let es = null
const columns = [
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
{ name: 'customer', label: 'Client lié', field: 'customer_name', align: 'left' },
{ name: 'gift_url', label: 'Shortlink', field: 'gift_url', align: 'left' },
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
{ name: 'error', label: 'Erreur', field: 'error', align: 'left' },
]
function counterFor (s) { return campaign.value?.counters?.[s] || 0 }
function statusColor (s) {
return {
pending: 'grey-5', queued: 'orange', sent: 'positive', opened: 'positive',
clicked: 'blue', failed: 'negative', bounced: 'negative',
draft: 'grey', sending: 'orange', completed: 'positive',
}[s] || 'grey-5'
}
function statusLabel (s) {
return {
pending: 'En attente', queued: 'En file', sent: 'Envoyé', opened: 'Ouvert',
clicked: 'Cliqué', failed: 'Échec', bounced: 'Rejeté',
draft: 'Brouillon', sending: 'En cours', completed: 'Terminée',
}[s] || s
}
function shortLink (u) { return (u || '').replace(/^https?:\/\//, '').slice(0, 28) + ((u || '').length > 35 ? '…' : '') }
const reportCsvUrl = computed(() => campaignReportCsvUrl(id))
const sentRatio = computed(() => {
const total = campaign.value?.counters?.total || 1
const done = counterFor('sent') + counterFor('opened') + counterFor('clicked')
+ counterFor('failed') + counterFor('bounced')
return Math.min(1, done / total)
})
async function load () {
loading.value = true
try { campaign.value = await getCampaign(id) }
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
finally { loading.value = false }
}
function subscribeSse () {
if (es) es.close()
es = new EventSource(campaignSseUrl(id))
es.addEventListener('recipient-update', (ev) => {
const data = JSON.parse(ev.data)
if (!campaign.value?.recipients) return
// Apply patch by index; counters will be re-rendered from recipients next refresh
if (campaign.value.recipients[data.i]) {
Object.assign(campaign.value.recipients[data.i], data.recipient)
// Recompute counters in-place for live update
const counters = { total: campaign.value.recipients.length }
for (const r of campaign.value.recipients) counters[r.status] = (counters[r.status] || 0) + 1
campaign.value.counters = counters
}
})
es.addEventListener('campaign-done', () => {
$q.notify({ type: 'positive', message: 'Campagne terminée' })
load()
})
es.addEventListener('campaign-status', (ev) => {
const data = JSON.parse(ev.data)
if (campaign.value) campaign.value.status = data.status
})
}
async function relaunch () {
resending.value = true
try {
await sendCampaign(id)
await load()
subscribeSse()
} catch (e) {
$q.notify({ type: 'negative', message: e.message })
} finally {
resending.value = false
}
}
onMounted(async () => {
await load()
// Auto-subscribe to SSE if still running (or about to run)
if (campaign.value && ['draft','sending'].includes(campaign.value.status)) {
subscribeSse()
}
})
onBeforeUnmount(() => { if (es) es.close() })
</script>

View File

@ -0,0 +1,772 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<div class="text-h5">Nouvelle campagne</div>
<q-space />
<!-- Always-accessible jump-to-editor link. Opens in new tab so the
wizard's uploaded files + parsed recipients stay intact. Useful
when the user wants to tweak the template mid-import. -->
<q-btn flat color="primary" icon="palette" label="Éditer le template (nouvel onglet)"
type="a" href="/ops/#/campaigns/templates/gift-email-fr" target="_blank">
<q-tooltip>S'ouvre dans un nouvel onglet tes fichiers uploadés restent intacts ici</q-tooltip>
</q-btn>
</div>
<q-stepper v-model="step" header-nav ref="stepper" color="primary" animated flat bordered class="bg-white">
<!-- Step 1 Upload + parameters -->
<q-step :name="1" title="Fichiers + paramètres" icon="upload_file" :done="step > 1" :header-nav="step > 1">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1 q-mb-sm">1. Export Map CSV (brut)</div>
<div class="text-caption text-grey-7 q-mb-md">
Le fichier <code>selectionAdressesMap*.csv</code> tel qu'exporté de la sélection
d'adresses (pipe-delimited, préambule de 1 ligne accepté).
</div>
<q-file v-model="mapFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readMapFile">
<template v-slot:prepend><q-icon name="attach_file" /></template>
</q-file>
<div v-if="mapPreview" class="text-caption q-mt-sm text-grey-7">
{{ countMapRows(mapPreview) }} lignes de contacts (préambule + header retirés)
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1 q-mb-sm">2. Shortlinks Giftbit CSV</div>
<div class="text-caption text-grey-7 q-mb-md">
Le fichier <code>giftbit-gifts-&lt;id&gt;.csv</code> retourné par
<code>create_giftbit_campaign.js</code> (colonnes: firstname, lastname, email,
gift_url, giftbit_uuid, gift_value_cents).
</div>
<q-file v-model="giftFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readGiftFile">
<template v-slot:prepend><q-icon name="attach_file" /></template>
</q-file>
<div v-if="giftPreview" class="text-caption q-mt-sm text-grey-7">
{{ countGiftRows(giftPreview) }} cartes-cadeaux
<span v-if="giftFormatHint" class="text-grey-6">(format: {{ giftFormatHint }})</span>
</div>
</q-card-section>
</q-card>
</div>
</div>
<q-card flat bordered class="q-mt-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">Paramètres de la campagne</div>
<div class="row q-col-gutter-md">
<q-input v-model="params.name" label="Nom interne" outlined dense class="col-12 col-md-6" />
<q-input v-model="params.amount" label="Montant (affiché)" outlined dense class="col-6 col-md-3" placeholder="60 $" />
<q-input v-model.number="params.commitment_months" type="number" label="Engagement (mois)" outlined dense class="col-6 col-md-3" />
<q-input v-model="params.subject" label="Sujet du courriel" outlined dense class="col-12 col-md-6" />
<q-input v-model="params.from" label="Expéditeur (From)" outlined dense class="col-12 col-md-6" placeholder="TARGO <support@targointernet.com>" />
<q-input v-model="params.expiry" label="Expiration (texte affiché)" outlined dense class="col-12 col-md-6" placeholder="31 décembre 2026" />
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-12 col-md-6" />
<q-select v-model="params.multi" :options="multiOptions" emit-value map-options label="Emails multiples (couples)" outlined dense class="col-12 col-md-6" />
</div>
</q-card-section>
</q-card>
<q-stepper-navigation>
<q-btn flat color="primary" icon="person_add" label="Saisie manuelle (sans CSV)" @click="goManual" class="q-mr-sm">
<q-tooltip>Sauter l'import CSV et ajouter les destinataires un par un à l'étape suivante</q-tooltip>
</q-btn>
<q-btn unelevated color="primary" label="Suivant — Aperçu" icon-right="arrow_forward"
:disable="!mapPreview || !giftPreview || parsing"
:loading="parsing" @click="goPreview" />
</q-stepper-navigation>
</q-step>
<!-- Step 2 Preview matched send list -->
<q-step :name="2" title="Aperçu et matching" icon="preview" :done="step > 2" :header-nav="step > 2">
<!-- Counter strip at a glance: total / paired / sendable / unmatched -->
<div class="row q-col-gutter-sm q-mb-md">
<q-card flat bordered class="col-6 col-md">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5">{{ recipients.length }}</div>
<div class="text-caption text-grey-7">Paires contact lien</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md" :class="{ 'bg-positive-1': sendableCount > 0 }">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-positive">{{ sendableCount }}</div>
<div class="text-caption text-grey-7">À envoyer</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-positive">{{ matchedCount }}</div>
<div class="text-caption text-grey-7">Client lié</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-warning">{{ unmatchedCount }}</div>
<div class="text-caption text-grey-7">Sans client</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md" v-if="unpairedContacts.length">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-negative">{{ unpairedContacts.length }}</div>
<div class="text-caption text-grey-7">Sans lien</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md" v-if="unusedGifts.length">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-orange">{{ unusedGifts.length }}</div>
<div class="text-caption text-grey-7">Liens en surplus</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md" v-if="namesNeedingReview">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-amber-9">{{ namesNeedingReview }}</div>
<div class="text-caption text-grey-7">Noms à vérifier</div>
</q-card-section>
</q-card>
</div>
<!-- Auto-clean summary informational, not blocking -->
<q-banner v-if="namesAutoCorrected || namesNeedingReview" class="bg-blue-1 text-blue-9 q-mb-sm" rounded dense>
<template v-slot:avatar><q-icon name="auto_fix_high" /></template>
<span v-if="namesAutoCorrected">
<strong>{{ namesAutoCorrected }} nom(s) auto-corrigés</strong> (Title Case, accents québécois,
prénoms composés séparés). L'icône verte dans le tableau indique les changements.
</span>
<span v-if="namesNeedingReview" :class="namesAutoCorrected ? 'q-ml-md' : ''">
<strong>{{ namesNeedingReview }} nom(s) suspects</strong> icône amber : cliquer la cellule
pour éditer en place.
</span>
</q-banner>
<!-- Imbalance banner: explicit explanation of what the imbalance means -->
<q-banner v-if="unpairedContacts.length || unusedGifts.length" class="bg-orange-1 text-orange-9 q-mb-md" rounded>
<template v-slot:avatar><q-icon name="warning" /></template>
<div v-if="unpairedContacts.length">
<strong>{{ unpairedContacts.length }} contact(s) sans lien-cadeau</strong>
ils n'apparaissent PAS dans la liste d'envoi ci-dessous et ne recevront rien.
Pour les inclure, acquérir {{ unpairedContacts.length }} liens supplémentaires
chez Giftbit et re-uploader le fichier.
</div>
<div v-if="unusedGifts.length" :class="unpairedContacts.length ? 'q-mt-xs' : ''">
<strong>{{ unusedGifts.length }} lien(s) Giftbit non utilisés</strong>
il y a plus de cartes-cadeaux que de contacts. Le surplus sera perdu si
la campagne est envoyée tel quel.
</div>
</q-banner>
<!-- Paired recipients (will be sent) -->
<div class="row items-center q-mb-xs">
<div class="text-subtitle1">
<q-icon name="link" /> Association contact lien-cadeau
<span class="text-caption text-grey-7"> vérifier avant d'approuver</span>
</div>
<q-space />
<q-btn unelevated dense color="primary" icon="person_add" label="Ajouter manuellement" @click="openManualDialog">
<q-tooltip>Ajouter un destinataire en saisissant les champs (sans passer par CSV)</q-tooltip>
</q-btn>
</div>
<q-table
:rows="recipients" :columns="recipientColumns" row-key="row_index"
flat bordered dense :pagination="{ rowsPerPage: 25 }"
:rows-per-page-options="[10, 25, 50, 100, 0]"
class="q-mb-md"
>
<template v-slot:body-cell-row_index="props">
<q-td :props="props" class="text-grey-6">#{{ props.row.row_index }}</q-td>
</template>
<!-- Editable firstname cell with auto-clean indicators.
Click the cell q-popup-edit opens, type new value, Enter saves.
Icons (left of name):
amber = nameWarning() heuristic flagged this (e.g. "deux prénoms collés")
green = auto-cleaner changed something at parse-time
(Title Case, accent restoration, compound split) -->
<template v-slot:body-cell-firstname="props">
<q-td :props="props" style="cursor:pointer">
<q-icon v-if="props.row.name_warnings?.firstname" name="warning" color="amber-9" size="14px" class="q-mr-xs">
<q-tooltip>{{ props.row.name_warnings.firstname }}</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.cleaned_changed && props.row.firstname !== props.row.firstname_raw"
name="auto_fix_high" color="positive" size="14px" class="q-mr-xs">
<q-tooltip>Auto-corrigé depuis "<strong>{{ props.row.firstname_raw }}</strong>"</q-tooltip>
</q-icon>
{{ props.row.firstname }}
<q-popup-edit v-model="props.row.firstname" auto-save v-slot="scope" buttons label-set="OK" label-cancel="Annuler">
<q-input v-model="scope.value" dense autofocus :model-value="scope.value"
@keyup.enter="scope.set" @keyup.esc="scope.cancel" />
</q-popup-edit>
</q-td>
</template>
<template v-slot:body-cell-lastname="props">
<q-td :props="props" style="cursor:pointer">
<q-icon v-if="props.row.name_warnings?.lastname" name="warning" color="amber-9" size="14px" class="q-mr-xs">
<q-tooltip>{{ props.row.name_warnings.lastname }}</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.cleaned_changed && props.row.lastname !== props.row.lastname_raw"
name="auto_fix_high" color="positive" size="14px" class="q-mr-xs">
<q-tooltip>Auto-corrigé depuis "<strong>{{ props.row.lastname_raw }}</strong>"</q-tooltip>
</q-icon>
{{ props.row.lastname }}
<q-popup-edit v-model="props.row.lastname" auto-save v-slot="scope" buttons label-set="OK" label-cancel="Annuler">
<q-input v-model="scope.value" dense autofocus
@keyup.enter="scope.set" @keyup.esc="scope.cancel" />
</q-popup-edit>
</q-td>
</template>
<template v-slot:body-cell-gift_url="props">
<q-td :props="props">
<a :href="props.row.gift_url" target="_blank" style="color:var(--q-primary); font-family:monospace; font-size:0.78rem">
{{ shortenUrl(props.row.gift_url) }}
</a>
</q-td>
</template>
<template v-slot:body-cell-language="props">
<q-td :props="props">
<!-- Clickable chip toggles FR EN inline. Default value comes
from the matched Customer.language (or 'fr' for unmatched).
The send-worker picks the template by this value at send
time, so flipping here changes which template gets used. -->
<q-chip dense clickable size="sm"
:color="props.row.language === 'en' ? 'blue-grey-6' : 'primary'"
text-color="white"
:label="(props.row.language || 'fr').toUpperCase()"
@click="props.row.language = props.row.language === 'en' ? 'fr' : 'en'">
<q-tooltip>Cliquer pour basculer FR EN</q-tooltip>
</q-chip>
</q-td>
</template>
<template v-slot:body-cell-match="props">
<q-td :props="props">
<q-chip v-if="props.row.manual" dense color="indigo-5" text-color="white" size="sm" icon="edit" label="manuel" />
<q-chip v-else-if="props.row.customer_id" dense color="positive" text-color="white" size="sm" :label="props.row.match_method" />
<q-chip v-else dense color="warning" text-color="white" size="sm" label="non lié" />
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn flat dense size="sm" :icon="props.row.excluded ? 'add_circle' : 'block'"
:color="props.row.excluded ? 'positive' : 'negative'"
@click="props.row.excluded = !props.row.excluded">
<q-tooltip>{{ props.row.excluded ? 'Inclure' : 'Exclure' }}</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<!-- Contacts that have NO gift-url (won't be sent) -->
<q-expansion-item v-if="unpairedContacts.length" expand-separator
icon="person_off" :label="`${unpairedContacts.length} contact(s) sans lien-cadeau (ne recevront pas)`"
header-class="bg-red-1 text-red-9">
<q-table
:rows="unpairedContacts" :columns="unpairedColumns" row-key="row_index"
flat dense hide-bottom :pagination="{ rowsPerPage: 0 }"
>
<template v-slot:body-cell-row_index="props">
<q-td :props="props" class="text-grey-6">#{{ props.row.row_index }}</q-td>
</template>
</q-table>
</q-expansion-item>
<!-- Unused gift URLs (extra capacity) -->
<q-expansion-item v-if="unusedGifts.length" expand-separator
icon="card_giftcard"
:label="`${unusedGifts.length} lien(s) Giftbit non utilisés`"
header-class="bg-orange-1 text-orange-9"
class="q-mt-sm">
<q-list dense>
<q-item v-for="g in unusedGifts" :key="g.gift_url">
<q-item-section side class="text-grey-6">#{{ g.row_index }}</q-item-section>
<q-item-section>
<a :href="g.gift_url" target="_blank" style="color:var(--q-primary); font-family:monospace; font-size:0.85rem">{{ g.gift_url }}</a>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<!-- Action row: preview + edit template are quick-access utilities,
both non-destructive. The primary action is "Continuer" which
moves to Step 3 (still NOT the send Step 3 has its own
explicit launch button). Icon changed from 'send' (confusing,
looked like it fired) to 'arrow_forward'. -->
<q-stepper-navigation>
<q-btn flat label="Retour" @click="step = 1" />
<q-space />
<q-btn flat color="primary" icon="visibility" label="Aperçu du courriel"
:disable="!firstPreviewable" @click="openPreview" class="q-mr-sm">
<q-tooltip>Voir le rendu du courriel avec les vraies données du destinataire #1 (n'envoie rien)</q-tooltip>
</q-btn>
<q-btn flat color="primary" icon="palette" label="Éditer le template"
type="a" :href="editorHref" target="_blank" class="q-mr-sm">
<q-tooltip>Ouvre l'éditeur dans un nouvel onglet ton import reste ici intact</q-tooltip>
</q-btn>
<q-btn unelevated color="primary" :label="`Continuer — ${sendableCount} prêts`"
icon-right="arrow_forward"
@click="step = 3" :disable="sendableCount === 0">
<q-tooltip>Va à l'étape de confirmation finale. L'envoi ne démarre qu'au clic sur "Lancer l'envoi" de l'étape 3.</q-tooltip>
</q-btn>
</q-stepper-navigation>
</q-step>
<!-- Step 3 Confirm + send -->
<q-step :name="3" title="Confirmation" icon="send">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1 q-mb-sm">Récapitulatif</div>
<q-list dense>
<q-item><q-item-section side>Nom</q-item-section><q-item-section>{{ params.name }}</q-item-section></q-item>
<q-item><q-item-section side>Destinataires actifs</q-item-section><q-item-section>{{ sendableCount }} (sur {{ recipients.length }} paires ; {{ excludedCount }} exclus, {{ unpairedContacts.length }} sans lien)</q-item-section></q-item>
<q-item><q-item-section side>Répartition par langue</q-item-section><q-item-section>{{ langBreakdown }}</q-item-section></q-item>
<q-item><q-item-section side>Montant affiché</q-item-section><q-item-section>{{ params.amount }}</q-item-section></q-item>
<q-item><q-item-section side>Engagement</q-item-section><q-item-section>{{ params.commitment_months }} mois</q-item-section></q-item>
<q-item><q-item-section side>Sujet</q-item-section><q-item-section>{{ params.subject }}</q-item-section></q-item>
<q-item><q-item-section side>Expéditeur</q-item-section><q-item-section>{{ params.from }}</q-item-section></q-item>
<q-item><q-item-section side>Throttle</q-item-section><q-item-section>{{ params.throttle_ms }} ms entre envois (≈ {{ Math.round((60 / (params.throttle_ms / 1000)) || 0) }} emails/min)</q-item-section></q-item>
<q-item><q-item-section side>Durée estimée</q-item-section><q-item-section> {{ estimatedMinutes }} min</q-item-section></q-item>
</q-list>
</q-card-section>
<q-card-section class="bg-red-1 text-red-9">
<q-icon name="warning" /> <strong>Confirmation finale.</strong>
L'envoi démarre dès le clic sur <em>"Lancer l'envoi maintenant"</em>.
Tu seras redirigé vers la page de progression temps réel.
</q-card-section>
</q-card>
<q-stepper-navigation>
<q-btn flat label="Retour modifier" icon="arrow_back" @click="step = 2" />
<q-space />
<q-btn flat color="primary" icon="visibility" label="Aperçu courriel" :disable="!firstPreviewable" @click="openPreview" class="q-mr-sm" />
<q-btn unelevated color="negative" label="Lancer l'envoi maintenant" icon-right="send"
:loading="sending" @click="confirmAndLaunch" />
</q-stepper-navigation>
</q-step>
</q-stepper>
<!-- Manual-add dialog push a recipient into recipients[] without going
through CSV parsing. Useful for ad-hoc gifts, retroactive top-ups,
or test sends to internal stakeholders. The matchCustomer() lookup
that the CSV path does is skipped here customer_id stays empty
unless the user manually pastes it. -->
<q-dialog v-model="manualOpen" persistent>
<q-card style="min-width: 480px; max-width: 640px;">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6"><q-icon name="person_add" class="q-mr-sm" />Ajouter un destinataire</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-form @submit="submitManualRow" class="q-gutter-sm">
<div class="row q-col-gutter-sm">
<q-input v-model="manualRow.firstname" label="Prénom *" outlined dense
class="col-12 col-sm-6" :rules="[v => !!v || 'requis']" autofocus />
<q-input v-model="manualRow.lastname" label="Nom" outlined dense
class="col-12 col-sm-6" />
</div>
<q-input v-model="manualRow.email" label="Email *" outlined dense type="email"
:rules="[v => !!v || 'requis', v => /.+@.+\..+/.test(v) || 'email invalide']" />
<q-input v-model="manualRow.gift_url" label="Lien-cadeau Giftbit *" outlined dense
placeholder="https://gft.link/..."
:rules="[v => !!v || 'requis', v => /^https?:\/\//.test(v) || 'doit commencer par http:// ou https://']" />
<q-input v-model="manualRow.civic_address" label="Adresse civique (pour {{description}})" outlined dense
placeholder="25 Rue des Hirondelles" />
<div class="row q-col-gutter-sm">
<q-input v-model="manualRow.city" label="Ville" outlined dense
placeholder="Ste-Clotilde" class="col-12 col-sm-7" />
<q-input v-model="manualRow.postal_code" label="Code postal" outlined dense
placeholder="J0L 1W0" class="col-12 col-sm-5" />
</div>
<div class="row q-col-gutter-sm">
<q-select v-model="manualRow.language" :options="languageOptions" emit-value map-options
label="Langue du template" outlined dense class="col-12 col-sm-6" />
<q-input v-model="manualRow.phone" label="Téléphone (optionnel)" outlined dense class="col-12 col-sm-6" />
</div>
<div class="row q-col-gutter-sm">
<q-input v-model="manualRow.amount" label="Montant affiché dans le courriel" outlined dense
:placeholder="`défaut: ${params.amount}`" class="col-12 col-sm-6">
<q-tooltip><span v-pre>Texte qui apparaîtra à la place de la variable {{amount}}. Laisse vide pour utiliser le montant de la campagne.</span></q-tooltip>
</q-input>
<q-input v-model.number="manualRow.gift_value_cents" type="number"
label="Valeur en cents (rapport)" outlined dense placeholder="5000"
class="col-12 col-sm-6" /></div>
<div class="text-caption text-grey-7 q-mt-xs">
<q-icon name="info" size="14px" /> Ville et code postal ne s'affichent pas dans le courriel
ils servent à éviter une confusion entre deux clients du même nom dans le tableau ci-dessous
et dans le rapport CSV.
</div>
<div class="text-caption text-grey-7 q-mt-xs">
<q-icon name="info" size="14px" /> Aucun match ERPNext n'est tenté.
<span v-pre>Seule l'adresse civique apparaît dans le courriel (variable <code>{{description}}</code>).</span>
</div>
<div class="row q-mt-md">
<q-space />
<q-btn flat label="Annuler" v-close-popup class="q-mr-sm" />
<q-btn unelevated color="primary" type="submit" icon="add" label="Ajouter" />
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
<!-- Preview dialog renders the actual template with the first sendable
recipient's data + the campaign params. Lets the user verify the
visual + content WITHOUT firing any emails. Toggleable FR/EN since
a mixed-language campaign would send both templates. -->
<q-dialog v-model="previewOpen" maximized persistent>
<q-card class="bg-grey-2">
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
<q-icon name="visibility" class="q-mr-sm" />
<q-toolbar-title>
Aperçu du courriel
<span v-if="previewRecipient" class="text-caption text-grey-7">
· destinataire #{{ previewRecipient.row_index }} {{ previewRecipient.firstname }} {{ previewRecipient.lastname }}
</span>
</q-toolbar-title>
<q-btn-toggle v-model="previewLang" :options="[{label:'🇫🇷 FR', value:'fr'},{label:'🇺🇸 EN', value:'en'}]"
dense unelevated toggle-color="primary" @update:model-value="renderPreview" />
<q-btn flat icon="open_in_new" :href="editorHref" target="_blank" class="q-mx-sm">
<q-tooltip>Éditer dans un nouvel onglet</q-tooltip>
</q-btn>
<q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar>
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9">
<q-spinner size="sm" /> Rendu en cours
</q-banner>
<q-card-section class="q-pa-md" style="height: calc(100vh - 60px); overflow:hidden">
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;"></iframe>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { parseCsvs, createCampaign, sendCampaign, previewTemplate } from 'src/api/campaigns'
const $q = useQuasar()
const router = useRouter()
const step = ref(1)
const mapFile = ref(null)
const giftFile = ref(null)
const mapPreview = ref('')
const giftPreview = ref('')
const params = ref({
name: `Campagne ${new Date().toISOString().slice(0,10)}`,
amount: '60 $',
commitment_months: 3,
subject: '🎁 Un cadeau pour toi, de la part de TARGO',
from: 'TARGO <support@targointernet.com>',
expiry: '',
throttle_ms: 600,
multi: 'first',
})
const multiOptions = [
{ label: '1er email seulement (1 cadeau/foyer)', value: 'first' },
{ label: 'Séparer en 2 rangées (1 cadeau/personne)', value: 'split' },
{ label: 'Ignorer les couples', value: 'skip' },
]
const parsing = ref(false)
const sending = ref(false)
const recipients = ref([])
const unpairedContacts = ref([])
const unusedGifts = ref([])
// row_index (#1, #2, ...) is the source-CSV position invaluable for the
// user to cross-reference what they see here against the file they uploaded.
// gift_url is rendered as a clickable short label so contactlink pairing
// can be eyeballed at a glance and the user can click through to verify
// the shortlink works.
const recipientColumns = [
{ name: 'row_index', label: '#', field: 'row_index', align: 'left' },
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
{ name: 'city', label: 'Ville', field: r => r.city || r.postal_code || '', align: 'left' },
{ name: 'gift_url', label: 'Lien-cadeau', field: 'gift_url', align: 'left' },
{ name: 'language', label: 'Langue', field: 'language', align: 'left' },
{ name: 'match', label: 'Match', field: 'match_method', align: 'left' },
{ name: 'customer_name', label: 'Client ERPNext', field: 'customer_name', align: 'left' },
{ name: 'actions', label: '', field: '', align: 'right' },
]
const unpairedColumns = [
{ name: 'row_index', label: '#', field: 'row_index', align: 'left' },
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
{ name: 'postal_code', label: 'Code postal', field: 'postal_code', align: 'left' },
]
const matchedCount = computed(() => recipients.value.filter(r => r.customer_id).length)
const unmatchedCount = computed(() => recipients.value.filter(r => !r.customer_id).length)
const excludedCount = computed(() => recipients.value.filter(r => r.excluded).length)
// Net number of emails that will actually be fired off (paired AND not excluded)
const sendableCount = computed(() => recipients.value.filter(r => !r.excluded && r.gift_url).length)
// Names that the auto-cleaner couldn't confidently fix. Heuristic warnings
// from the backend (digit in name, two names possibly stuck together, etc.).
// User should glance at these before sending.
const namesNeedingReview = computed(() =>
recipients.value.filter(r => r.name_warnings?.firstname || r.name_warnings?.lastname).length
)
const namesAutoCorrected = computed(() =>
recipients.value.filter(r => r.cleaned_changed).length
)
// FR / EN breakdown of the sendable recipients useful preview before launch
// so the user knows which template will actually be used and how many.
const langBreakdown = computed(() => {
const counts = {}
for (const r of recipients.value) {
if (r.excluded || !r.gift_url) continue
const lang = (r.language || 'fr').toLowerCase()
counts[lang] = (counts[lang] || 0) + 1
}
const parts = Object.entries(counts).map(([l, n]) => `${n} × ${l.toUpperCase()}`)
return parts.length ? parts.join(', ') : '—'
})
const estimatedMinutes = computed(() => {
const per = (params.value.throttle_ms || 600) / 1000
return Math.max(1, Math.round((sendableCount.value * per) / 60))
})
function shortenUrl (u) {
if (!u) return ''
return u.replace(/^https?:\/\//, '').slice(0, 28) + (u.length > 35 ? '…' : '')
}
// Preview dialog
// Renders the email through the hub's /campaigns/templates/:name/preview
// endpoint, using the first sendable recipient's data + the current campaign
// params. Non-destructive no emails are fired by this action.
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewHtmlContent = ref('')
const previewLang = ref('fr')
const previewRecipient = ref(null)
// First recipient that's actually going to be sent used as the preview
// sample so the user sees real data, not synthetic placeholders.
const firstPreviewable = computed(() =>
recipients.value.find(r => !r.excluded && r.gift_url) || null
)
// Link to the template editor for the relevant language. Always opens
// in a new tab so the user's in-progress wizard state is preserved.
const editorHref = computed(() =>
`/ops/#/campaigns/templates/gift-email-${previewLang.value}`
)
async function openPreview () {
const r = firstPreviewable.value
if (!r) return
previewRecipient.value = r
// Default preview language to the recipient's actual language so the user
// first sees what THIS recipient will receive
previewLang.value = (r.language || 'fr').toLowerCase().split('-')[0]
previewOpen.value = true
await renderPreview()
}
async function renderPreview () {
if (!previewRecipient.value) return
previewLoading.value = true
try {
const r = previewRecipient.value
const vars = {
firstname: r.firstname || (previewLang.value === 'en' ? 'dear customer' : 'cher client'),
lastname: r.lastname || '',
email: r.email,
description: r.civic_address || '',
gift_url: r.gift_url,
amount: params.value.amount,
expiry: params.value.expiry,
commitment_months: params.value.commitment_months,
year: new Date().getFullYear(),
}
const res = await previewTemplate(`gift-email-${previewLang.value}`, { vars })
previewHtmlContent.value = res.rendered || ''
} catch (e) {
$q.notify({ type: 'negative', message: 'Aperçu impossible: ' + e.message })
} finally {
previewLoading.value = false
}
}
// Wrapper around launchSend that confirms one last time before firing. The
// Step 3 page is already a "confirmation step", but this dialog adds one
// final friction so accidental clicks don't fire 200 emails.
function confirmAndLaunch () {
$q.dialog({
title: 'Envoyer maintenant ?',
message: `Cette action enverra <strong>${sendableCount.value} courriel(s)</strong>
via Mailjet immédiatement. Pas annulable une fois démarré.`,
html: true,
persistent: true,
ok: { label: 'Oui, envoyer', color: 'negative', icon: 'send', unelevated: true },
cancel: { label: 'Annuler', flat: true },
}).onOk(() => {
launchSend()
})
}
function readFile (file) {
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result)
r.onerror = reject
r.readAsText(file, 'utf-8')
})
}
async function readMapFile (file) { if (file) mapPreview.value = await readFile(file) }
async function readGiftFile (file) { if (file) giftPreview.value = await readFile(file) }
// Counts must mirror the backend parser exactly so the user sees the same
// numbers in the preview as what Step 2 will receive.
// Map CSV format: 1-line title preamble + header row + N data rows.
// Returns N (the # of contact lines, excluding the preamble and header).
function countMapRows (text) {
if (!text) return 0
const lines = text.split(/\r?\n/).filter(l => l.trim())
// -1 for preamble, -1 for header
return Math.max(0, lines.length - 2)
}
// Giftbit CSV: TWO formats
// 1. "Link Order" headerless, one URL per line (each URL = 1 gift)
// 2. "Campaign export" header row + N data rows (-1 for header)
// Detect like the backend: first non-empty line is a bare URL with no
// separator no header.
function countGiftRows (text) {
if (!text) return 0
const cleaned = text.replace(/^/, '').trim()
if (!cleaned) return 0
const firstLine = cleaned.split(/\r?\n/, 1)[0].trim()
const isLinkOrder = /^https?:\/\/\S+$/.test(firstLine) &&
!firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')
const lines = cleaned.split(/\r?\n/).filter(l => l.trim())
return isLinkOrder ? lines.length : Math.max(0, lines.length - 1)
}
// Show "Link Order" or "Campaign export" hint next to the gift count
const giftFormatHint = computed(() => {
if (!giftPreview.value) return ''
const firstLine = giftPreview.value.replace(/^/, '').trim().split(/\r?\n/, 1)[0].trim()
if (/^https?:\/\/\S+$/.test(firstLine) && !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')) {
return 'Link Order'
}
return 'Campaign export'
})
async function goPreview () {
if (!mapPreview.value || !giftPreview.value) return
parsing.value = true
try {
const r = await parseCsvs({ map_csv: mapPreview.value, giftbit_csv: giftPreview.value, multi: params.value.multi })
recipients.value = r.recipients || []
unpairedContacts.value = r.unpaired_contacts || []
unusedGifts.value = r.unused_gifts || []
step.value = 2
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
} finally {
parsing.value = false
}
}
// Skip CSV parsing entirely empty recipients list, user adds rows manually
// via the "Ajouter manuellement" button on Step 2. The Suivant button is
// disabled if no rows, so the user can't accidentally proceed with nothing.
function goManual () {
recipients.value = []
unpairedContacts.value = []
unusedGifts.value = []
step.value = 2
// Open the dialog immediately the most likely next action is adding a row
openManualDialog()
}
// Manual recipient entry
// Lets the user add a recipient by typing the fields directly instead of
// importing from CSV. Useful for: small ad-hoc gifts, replacement sends after
// a bounce, internal QA test sends, or topping up an existing CSV import
// with a missed contact. No ERPNext matching is attempted on these rows.
const manualOpen = ref(false)
const languageOptions = [
{ label: '🇫🇷 Français', value: 'fr' },
{ label: '🇺🇸 English', value: 'en' },
]
function emptyManualRow () {
return {
firstname: '', lastname: '', email: '', phone: '',
civic_address: '', city: '', postal_code: '', language: 'fr',
gift_url: '', gift_value_cents: null, amount: '',
}
}
const manualRow = ref(emptyManualRow())
function openManualDialog () {
manualRow.value = emptyManualRow()
manualOpen.value = true
}
function submitManualRow () {
// Determine next row_index. CSV-imported rows start at 1; manuals continue
// the sequence so #N reads naturally regardless of source.
const maxIdx = recipients.value.reduce((m, r) => Math.max(m, r.row_index || 0), 0)
recipients.value.push({
row_index: maxIdx + 1,
firstname: (manualRow.value.firstname || '').trim(),
lastname: (manualRow.value.lastname || '').trim(),
email: (manualRow.value.email || '').trim().toLowerCase(),
phone: (manualRow.value.phone || '').trim(),
civic_address: (manualRow.value.civic_address || '').trim(),
city: (manualRow.value.city || '').trim(),
postal_code: (manualRow.value.postal_code || '').trim().toUpperCase(),
language: manualRow.value.language || 'fr',
gift_url: (manualRow.value.gift_url || '').trim(),
gift_value_cents: manualRow.value.gift_value_cents || null,
// Per-recipient amount override. Empty string falls back to campaign
// params.amount in the worker. Useful for manuals on a mixed-amount campaign.
amount: (manualRow.value.amount || '').trim() || null,
giftbit_uuid: null,
// Flag for downstream code + table display so a "manuel" chip can be shown
// and the match-method column doesn't read "non lié" misleadingly.
manual: true,
match_method: 'manuel',
customer_id: null,
status: 'pending',
excluded: false,
})
manualOpen.value = false
$q.notify({ type: 'positive', message: 'Destinataire ajouté' })
}
async function launchSend () {
sending.value = true
try {
const saved = await createCampaign({
name: params.value.name,
params: { ...params.value },
recipients: recipients.value,
})
await sendCampaign(saved.id)
router.push(`/campaigns/${saved.id}`)
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
sending.value = false
}
}
</script>

View File

@ -0,0 +1,88 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<div class="text-h5">Campagnes</div>
<q-space />
<q-btn flat color="primary" icon="palette" label="Éditer le template" :to="'/campaigns/templates/gift-email-fr'" class="q-mr-sm" />
<q-btn unelevated color="primary" icon="add" label="Nouvelle campagne" :to="'/campaigns/new'" />
</div>
<q-card flat bordered v-if="!loading && campaigns.length === 0" class="q-pa-xl text-center text-grey-7">
<q-icon name="card_giftcard" size="48px" class="q-mb-md" />
<div class="text-h6">Aucune campagne pour le moment</div>
<div class="q-mt-sm">
Une campagne envoie des cartes-cadeaux Giftbit par courriel personnalisé.
Importer 2 CSV (export Map + shortlinks Giftbit) et lancer l'envoi via Mailjet.
</div>
<q-btn class="q-mt-lg" color="primary" icon="add" label="Créer la première" :to="'/campaigns/new'" />
</q-card>
<q-table
v-else
:rows="campaigns"
:columns="columns"
row-key="id"
:loading="loading"
flat bordered
:pagination="{ rowsPerPage: 25, sortBy: 'created_at', descending: true }"
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
</q-td>
</template>
<template v-slot:body-cell-progress="props">
<q-td :props="props">
<div class="row items-center q-gutter-xs">
<span class="text-grey-7">{{ (props.row.counters?.sent || 0) }} / {{ props.row.total || 0 }}</span>
<q-linear-progress
:value="(props.row.counters?.sent || 0) / Math.max(1, props.row.total || 1)"
size="6px"
:color="props.row.counters?.failed ? 'negative' : 'positive'"
style="min-width:80px"
/>
<span v-if="props.row.counters?.failed" class="text-negative text-caption">
{{ props.row.counters.failed }} échec(s)
</span>
</div>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn flat dense color="primary" icon="visibility" :to="`/campaigns/${props.row.id}`" />
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listCampaigns } from 'src/api/campaigns'
const campaigns = ref([])
const loading = ref(true)
const columns = [
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
{ name: 'created_at', label: 'Créée', field: r => new Date(r.created_at).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' }), align: 'left', sortable: true },
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
{ name: 'progress', label: 'Envois', field: 'counters', align: 'left' },
{ name: 'actions', label: '', field: '', align: 'right' },
]
function statusColor (s) {
return { draft: 'grey', sending: 'orange', completed: 'positive', failed: 'negative' }[s] || 'grey'
}
function statusLabel (s) {
return { draft: 'Brouillon', sending: 'En cours', completed: 'Terminée', failed: 'Échec' }[s] || s
}
async function load () {
loading.value = true
try { campaigns.value = await listCampaigns() }
finally { loading.value = false }
}
onMounted(load)
</script>

View File

@ -0,0 +1,500 @@
<template>
<q-page>
<!-- Top bar: template selector + saved chip + quick actions. The Unlayer
editor below has its own toolbar for blocks/preview/etc. -->
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<div class="text-h6 q-mr-md">Éditeur de template</div>
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
style="min-width:240px" @update:model-value="onSelectTemplate" />
<q-btn flat dense icon="add" label="Nouveau" color="primary" class="q-ml-sm" @click="newTemplateOpen = true">
<q-tooltip>Créer un nouveau template (vide ou copié depuis un existant)</q-tooltip>
</q-btn>
<q-chip v-if="lastSavedTs" dense size="sm" color="grey-2" text-color="grey-9" class="q-ml-sm" icon="cloud_done">
Sauvegardé · {{ lastSavedLabel }}
</q-chip>
<q-btn flat dense icon="code" class="q-ml-sm" color="grey-7">
<q-tooltip max-width="320px">
<strong>9 variables disponibles</strong> (Client / Offre / Système).
Insertion : clic dans un texte barre flottante icône <code>{}</code> Merge Tags.
Marche aussi dans les champs URL (boutons, images, mailto).
</q-tooltip>
</q-btn>
<q-space />
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm" @click="openPreview">
<q-tooltip>Voir le HTML rendu (substitué) tel que reçu par le destinataire</q-tooltip>
</q-btn>
<q-btn flat color="purple-7" icon="translate" label="Traduire (AI)" class="q-mr-sm"
:disable="!aiTranslateTargetName" @click="aiTranslateOpen = true">
<q-tooltip>{{ aiTranslateTargetName ? `Traduire vers ${aiTranslateTargetName} via Gemini` : 'Disponible pour les templates avec suffixe -fr ou -en' }}</q-tooltip>
</q-btn>
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm" @click="testSendOpen = true">
<q-tooltip>Envoyer un courriel réel à une adresse de test</q-tooltip>
</q-btn>
<q-btn unelevated color="primary" icon="save" label="Enregistrer"
:loading="saving" @click="saveTemplate" />
</div>
<!-- Unlayer editor Vue 3 native, no iframe, full features:
responsive preview, AMP blocks, Unsplash, file manager, dark mode,
layers/structure panel, design tokens, etc.
Wrapped in an explicit-sized container so the inner iframe gets
enough height/width (Quasar's q-page doesn't propagate dimensions
that easyEditor's nested iframe can pick up automatically). -->
<div style="height: calc(100vh - 60px); width: 100%; overflow: hidden; position: relative;">
<EmailEditor
ref="editor"
:options="editorOptions"
:min-height="'100%'"
style="height: 100%; width: 100%;"
@load="onEditorLoad"
@ready="onEditorReady"
/>
</div>
<!-- Aperçu dialog renders the latest saved HTML with sample vars -->
<q-dialog v-model="previewOpen" maximized persistent>
<q-card class="bg-grey-2">
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
<q-icon name="visibility" class="q-mr-sm" />
<q-toolbar-title>Aperçu inbox · {{ currentName }}</q-toolbar-title>
<q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar>
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9"><q-spinner size="sm" /> Rendu en cours</q-banner>
<q-card-section style="height: calc(100vh - 60px); overflow:hidden">
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;" />
</q-card-section>
</q-card>
</q-dialog>
<!-- New template dialog name + starter -->
<q-dialog v-model="newTemplateOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-blue-1 text-blue-9">
<q-icon name="add" class="q-mr-sm" />
<div class="text-h6">Nouveau template</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-input v-model="newTemplateForm.suffix" outlined dense autofocus
label="Nom (suffixe après gift-email-)" :prefix="newTemplateForm.prefix + '-'"
:rules="[v => /^[a-z0-9-]+$/.test(v) || 'Lettres minuscules, chiffres et tirets seulement']"
hint="Exemple: summer-2026, automne-promo, anniversaire" class="q-mb-md" />
<q-select v-model="newTemplateForm.prefix" :options="['gift-email','newsletter','transactional']"
label="Type" outlined dense class="q-mb-md" />
<q-select v-model="newTemplateForm.starter" :options="starterOptions" emit-value map-options
label="Démarrer depuis" outlined dense class="q-mb-md" />
<q-banner v-if="newTemplateFinal" class="bg-grey-2 text-grey-8 q-mt-sm" rounded dense>
<q-icon name="info" class="q-mr-xs" />
Le template sera créé sous le nom <strong>{{ newTemplateFinal }}</strong>
<span v-if="newTemplateForm.starter !== 'blank'">
· contenu copié depuis <em>{{ newTemplateForm.starter }}</em>
</span>
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon="add" label="Créer le template"
:loading="creating" :disable="!newTemplateValid" @click="createTemplate" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- AI translate dialog -->
<q-dialog v-model="aiTranslateOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-purple-1 text-purple-9">
<q-icon name="translate" class="q-mr-sm" />
<div class="text-h6">Traduire avec Gemini AI</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="q-mb-md">
Le contenu de <strong>{{ currentName }}</strong> sera traduit vers
<strong>{{ aiTranslateTargetName }}</strong> via Gemini Flash.
</div>
<q-banner class="bg-grey-2 text-grey-8 q-mb-md" rounded dense>
<q-icon name="info" class="q-mr-xs" />
<!-- v-pre on this span so Vue doesn't try to compile the literal
{{...}} braces inside the explanatory <code> tag. -->
<span v-pre>
Le AI préserve la structure HTML, les variables <code>{{...}}</code>,
les URLs, les noms de marque (TARGO, Giftbit) et les emojis.
Il traduit seulement le texte visible.
</span>
</q-banner>
<q-banner v-if="targetTemplateExists" class="bg-amber-1 text-amber-9 q-mb-md" rounded dense>
<q-icon name="warning" class="q-mr-xs" />
Le template <strong>{{ aiTranslateTargetName }}</strong> existe déjà.
La traduction va l'écraser (backup automatique avant).
<q-toggle v-model="aiTranslateOverride" label="Confirmer l'écrasement" class="q-mt-sm" />
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="purple-7" icon="translate" label="Traduire maintenant"
:loading="aiTranslating"
:disable="targetTemplateExists && !aiTranslateOverride"
@click="doAiTranslate" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Test-send dialog -->
<q-dialog v-model="testSendOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-orange-1 text-orange-9">
<q-icon name="send" class="q-mr-sm" />
<div class="text-h6">Envoyer un test du courriel</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense type="email" autofocus class="q-mb-md" />
<q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" />
<div class="text-subtitle2 q-mb-sm">Variables</div>
<div class="row q-col-gutter-sm">
<q-input v-model="testSendForm.vars.firstname" label="firstname" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.lastname" label="lastname" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.amount" label="amount" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.commitment_months" label="commitment_months" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.gift_url" label="gift_url" outlined dense class="col-12" />
<q-input v-model="testSendForm.vars.description" label="description" outlined dense class="col-12" />
<q-input v-model="testSendForm.vars.expiry" label="expiry" outlined dense class="col-12" />
</div>
</q-card-section>
<q-card-section class="bg-grey-2">
<div class="text-caption text-grey-8">
Sauvegarde l'éditeur d'abord pour tester la dernière version.
Envoyé via Mailjet depuis <code>TARGO &lt;support@targointernet.com&gt;</code>.
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon-right="send" label="Envoyer le test"
:loading="testSending" :disable="!testSendForm.to" @click="doTestSend" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { EmailEditor } from 'vue-email-editor'
import { listTemplates, getTemplate, saveTemplate as saveTemplateApi,
previewTemplate, testSendTemplate, translateTemplate } from 'src/api/campaigns'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
// Editor ref + Unlayer configuration
const editor = ref(null)
const editorReady = ref(false)
const saving = ref(false)
const currentName = ref(route.params.name || 'gift-email-fr')
// Unlayer editor options:
// - mergeTags: shown in the "Merge tags" panel, drag-droppable into text
// - features: Unsplash, file manager, etc. are all on by default
// - tools: which block types to expose
// - appearance: light theme matching our ops UI
const editorOptions = {
appearance: {
theme: 'modern_light',
panels: {
tools: { dock: 'left' },
},
},
// Merge tags organized by category Unlayer shows these in a dropdown
// when editing a text block (click into text toolbar {} icon) and
// ALSO in URL fields (Button "Action URL", Image "Source", mailto links).
// The `sample` field is what Unlayer shows as a preview (so the user sees
// realistic content while editing); on send, the hub's Mustache renderer
// substitutes the actual value.
mergeTags: [
{
name: 'Client',
mergeTags: [
{ name: 'Prénom', value: '{{firstname}}', sample: 'Louis' },
{ name: 'Nom de famille', value: '{{lastname}}', sample: 'Tremblay' },
{ name: 'Courriel', value: '{{email}}', sample: 'louis@targo.ca' },
{ name: 'Adresse service', value: '{{description}}', sample: '123 Rue de la Rivière, Ste-Clotilde' },
],
},
{
name: 'Offre',
mergeTags: [
{ name: 'Montant', value: '{{amount}}', sample: '60 $' },
{ name: 'Lien cadeau (URL)', value: '{{gift_url}}', sample: 'https://gft.link/abc' },
{ name: "Date d'expiration", value: '{{expiry}}', sample: '31 décembre 2026' },
{ name: 'Engagement (mois)', value: '{{commitment_months}}', sample: '3' },
],
},
{
name: 'Système',
mergeTags: [
{ name: 'Année courante', value: '{{year}}', sample: '2026' },
],
},
],
// Display mode: 'email' (default, with mobile preview), 'web' for landing pages
displayMode: 'email',
// Locale for built-in strings
locale: 'fr-CA',
// Enable optional sidebar features (free tier limits some see Unlayer docs)
features: {
// Built-in Unlayer template library limited selection without projectId
// but still gives the user some pre-built starts to pick from.
templates: true,
// Unsplash image search panel
stockImages: true,
// Image upload (uses Unlayer's CDN by default; can be wired to our hub
// /campaigns/assets/upload endpoint via customJS later if we want to
// self-host uploads for now their CDN is fine for ad-hoc images)
imageEditor: true,
// Undo/redo + history
undoRedo: true,
},
// Use Unlayer's free CDN. For paid users this would carry a projectId.
// Without a projectId the "Powered by Unlayer" badge shows in the sidebar.
}
// Template list (selector at the top)
const templates = ref([])
const templateOptions = computed(() => templates.value.map(t => ({
label: `${t.name} (${Math.round(t.size / 1024)} KB)`,
value: t.name,
})))
async function loadAvailableTemplates () {
templates.value = await listTemplates()
}
// AI translation (Gemini via hub)
// Auto-detect source language from the template name suffix (-fr / -en) and
// compute the target name with the OPPOSITE suffix.
const aiTranslateOpen = ref(false)
const aiTranslating = ref(false)
const aiTranslateOverride = ref(false)
const aiTranslateTargetName = computed(() => {
const m = (currentName.value || '').match(/^(.+)-(fr|en)$/)
if (!m) return ''
const opposite = m[2] === 'fr' ? 'en' : 'fr'
return `${m[1]}-${opposite}`
})
const targetTemplateExists = computed(() =>
!!aiTranslateTargetName.value && !!templates.value.find(t => t.name === aiTranslateTargetName.value),
)
async function doAiTranslate () {
if (!aiTranslateTargetName.value) return
aiTranslating.value = true
try {
const r = await translateTemplate(currentName.value, aiTranslateTargetName.value, {
override: aiTranslateOverride.value,
})
aiTranslateOpen.value = false
aiTranslateOverride.value = false
await loadAvailableTemplates()
$q.notify({
type: 'positive',
message: `Traduction terminée : ${r.source}${r.target}`,
caption: `${r.src_bytes}${r.out_bytes} octets (${r.from_lang}${r.to_lang})`,
timeout: 6000,
actions: [
{ label: 'Ouvrir', color: 'white', handler: () => { onSelectTemplate(r.target) } },
],
})
} catch (e) {
$q.notify({ type: 'negative', message: 'Échec traduction: ' + e.message, timeout: 6000 })
} finally {
aiTranslating.value = false
}
}
// New template creation
const newTemplateOpen = ref(false)
const creating = ref(false)
const newTemplateForm = ref({
prefix: 'gift-email',
suffix: '',
starter: 'blank', // 'blank' | template name to copy from
})
const newTemplateFinal = computed(() => {
const { prefix, suffix } = newTemplateForm.value
if (!suffix || !/^[a-z0-9-]+$/.test(suffix)) return ''
return `${prefix}-${suffix}`
})
const newTemplateValid = computed(() =>
!!newTemplateFinal.value &&
!templates.value.find(t => t.name === newTemplateFinal.value),
)
const starterOptions = computed(() => [
{ label: 'Vide (canevas blanc)', value: 'blank' },
...templates.value.map(t => ({ label: `Copier depuis ${t.name}`, value: t.name })),
])
async function createTemplate () {
if (!newTemplateValid.value) return
creating.value = true
try {
const newName = newTemplateFinal.value
let html = '<div style="padding:32px;text-align:center;color:#64748B;font-family:sans-serif;">Nouveau template — drag des blocs depuis la sidebar pour commencer.</div>'
let design = null
if (newTemplateForm.value.starter !== 'blank') {
// Copy html + design from the chosen source template
const src = await getTemplate(newTemplateForm.value.starter)
html = src.html || html
design = src.design || null
}
await saveTemplateApi(newName, html, { design })
await loadAvailableTemplates()
newTemplateOpen.value = false
// Switch to the new template
currentName.value = newName
router.replace({ path: `/campaigns/templates/${newName}` })
loadTemplateIntoEditor(newName)
newTemplateForm.value.suffix = '' // reset for next time
$q.notify({ type: 'positive', message: `Template "${newName}" créé`, timeout: 3000 })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur création: ' + e.message })
} finally {
creating.value = false
}
}
// Load template into the Unlayer canvas on editor ready + on switch
async function loadTemplateIntoEditor (name) {
if (!editorReady.value || !editor.value) return
try {
const data = await getTemplate(name)
// Priority: Unlayer design JSON saved by a previous edit > nothing.
// Legacy MJML/HTML templates aren't auto-importable into Unlayer's
// component tree the user reconstructs visually once, and the
// design saved by the next "Enregistrer" fixes future loads.
if (data.design) {
const design = typeof data.design === 'string' ? JSON.parse(data.design) : data.design
editor.value.loadDesign(design)
} else {
// No saved design vue-email-editor 2.x doesn't have a loadBlank()
// method on the ref, so we just let the editor show its default
// empty state ("No content here. Drag content from left.") and
// notify the user to start building.
$q.notify({
type: 'info',
message: `Pas encore de design pour "${name}" — drag des blocs depuis la sidebar gauche pour construire la template, puis "Enregistrer".`,
timeout: 8000,
})
}
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de chargement: ' + e.message })
}
}
function onEditorLoad () {
// Fired once when the editor IFRAME loads (before the editor inside is ready)
}
async function onEditorReady () {
// Fired when the editor INSIDE the iframe is ready to accept loadDesign()
editorReady.value = true
await loadTemplateIntoEditor(currentName.value)
}
function onSelectTemplate (name) {
currentName.value = name
router.replace({ path: `/campaigns/templates/${name}` })
loadTemplateIntoEditor(name)
}
// Save: export HTML + design JSON, POST to hub
const lastSavedTs = ref(null)
const lastSavedLabel = computed(() => {
if (!lastSavedTs.value) return ''
const diff = Math.floor((Date.now() - lastSavedTs.value) / 1000)
if (diff < 60) return `il y a ${diff}s`
if (diff < 3600) return `il y a ${Math.floor(diff / 60)}min`
return new Date(lastSavedTs.value).toLocaleTimeString('fr-CA')
})
function saveTemplate () {
if (!editor.value) return
saving.value = true
// exportHtml uses a callback (legacy Unlayer API) wrap in a Promise
editor.value.exportHtml(async (data) => {
try {
const { html, design } = data
await saveTemplateApi(currentName.value, html, { design })
lastSavedTs.value = Date.now()
$q.notify({ type: 'positive', message: 'Template enregistré ✓', timeout: 2500 })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
})
}
// Preview dialog
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewHtmlContent = ref('')
async function openPreview () {
previewOpen.value = true
previewLoading.value = true
try {
const r = await previewTemplate(currentName.value)
previewHtmlContent.value = r.rendered || ''
} catch (e) {
$q.notify({ type: 'negative', message: 'Aperçu impossible: ' + e.message })
} finally {
previewLoading.value = false
}
}
// Test-send dialog
const testSendOpen = ref(false)
const testSending = ref(false)
const testSendForm = ref({
to: 'louis@targo.ca',
subject: '[TEST] Aperçu du courriel TARGO',
vars: {
firstname: 'Louis', lastname: 'Test', amount: '60 $',
commitment_months: '3',
gift_url: 'https://gft.link/TEST123',
description: '123 Rue de Test, Ste-Clotilde',
expiry: '31 décembre 2026',
},
})
async function doTestSend () {
testSending.value = true
try {
const r = await testSendTemplate(currentName.value, {
to: testSendForm.value.to.trim(),
subject: testSendForm.value.subject,
vars: testSendForm.value.vars,
})
$q.notify({ type: 'positive', message: `Test envoyé à ${r.to}`, caption: `${r.bytes} octets`, timeout: 5000 })
testSendOpen.value = false
} catch (e) {
$q.notify({ type: 'negative', message: 'Échec envoi: ' + e.message })
} finally {
testSending.value = false
}
}
onMounted(loadAvailableTemplates)
</script>

View File

@ -38,6 +38,13 @@ const routes = [
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') }, { path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') }, { path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
{ path: 'network', component: () => import('src/pages/NetworkPage.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 },
], ],
}, },
] ]

View File

@ -99,7 +99,7 @@ domain level. The two known-validated senders on this account are:
The default for gift campaigns: The default for gift campaigns:
``` ```
--from "Gigafibre Support <support@targointernet.com>" --from "TARGO <support@targointernet.com>"
``` ```
Reasoning for `support@` over `noreply@`: campaigns INVITE a reply Reasoning for `support@` over `noreply@`: campaigns INVITE a reply
@ -129,10 +129,11 @@ node send_gift_campaign.js \
--gifts /path/to/giftbit-gifts.csv \ --gifts /path/to/giftbit-gifts.csv \
--contacts /path/to/giftbit-contacts-A-first-email.csv \ --contacts /path/to/giftbit-contacts-A-first-email.csv \
--template ./templates/gift-email-fr.html \ --template ./templates/gift-email-fr.html \
--subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \ --subject "🎁 Un cadeau pour vous, de la part de TARGO" \
--amount "50 $" \ --amount "60 $" \
--expiry "31 décembre 2026" \ --expiry "31 décembre 2026" \
--from "Gigafibre Support <support@targointernet.com>" \ --commitment-months 3 \
--from "TARGO <support@targointernet.com>" \
--dry-run --dry-run
``` ```
@ -151,10 +152,11 @@ node send_gift_campaign.js \
--gifts /path/to/giftbit-gifts.csv \ --gifts /path/to/giftbit-gifts.csv \
--contacts /path/to/giftbit-contacts-A-first-email.csv \ --contacts /path/to/giftbit-contacts-A-first-email.csv \
--template ./templates/gift-email-fr.html \ --template ./templates/gift-email-fr.html \
--subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \ --subject "🎁 Un cadeau pour vous, de la part de TARGO" \
--amount "50 $" \ --amount "60 $" \
--expiry "31 décembre 2026" \ --expiry "31 décembre 2026" \
--from "Gigafibre Support <support@targointernet.com>" \ --commitment-months 3 \
--from "TARGO <support@targointernet.com>" \
--smtp-host in-v3.mailjet.com --smtp-port 587 \ --smtp-host in-v3.mailjet.com --smtp-port 587 \
--smtp-user "$SMTP_USER" --smtp-pass "$SMTP_PASS" \ --smtp-user "$SMTP_USER" --smtp-pass "$SMTP_PASS" \
--throttle-ms 600 --throttle-ms 600
@ -185,11 +187,13 @@ Variables resolved at send time:
| `{{gift_url}}` | matched from the gifts CSV | | `{{gift_url}}` | matched from the gifts CSV |
| `{{amount}}` | `--amount` CLI flag (e.g. `"50 $"`) | | `{{amount}}` | `--amount` CLI flag (e.g. `"50 $"`) |
| `{{expiry}}` | `--expiry` CLI flag (e.g. `"31 décembre 2026"`) | | `{{expiry}}` | `--expiry` CLI flag (e.g. `"31 décembre 2026"`) |
| `{{commitment_months}}` | `--commitment-months` CLI flag (default `3`) — used in the "Condition" pill and prorata-refund disclaimer of the retention offer |
The template uses a vintage `{{#expiry}} ... {{/expiry}}` block for the The template uses a Mustache-style `{{#expiry}} ... {{/expiry}}` block for
optional expiry line — currently rendered as plain text (the script's the optional expiry line. The renderer keeps the contents when the
simple `{{var}}` renderer doesn't strip the tags). If you don't want the matching variable is truthy and drops them entirely otherwise — so if you
expiry sentence, edit the template directly to remove that block. omit `--expiry` from the CLI, the "Le lien expire le …" sentence
disappears cleanly with no orphan tags showing.
## Source data — the two CSVs ## Source data — the two CSVs

25
scripts/campaigns/package-lock.json generated Normal file
View File

@ -0,0 +1,25 @@
{
"name": "campaigns",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "campaigns",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"nodemailer": "^8.0.7"
}
},
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
}
}
}

View File

@ -0,0 +1,16 @@
{
"name": "campaigns",
"version": "1.0.0",
"description": "One-shot tool to send Giftbit gift cards to a list of contacts with a branded French email, bypassing Giftbit's English-only built-in delivery.",
"main": "create_giftbit_campaign.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"nodemailer": "^8.0.7"
}
}

View File

@ -18,6 +18,7 @@
* --subject "Votre cadeau Gigafibre" \ * --subject "Votre cadeau Gigafibre" \
* --amount "50 $" \ * --amount "50 $" \
* --expiry "31 décembre 2026" \ * --expiry "31 décembre 2026" \
* --commitment-months 3 \
* --from "Gigafibre <noreply@gigafibre.ca>" \ * --from "Gigafibre <noreply@gigafibre.ca>" \
* --smtp-host in-v3.mailjet.com --smtp-port 587 \ * --smtp-host in-v3.mailjet.com --smtp-port 587 \
* --smtp-user $SMTP_USER --smtp-pass $SMTP_PASS \ * --smtp-user $SMTP_USER --smtp-pass $SMTP_PASS \
@ -75,6 +76,9 @@ const AMOUNT = args.amount || '50 $'
const EXPIRY = args.expiry || '' const EXPIRY = args.expiry || ''
const SUBJECT = args.subject const SUBJECT = args.subject
const FROM = args.from const FROM = args.from
// Retention commitment used in the template's "Condition" pill and prorata
// disclaimer. Default 3 months. Override with --commitment-months 6 etc.
const COMMITMENT_MONTHS = args['commitment-months'] || '3'
// ── CSV parsing ──────────────────────────────────────────────────────────── // ── CSV parsing ────────────────────────────────────────────────────────────
// Minimal RFC-4180-ish parser. Handles quoted fields with embedded commas // Minimal RFC-4180-ish parser. Handles quoted fields with embedded commas
@ -164,7 +168,18 @@ function matchByEmail (gifts, contacts, urlCol) {
} }
// ── Template rendering ───────────────────────────────────────────────────── // ── Template rendering ─────────────────────────────────────────────────────
// Supports two constructs:
// {{var}} simple substitution
// {{#var}}...{{/var}} section block: kept if var is truthy, dropped otherwise
//
// The section pass runs FIRST so that variable expansion can fill in the
// kept body. Non-greedy match with [\s\S] handles multi-line blocks (HTML
// templates span many lines between the open/close tags).
function render (tpl, vars) { function render (tpl, vars) {
// Pass 1: section blocks (truthy → keep body, falsy → drop everything)
tpl = tpl.replace(/\{\{\s*#\s*(\w+)\s*\}\}([\s\S]*?)\{\{\s*\/\s*\1\s*\}\}/g,
(_, k, body) => (vars[k] ? body : ''))
// Pass 2: simple variable substitution
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => { return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => {
const v = vars[k] const v = vars[k]
return v == null ? '' : String(v) return v == null ? '' : String(v)
@ -254,6 +269,7 @@ async function main () {
gift_url, gift_url,
amount: AMOUNT, amount: AMOUNT,
expiry: EXPIRY, expiry: EXPIRY,
commitment_months: COMMITMENT_MONTHS,
} }
const html = render(tpl, vars) const html = render(tpl, vars)
const ts = new Date().toISOString() const ts = new Date().toISOString()

View File

@ -0,0 +1,200 @@
#!/usr/bin/env node
'use strict'
/**
* setup_mailjet_webhook.js Register the Hub's /campaigns/webhook URL with
* Mailjet's Event API for every event type we care about.
*
* Mailjet's API uses ONE eventcallbackurl record PER event type. We want to
* be notified about: sent, open, click, bounce, blocked, spam, unsub. So this
* script idempotently registers (POST) or updates (PUT) one record per type.
*
* Auth: SMTP_USER + SMTP_PASS env vars (same creds work for the REST API on
* Mailjet they call them API_PUBLIC_KEY / API_PRIVATE_KEY but the values
* are identical to the SMTP credentials).
*
* Usage:
* export SMTP_USER=<MJ_APIKEY_PUBLIC>
* export SMTP_PASS=<MJ_APIKEY_PRIVATE>
* node setup_mailjet_webhook.js --url https://msg.gigafibre.ca/campaigns/webhook
*
* # Production-safe defaults:
* # --is-backup false primary (not backup) callback URL
* # --group-events true send events as a JSON array (recommended,
* # minimizes hub load one POST per ~50 events
* # instead of one POST per event)
*
* To inspect / delete what's registered:
* node setup_mailjet_webhook.js --list
* node setup_mailjet_webhook.js --delete <id>
*/
const https = require('https')
const ALL_EVENTS = ['sent', 'open', 'click', 'bounce', 'blocked', 'spam', 'unsub']
// Safe defaults: only events that aren't typically already claimed by other
// integrations (WP-Mail-SMTP on targo.ca currently owns sent/bounce/blocked
// — see `--list` output). open + click are the events the gift campaign
// actually needs for tracking; spam + unsub are nice-to-have signals.
const SAFE_EVENTS = ['open', 'click', 'spam', 'unsub']
function parseArgs (argv) {
const out = {}
for (let i = 2; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith('--')) {
const k = a.slice(2); const next = argv[i + 1]
if (!next || next.startsWith('--')) out[k] = true
else { out[k] = next; i++ }
}
}
return out
}
function mjApi (method, urlPath, body, { user, pass }) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null
const auth = Buffer.from(`${user}:${pass}`).toString('base64')
const req = https.request({
host: 'api.mailjet.com',
path: '/v3/REST' + urlPath,
method,
headers: {
'Authorization': 'Basic ' + auth,
'Accept': 'application/json',
...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}),
},
}, res => {
let chunks = ''
res.on('data', c => { chunks += c })
res.on('end', () => {
try { resolve({ status: res.statusCode, body: chunks ? JSON.parse(chunks) : {} }) }
catch (e) { resolve({ status: res.statusCode, body: chunks }) }
})
})
req.on('error', reject)
if (data) req.write(data)
req.end()
})
}
async function listCallbacks (creds) {
const r = await mjApi('GET', '/eventcallbackurl?Limit=100', null, creds)
if (r.status !== 200) throw new Error(`GET eventcallbackurl ${r.status}: ${JSON.stringify(r.body)}`)
return r.body.Data || []
}
async function deleteCallback (id, creds) {
const r = await mjApi('DELETE', `/eventcallbackurl/${id}`, null, creds)
return r.status === 204 || r.status === 200
}
async function upsert (eventType, url, isBackup, creds, existing) {
// existing array is the result of GET — find a matching record (same
// EventType + IsBackup combination). Mailjet only allows ONE primary +
// ONE backup URL per event, so this combination is the unique key.
const match = existing.find(r => r.EventType === eventType && Boolean(r.IsBackup) === isBackup)
const payload = { EventType: eventType, IsBackup: isBackup, Url: url, Status: 'alive', Version: 2 }
if (match) {
const r = await mjApi('PUT', `/eventcallbackurl/${match.ID}`, payload, creds)
return { action: 'updated', id: match.ID, status: r.status, ok: r.status === 200 }
} else {
const r = await mjApi('POST', '/eventcallbackurl', payload, creds)
const id = r.body.Data?.[0]?.ID
return { action: 'created', id, status: r.status, ok: r.status === 201 || r.status === 200 }
}
}
async function main () {
const args = parseArgs(process.argv)
const user = process.env.SMTP_USER || process.env.MJ_APIKEY_PUBLIC
const pass = process.env.SMTP_PASS || process.env.MJ_APIKEY_PRIVATE
if (!user || !pass) {
console.error('Set SMTP_USER + SMTP_PASS (or MJ_APIKEY_PUBLIC + MJ_APIKEY_PRIVATE).')
process.exit(1)
}
const creds = { user, pass }
// --list — dump current config and exit
if (args.list) {
const callbacks = await listCallbacks(creds)
console.log(`\n── Registered event callbacks: ${callbacks.length} ──`)
for (const c of callbacks) {
const flag = c.IsBackup ? '[BACKUP]' : '[PRIMARY]'
console.log(` ${flag} id=${c.ID} event=${c.EventType.padEnd(10)} status=${c.Status} v${c.Version}${c.Url}`)
}
process.exit(0)
}
// --delete <id>
if (args.delete && args.delete !== true) {
const ok = await deleteCallback(args.delete, creds)
console.log(ok ? ` ✓ deleted callback ${args.delete}` : ` ✗ delete failed`)
process.exit(ok ? 0 : 1)
}
const url = args.url
if (!url || url === true) {
console.error('Missing --url <callback-url>')
console.error('Example: --url https://msg.gigafibre.ca/campaigns/webhook')
process.exit(1)
}
if (!url.startsWith('https://')) {
console.error('Mailjet requires HTTPS. Got:', url)
process.exit(1)
}
const isBackup = args['is-backup'] === 'true' || args['is-backup'] === true
// Resolve which events to configure
let events
if (args.all) {
events = ALL_EVENTS
} else if (args.events && args.events !== true) {
events = args.events.split(',').map(e => e.trim()).filter(Boolean)
} else {
events = SAFE_EVENTS
}
const existing = await listCallbacks(creds)
// Pre-flight: detect conflicts with existing PRIMARY records pointing
// elsewhere. Refuse to overwrite unless --force-takeover is passed.
const conflicts = events
.map(ev => ({ ev, hit: existing.find(r => r.EventType === ev && Boolean(r.IsBackup) === isBackup) }))
.filter(c => c.hit && c.hit.Url !== url)
console.log(`\n── Mailjet Event API webhook setup ──`)
console.log(` callback URL: ${url}`)
console.log(` type: ${isBackup ? 'BACKUP' : 'PRIMARY'}`)
console.log(` events: ${events.join(', ')}`)
console.log(` existing: ${existing.length} records on the account`)
if (conflicts.length && !args['force-takeover']) {
console.log(`\n ⚠ Conflicts detected — these events already point elsewhere:`)
for (const c of conflicts) {
console.log(`${c.ev.padEnd(10)}${c.hit.Url} (id=${c.hit.ID})`)
}
console.log(`\n Refusing to overwrite without --force-takeover. Either:`)
console.log(` • Exclude the conflicting events: --events open,click`)
console.log(` • Or override the existing config: --force-takeover`)
process.exit(1)
}
console.log()
let okCount = 0
for (const ev of events) {
process.stdout.write(` ${ev.padEnd(10)} ... `)
const r = await upsert(ev, url, isBackup, creds, existing)
if (r.ok) {
okCount++
console.log(`${r.action} (id=${r.id})`)
} else {
console.log(`✗ status=${r.status}`)
}
}
console.log(`\n ${okCount}/${events.length} events configured.`)
console.log(`\n Verify with: node setup_mailjet_webhook.js --list`)
console.log(` Mailjet dashboard: Account Settings → REST API → Event tracking\n`)
}
main().catch(e => { console.error('Fatal:', e); process.exit(1) })

View File

@ -0,0 +1,880 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 620px) {
.u-row {
width: 600px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 600px !important;
}
}
@media only screen and (max-width: 620px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row {
width: 100% !important;
}
.u-row .u-col {
display: block !important;
width: 100% !important;
min-width: 320px !important;
max-width: 100% !important;
}
.u-row .u-col > div {
margin: 0 auto;
}
}
body{margin:0;padding:0}table,td,tr{border-collapse:collapse;vertical-align:top}.ie-container table,.mso-container table{table-layout:fixed}*{line-height:inherit}a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:none!important}
table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: underline; }
</style>
<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700" rel="stylesheet" type="text/css"><!--<![endif]-->
</head>
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24">
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table role="presentation" id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #F5FAF7;width:100%" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="display:none !important;visibility:hidden;mso-hide:all;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
Because great connections aren't just about fiber — they're about people too.
</td>
</tr>
<tr style="vertical-align: top">
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #F5FAF7;" bgcolor="#F5FAF7"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center" style="border-collapse: collapse;"><tr><td style="padding:0;"><![endif]-->
<div class="u-row-container" style="padding: 0px;background-color: transparent;">
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 20px 0px 0px;"><![endif]-->
<div class="u-col u-col-100" style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
<div style="height: 100%;width: 100% !important;">
<!--[if (!mso)&(!IE)]><!--><div style="box-sizing: border-box; height: 100%; padding: 20px 0px 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;"><!--<![endif]-->
<table style="font-family:'Plus Jakarta Sans', sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left">
<div>
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">Because great connections aren't just about fiber — they're about people too.</div>
<div
aria-label="Your exclusive offer from TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
>
<!-- ════════ HEADER LOGO ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:12px 12px 0 0;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border:1px solid #e5e7eb;border-bottom:none;border-radius:12px 12px 0 0;direction:ltr;font-size:0px;padding:28px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:140px;">
<img
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="140" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ GREETING + INTRO (4 blocs sémantiques) ════════
Bloc 1 — Greeting personnalisé
Bloc 2 — Manifeste brand TARGO (2 lignes qui voyagent ensemble)
Bloc 3 — Annonce du cadeau
Bloc 4 — Upsell forfaits + invitation contact
Chaque bloc est un mj-text indépendant = drag-droppable séparément. -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:26px 36px 18px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#374151;"
>Hey {{firstname}},</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;"
>Summer is here, and so is something special — for a limited time.</div>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:500;line-height:1.5;text-align:justify;color:#1B2E24;"
>Thank you for choosing local. Your support helps keep our community connected.<br />
Because great connections aren't just about fiber — they're about people too.</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;">Because our customers trust us, we're now able to offer the <strong>fastest plans around</strong>, with speeds up to <strong>3.5&nbsp;Gbit/s</strong>.<br />
Whether you're looking for more speed, want to beat another offer, or just need to optimize your gear, don't be shy! We're right next door — and we genuinely love lending a hand.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ OPTION 1 CHIP ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:18px 36px 8px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#1B2E24;"
><span style="display:inline-block;background:#E6F9EE;color:#00C853;font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;">✅ Option 1</span></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ COMPACT INFO CARD (was 3 pills) ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:18px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="border-collapse:separate;"
>
<tbody>
<tr>
<td style="background-color:#F5FAF7;border-radius:10px;vertical-align:top;border-collapse:separate;padding:18px 22px;">
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0 0 8px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:700;line-height:1.5;text-align:left;color:#1B2E24;"
>🎁 {{amount}} to spend at hundreds of your favorite stores<br><br><span style="display:inline-block;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/4b0b2a4a-5f99-416c-8873-8d3e4389b6f7/content" width="32" alt="Tim Hortons" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/14df433d-583c-4602-a403-d47ee84966a6/content" width="32" alt="Walmart" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/b8d3db5a-d39e-43ce-a84a-2f5dddbf0192/content" width="32" alt="Home Depot" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/9c9dfa18-2a3a-414a-b5ad-16d490c961b5/content" width="32" alt="IGA" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/a1e5f032-a192-4499-97ba-53b939712fa9/content" width="32" alt="Home Hardware" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><span style="font-size:13px;color:#64748B;vertical-align:middle;font-weight:400;">and more</span></span></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:0 0 4px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>⚡ Instantly available on Giftbit — just click your {{amount}} to claim it!</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>🤝 You just need to keep your subscription for {{commitment_months}} months or more.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ BIG GREEN CTA BUTTON ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:8px 36px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;width:100%;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#00C853" role="presentation" style="border:none;border-radius:12px;cursor:auto;mso-padding-alt:30px 24px;background:#00C853;" valign="middle"
>
<a
href="{{gift_url}}" style="display:inline-block;background:#00C853;color:#ffffff;font-family:Space Grotesk, Helvetica, Arial, sans-serif;font-size:32px;font-weight:700;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:30px 24px;mso-padding-alt:0px;border-radius:12px;" target="_blank"
>
🎁&nbsp;&nbsp;{{amount}}
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="center" class="cta-subtitle" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:center;color:#ffffff;"
><!-- Sub-labels inside the button: not directly supported in mjml-button,
so we render them as a styled text block immediately below.
In the actual rendered output they appear visually under the
button text. --></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ PRORATA REFUND DISCLAIMER ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:10px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#6b7280;"
>🪂 Cancellation before {{commitment_months}} months: only the prorated amount for the remaining months is refundable.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ OPTION 2 CHIP + TEXT ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:18px 36px 6px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#1B2E24;"
><span style="display:inline-block;background:#F5FAF7;color:#6b7280;font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;">⏭️ Option 2</span></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:6px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.55;text-align:left;color:#4b5563;"
>Do nothing. No changes to your current subscription.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ SIGNATURE ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:0 0 12px 12px;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border-bottom:1px solid #e5e7eb;border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;border-radius:0 0 12px 12px;direction:ltr;font-size:0px;padding:18px 36px 28px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.5;text-align:left;color:#1B2E24;"
>🤝 Thanks for helping keep our local economy buzzing!</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:8px 0 0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>The TARGO Team</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ CONTACT INFO (outside card) ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:18px 36px 8px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:528px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:12px;line-height:1.55;text-align:center;color:#64748B;"
>You're getting this email because you're a TARGO customer at <strong style="color:#1B2E24;">{{description}}</strong>.<br />
Got a question? Feel free to email us at
<a href="mailto:support@targo.ca" style="color:#00C853;text-decoration:none;">support@targo.ca</a>
or call us at
<a href="tel:5144480773" style="color:#00C853;text-decoration:none;">514-448-0773</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ DARK FOOTER BAND ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#1C1E26" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#1C1E26;background-color:#1C1E26;margin:0px auto;max-width:600px;border-radius:12px;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#1C1E26;background-color:#1C1E26;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border-radius:12px;direction:ltr;font-size:0px;padding:26px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:528px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:120px;">
<img
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="120" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="center" style="font-size:0px;padding:18px 0 0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.55;text-align:center;color:rgba(255,255,255,0.55);"
><a href="https://www.targo.ca" style="color:rgba(255,255,255,0.7);text-decoration:none;">www.targo.ca</a>
&nbsp;·&nbsp; 1867 ch. de la rivière, Ste-Clotilde, QC<br />
© {{year}} TARGO Communications · All rights reserved.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!--></div><!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,99 +1,880 @@
<!DOCTYPE html> <!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="fr"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
<meta charset="UTF-8"> <!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Un cadeau de Gigafibre</title> <meta name="x-apple-disable-message-reformatting">
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 620px) {
.u-row {
width: 600px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 600px !important;
}
}
@media only screen and (max-width: 620px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row {
width: 100% !important;
}
.u-row .u-col {
display: block !important;
width: 100% !important;
min-width: 320px !important;
max-width: 100% !important;
}
.u-row .u-col > div {
margin: 0 auto;
}
}
body{margin:0;padding:0}table,td,tr{border-collapse:collapse;vertical-align:top}.ie-container table,.mso-container table{table-layout:fixed}*{line-height:inherit}a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:none!important}
table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: underline; }
</style>
<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700" rel="stylesheet" type="text/css"><!--<![endif]-->
</head> </head>
<body style="margin:0; padding:0; background:#f5f6fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; color:#1f2937; line-height:1.5;">
<!-- Spacer above the card --> <body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24">
<div style="height:32px;"></div> <!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"> <table role="presentation" id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #F5FAF7;width:100%" cellpadding="0" cellspacing="0">
<tbody>
<tr> <tr>
<td align="center"> <td style="display:none !important;visibility:hidden;mso-hide:all;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
Comme toi, on aime les connexions stables et les relations durables.
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
style="max-width:600px; background:#ffffff; border-radius:14px; overflow:hidden; box-shadow:0 6px 24px rgba(15,23,42,0.07);">
<!-- Header band -->
<tr>
<td style="background:linear-gradient(135deg,#4f46e5 0%, #7c3aed 100%); padding:36px 32px 28px; text-align:center; color:#ffffff;">
<div style="font-size:0.78rem; font-weight:700; letter-spacing:0.12em; text-transform:uppercase; opacity:0.85;">
Gigafibre &middot; Récompense
</div>
<div style="font-size:2.2rem; line-height:1.1; margin-top:10px; font-weight:800;">
🎁 Un cadeau pour vous
</div>
</td> </td>
</tr> </tr>
<!-- Body --> <tr style="vertical-align: top">
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #F5FAF7;" bgcolor="#F5FAF7"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center" style="border-collapse: collapse;"><tr><td style="padding:0;"><![endif]-->
<div class="u-row-container" style="padding: 0px;background-color: transparent;">
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 20px 0px 0px;"><![endif]-->
<div class="u-col u-col-100" style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
<div style="height: 100%;width: 100% !important;">
<!--[if (!mso)&(!IE)]><!--><div style="box-sizing: border-box; height: 100%; padding: 20px 0px 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;"><!--<![endif]-->
<table style="font-family:'Plus Jakarta Sans', sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr> <tr>
<td style="padding:36px 36px 12px;"> <td style="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left">
<p style="margin:0 0 16px; font-size:1.05rem;">Bonjour {{firstname}},</p>
<p style="margin:0 0 16px;"> <div>
Merci de faire partie de la famille Gigafibre. Pour vous remercier <div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">Comme toi, on aime les connexions stables et les relations durables.</div>
de votre fidélité, voici une carte-cadeau d'une valeur de
<strong>{{amount}}</strong>, utilisable sur les marchands de votre choix.
</p>
<!-- CTA button --> <div
<div style="text-align:center; margin:32px 0 28px;"> aria-label="Une offre exclusive de TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
<a href="{{gift_url}}" >
style="display:inline-block; padding:16px 36px; background:#4f46e5; color:#ffffff; <!-- ════════ HEADER LOGO ════════ -->
text-decoration:none; font-weight:700; font-size:1.05rem;
border-radius:10px; box-shadow:0 4px 12px rgba(79,70,229,0.35);"> <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
Récupérer mon cadeau →
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:12px 12px 0 0;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border:1px solid #e5e7eb;border-bottom:none;border-radius:12px 12px 0 0;direction:ltr;font-size:0px;padding:28px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:140px;">
<img
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="140" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ GREETING + INTRO (4 blocs sémantiques) ════════
Bloc 1 — Greeting personnalisé
Bloc 2 — Manifeste brand TARGO (2 lignes qui voyagent ensemble)
Bloc 3 — Annonce du cadeau
Bloc 4 — Upsell forfaits + invitation contact
Chaque bloc est un mj-text indépendant = drag-droppable séparément. -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:26px 36px 18px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#374151;"
>Bonjour {{firstname}},</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;"
>Avec l'arrivée de l'été, voici un <strong>cadeau pour toi, disponible pour un temps limité</strong>.</div>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:500;line-height:1.5;text-align:justify;color:#1B2E24;"
>On veut te remercier pour ta loyauté envers l'achat local.<br />
Comme toi, on aime les connexions stables et les relations durables.</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;">Grâce à la confiance de nos clients, on offre maintenant les forfaits à <strong>la plus haute vitesse dans le secteur</strong>, jusqu'à <strong>3.5&nbsp;Gbit/s</strong>.<br />
Que tu souhaites plus de vitesse, battre une autre offre ou faire optimiser des équipements, n'hésite pas. On est juste à côté et on aime aider.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ OPTION 1 CHIP ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:18px 36px 8px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#1B2E24;"
><span style="display:inline-block;background:#E6F9EE;color:#00C853;font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;">✅ Option 1</span></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ COMPACT INFO CARD (was 3 pills) ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:18px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="border-collapse:separate;"
>
<tbody>
<tr>
<td style="background-color:#F5FAF7;border-radius:10px;vertical-align:top;border-collapse:separate;padding:18px 22px;">
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0 0 8px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:700;line-height:1.5;text-align:left;color:#1B2E24;"
>🎁 {{amount}} chez des centaines de marques<br><br><span style="display:inline-block;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/4b0b2a4a-5f99-416c-8873-8d3e4389b6f7/content" width="32" alt="Tim Hortons" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/14df433d-583c-4602-a403-d47ee84966a6/content" width="32" alt="Walmart" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/b8d3db5a-d39e-43ce-a84a-2f5dddbf0192/content" width="32" alt="Home Depot" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/9c9dfa18-2a3a-414a-b5ad-16d490c961b5/content" width="32" alt="IGA" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/a1e5f032-a192-4499-97ba-53b939712fa9/content" width="32" alt="Home Hardware" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><span style="font-size:13px;color:#64748B;vertical-align:middle;font-weight:400;">et plus</span></span></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:0 0 4px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>⚡ Disponible instantanément sur Giftbit en cliquant sur ton montant</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>🤝 Condition : Maintenir l'abonnement {{commitment_months}} mois ou +</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ BIG GREEN CTA BUTTON ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:8px 36px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;width:100%;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#00C853" role="presentation" style="border:none;border-radius:12px;cursor:auto;mso-padding-alt:30px 24px;background:#00C853;" valign="middle"
>
<a
href="{{gift_url}}" style="display:inline-block;background:#00C853;color:#ffffff;font-family:Space Grotesk, Helvetica, Arial, sans-serif;font-size:32px;font-weight:700;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:30px 24px;mso-padding-alt:0px;border-radius:12px;" target="_blank"
>
🎁&nbsp;&nbsp;{{amount}}
</a> </a>
</div>
<p style="margin:0 0 6px; font-size:0.9rem; color:#6b7280;">
Le lien vous mène à une page sécurisée où vous pourrez choisir la
marque qui vous fait plaisir (Amazon, Tim Hortons, SAQ, App Store,
et plusieurs autres).
</p>
{{#expiry}}
<p style="margin:6px 0 0; font-size:0.85rem; color:#9ca3af;">
⏰ Le lien expire le <strong>{{expiry}}</strong>.
</p>
{{/expiry}}
</td> </td>
</tr> </tr>
</tbody>
<!-- Why this email -->
<tr>
<td style="padding:0 36px 28px;">
<div style="border-top:1px solid #e5e7eb; padding-top:20px; font-size:0.82rem; color:#6b7280;">
Vous recevez ce cadeau parce que vous êtes client(e) Gigafibre à
l'adresse <strong style="color:#374151;">{{description}}</strong>.
Si vous avez la moindre question, écrivez-nous à
<a href="mailto:facturation@targointernet.com" style="color:#4f46e5;">facturation@targointernet.com</a>
ou appelez-nous au <a href="tel:5142421500" style="color:#4f46e5;">514 242-1500</a>.
</div>
</td>
</tr>
<!-- Footer band -->
<tr>
<td style="background:#f9fafb; padding:18px 32px; text-align:center; border-top:1px solid #e5e7eb;">
<div style="font-size:0.75rem; color:#9ca3af;">
Gigafibre — Internet fibre optique au Québec<br>
<a href="https://www.gigafibre.ca" style="color:#9ca3af; text-decoration:underline;">www.gigafibre.ca</a>
</div>
</td>
</tr>
</table> </table>
</td> </td>
</tr> </tr>
<tr>
<td
align="center" class="cta-subtitle" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:center;color:#ffffff;"
><!-- Sub-labels inside the button: not directly supported in mjml-button,
so we render them as a styled text block immediately below.
In the actual rendered output they appear visually under the
button text. --></div>
</td>
</tr>
</tbody>
</table> </table>
<!-- Spacer below the card --> </div>
<div style="height:48px;"></div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ PRORATA REFUND DISCLAIMER ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:10px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#6b7280;"
>🪂 Annulation avant {{commitment_months}} mois : seulement à rembourser au prorata des mois restants.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ OPTION 2 CHIP + TEXT ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:18px 36px 6px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#1B2E24;"
><span style="display:inline-block;background:#F5FAF7;color:#6b7280;font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;">⏭️ Option 2</span></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:6px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.55;text-align:left;color:#4b5563;"
>Ne rien faire. Aucun changement à ton abonnement actuel.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ SIGNATURE ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:0 0 12px 12px;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border-bottom:1px solid #e5e7eb;border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;border-radius:0 0 12px 12px;direction:ltr;font-size:0px;padding:18px 36px 28px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.5;text-align:left;color:#1B2E24;"
>🤝 Merci de faire rouler l'économie de notre région avec nous !</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:8px 0 0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>L'équipe TARGO</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ CONTACT INFO (outside card) ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:18px 36px 8px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:528px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:12px;line-height:1.55;text-align:center;color:#64748B;"
>Tu reçois ce courriel parce que tu es client(e) TARGO à <strong style="color:#1B2E24;">{{description}}</strong>.<br />
Une question ? N'hésite pas à nous écrire à
<a href="mailto:support@targo.ca" style="color:#00C853;text-decoration:none;">support@targo.ca</a>
ou nous appeler au
<a href="tel:5144480773" style="color:#00C853;text-decoration:none;">514-448-0773</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ DARK FOOTER BAND ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#1C1E26" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#1C1E26;background-color:#1C1E26;margin:0px auto;max-width:600px;border-radius:12px;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#1C1E26;background-color:#1C1E26;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border-radius:12px;direction:ltr;font-size:0px;padding:26px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:528px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:120px;">
<img
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="120" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="center" style="font-size:0px;padding:18px 0 0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.55;text-align:center;color:rgba(255,255,255,0.55);"
><a href="https://www.targo.ca" style="color:rgba(255,255,255,0.7);text-decoration:none;">www.targo.ca</a>
&nbsp;·&nbsp; 1867 ch. de la rivière, Ste-Clotilde, QC<br />
© {{year}} TARGO Communications · Tous droits réservés.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!--></div><!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

5
services/email-editor/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
.DS_Store
*.log

View File

@ -0,0 +1,52 @@
# Multi-stage Dockerfile for the email-editor microservice.
# Stage 1: Vite build into dist/
# Stage 2: nginx serving the static files
# ── Stage 1: build ──
FROM node:20-alpine AS builder
WORKDIR /app
# Install only what's needed for build (no native deps required)
COPY package.json package-lock.json* ./
RUN npm install --silent
COPY tsconfig.json vite.config.ts index.html ./
COPY src ./src
# Inline the prod hub URL at build time. Override via --build-arg on docker
# build if running against a different hub (e.g. staging).
ARG VITE_HUB_URL=https://msg.gigafibre.ca
ENV VITE_HUB_URL=$VITE_HUB_URL
RUN npm run build
# ── Stage 2: nginx serve ──
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# Drop the default nginx config and inject a minimal SPA-friendly one
# (all routes serve index.html — easy-email is a SPA, query params drive
# which template to load).
RUN rm /etc/nginx/conf.d/default.conf
COPY <<EOF /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Long cache for hashed assets (vite outputs them with content hash in name)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — every request returns index.html so the React app handles
# routing client-side based on ?name=... query
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
EXPOSE 80

View File

@ -0,0 +1,63 @@
# TARGO Email Editor
Standalone email template editor microservice — React + Vite + [easy-email](https://github.com/zalify/easy-email)
(OSS WYSIWYG email builder, MJML-based).
Embedded as an iframe in the ops UI's `/campaigns/templates/:name` page.
Talks to the hub's `/campaigns/templates/*` REST endpoints for load/save.
## Architecture
```
ops UI (Vue) → iframe → editor.gigafibre.ca → REST → msg.gigafibre.ca (hub)
└─ writes .mjml + .html
to /opt/targo-hub/templates/
```
## URL params
- `?name=gift-email-fr` — which template to load (defaults to gift-email-fr)
## postMessage protocol (to parent window)
Emitted on save success:
```js
{ type: 'email-editor:saved', template: 'gift-email-fr', ts: 1700000000000 }
```
The parent ops UI listens via:
```js
window.addEventListener('message', (e) => {
if (e.data.type === 'email-editor:saved') {
// refresh preview / show toast
}
})
```
## Local dev
```bash
npm install
npm run dev # http://localhost:5173?name=gift-email-fr
```
Set `VITE_HUB_URL` env to point at a non-prod hub if needed.
## Build + deploy
```bash
docker compose build
docker compose up -d
```
Traefik auto-provisions HTTPS at `editor.gigafibre.ca` (Let's Encrypt via
the `letsencrypt` resolver shared with the rest of our stack).
## Known limitations (Phase 1)
- Existing MJML templates from the hub are NOT auto-imported into the
easy-email JSON tree (no MJML → JSON parser in easy-email out of the box).
The editor starts from an empty page. User rebuilds visually with the
hub's compiled HTML as visual reference.
- TODO Phase 3: integrate an MJML → easy-email-JSON parser (likely fork or
reverse-engineer JsonToMjml).

View File

@ -0,0 +1,28 @@
services:
email-editor:
build:
context: .
args:
# Override at build time if pointing at staging:
# docker compose build --build-arg VITE_HUB_URL=https://staging-msg.gigafibre.ca
VITE_HUB_URL: https://msg.gigafibre.ca
container_name: email-editor
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
# Public route — same Authentik forwardAuth pattern as ops UI could be
# added here too, but for now the editor is iframed from the (already
# authenticated) ops UI so external auth is layered through the parent.
# Leaving it open means anyone with the URL can edit templates — fine
# for the iframe-only use case; harden later if exposed standalone.
- "traefik.http.routers.email-editor.rule=Host(`editor.gigafibre.ca`)"
- "traefik.http.routers.email-editor.entrypoints=websecure"
- "traefik.http.routers.email-editor.tls.certresolver=letsencrypt"
- "traefik.http.services.email-editor.loadbalancer.server.port=80"
networks:
proxy:
external: true

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TARGO email editor</title>
<!-- easy-email's required styles. Order matters: extensions first, then editor. -->
<link rel="stylesheet" href="https://unpkg.com/easy-email-editor@4.16.6/lib/style.css" />
<link rel="stylesheet" href="https://unpkg.com/easy-email-extensions@4.16.5/lib/style.css" />
<link rel="stylesheet" href="https://unpkg.com/@arco-themes/react-easy-email-theme/css/arco.css" />
</head>
<body style="margin:0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3219
services/email-editor/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"name": "targo-email-editor",
"version": "1.0.0",
"description": "Standalone email editor microservice — easy-email (React) embedded via iframe in the ops UI. Talks to the targo-hub /campaigns/templates/* endpoints to load and save campaign templates.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"easy-email-core": "^4.16.5",
"easy-email-editor": "^4.16.6",
"easy-email-extensions": "^4.16.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}

View File

@ -0,0 +1,234 @@
import React, { useEffect, useState, useCallback } from 'react'
import { EmailEditorProvider, EmailEditor, IEmailTemplate } from 'easy-email-editor'
import { ExtensionProps, StandardLayout } from 'easy-email-extensions'
import { BasicType, AdvancedType, JsonToMjml } from 'easy-email-core'
// ─────────────────────────────────────────────────────────────────────────────
// Targo email editor — wraps easy-email-editor with our hub integration:
//
// 1. On mount: read ?name=<template-name> from URL, GET its MJML from the hub
// 2. Render easy-email with the loaded MJML
// 3. On save (Cmd-S or button): convert easy-email JSON → MJML, PUT to hub
// 4. postMessage to parent window so the wrapping ops UI knows we saved
//
// Hub URL is read from VITE_HUB_URL env (defaults to msg.gigafibre.ca in prod).
// The hub does the MJML → HTML compilation server-side; we just send the MJML.
// ─────────────────────────────────────────────────────────────────────────────
const HUB_URL = (import.meta as any).env?.VITE_HUB_URL || 'https://msg.gigafibre.ca'
// Merge tags exposed to the editor's "Variables" panel. These map to the
// Mustache variables the hub renders at send time.
const MERGE_TAGS = {
firstname: '{{firstname}}',
lastname: '{{lastname}}',
email: '{{email}}',
amount: '{{amount}}',
gift_url: '{{gift_url}}',
description: '{{description}}',
expiry: '{{expiry}}',
commitment_months: '{{commitment_months}}',
year: '{{year}}',
}
// Minimal initial template returned when the hub has no content yet (rare —
// since we always pre-create gift-email-fr.mjml). Kept defensive.
function emptyTemplate(): IEmailTemplate {
return {
subject: 'Une offre exclusive de TARGO',
subTitle: 'Comme toi, on aime les connexions stables et les relations durables.',
content: {
type: BasicType.PAGE,
data: {
value: {
breakpoint: '480px',
headAttributes: '',
'font-size': '16px',
'line-height': '1.5',
'font-family': "'Plus Jakarta Sans', Helvetica, Arial, sans-serif",
},
},
attributes: {
'background-color': '#F5FAF7',
width: '600px',
},
children: [],
} as any,
}
}
export function EmailEditorApp() {
const [templateName, setTemplateName] = useState<string>('')
const [initialValues, setInitialValues] = useState<IEmailTemplate | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// Read template name from URL and fetch its MJML content from the hub on mount
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const name = params.get('name') || 'gift-email-fr'
setTemplateName(name)
;(async () => {
try {
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`)
if (!res.ok) throw new Error(`Hub returned ${res.status}`)
const data = await res.json()
// Three sources of truth, in priority order:
// 1. .json file → easy-email JSON tree (fast, full restore)
// 2. .mjml file → MJML source (no auto-importer, start blank)
// 3. nothing → empty page
if (data.json) {
try {
const parsed = typeof data.json === 'string' ? JSON.parse(data.json) : data.json
setInitialValues(parsed as IEmailTemplate)
} catch (e: any) {
setError(`Stored JSON is invalid (${e.message}) — starting blank`)
setInitialValues(emptyTemplate())
}
} else if (data.mjml) {
setError(`Existing MJML (${(data.mjml || '').length}b) cannot be auto-imported into easy-email. ` +
`Reconstructing this template once with the drag-drop blocks here will save an editable JSON snapshot for next time.`)
setInitialValues(emptyTemplate())
} else {
setInitialValues(emptyTemplate())
}
} catch (e: any) {
setError(`Could not load template "${name}": ${e.message}`)
setInitialValues(emptyTemplate())
} finally {
setLoading(false)
}
})()
}, [])
// Save → convert easy-email's JSON tree to MJML, PUT to hub
const onSave = useCallback(async (values: IEmailTemplate) => {
if (!templateName) return
setSaving(true)
setError(null)
try {
const mjmlSource = JsonToMjml({
data: values.content as any,
mode: 'production',
context: values.content as any,
})
// Send BOTH the compiled MJML (for send-worker) AND the raw easy-email
// JSON tree (for next-load restore). Hub persists .mjml + .html + .json
// — the JSON file is the canonical editing source going forward.
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mjml: mjmlSource, json: values }),
})
if (!res.ok) {
const errBody = await res.json().catch(() => ({}))
throw new Error(errBody.error || `Hub returned ${res.status}`)
}
// Notify parent window (the ops UI iframing us) that we saved
if (window.parent !== window) {
window.parent.postMessage(
{ type: 'email-editor:saved', template: templateName, ts: Date.now() },
'*',
)
}
// Visual confirmation (toast handled by easy-email's own UI)
} catch (e: any) {
setError(`Save failed: ${e.message}`)
} finally {
setSaving(false)
}
}, [templateName])
if (loading) {
return <div style={{ padding: 32, textAlign: 'center' }}>Loading template</div>
}
if (!initialValues) {
return <div style={{ padding: 32, color: 'red' }}>{error}</div>
}
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Top bar — shows template name + save state + parent communication */}
<div style={{
padding: '8px 16px',
background: '#1B2E24',
color: '#fff',
fontSize: 14,
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
<strong>TARGO Email Editor</strong>
<span style={{ opacity: 0.7 }}>· {templateName}</span>
{saving && <span style={{ color: '#00C853' }}>· Saving</span>}
{error && (
<span style={{ color: '#fbbf24', fontSize: 12, marginLeft: 'auto', maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{error}
</span>
)}
</div>
{/* Editor */}
<div style={{ flex: 1, overflow: 'hidden' }}>
<EmailEditorProvider
data={initialValues}
height="100%"
autoComplete
dashed={false}
mergeTags={MERGE_TAGS}
mergeTagGenerate={(tag: string) => `{{${tag}}}`}
onSubmit={onSave}
>
{() => (
<StandardLayout
showSourceCode
categories={DEFAULT_CATEGORIES}
/>
)}
</EmailEditorProvider>
</div>
</div>
)
}
// Block categories shown in the left sidebar — same set easy-email uses by
// default, organized for email composition.
const DEFAULT_CATEGORIES: ExtensionProps['categories'] = [
{
label: 'Content',
active: true,
blocks: [
{ type: AdvancedType.TEXT },
{ type: AdvancedType.BUTTON },
{ type: AdvancedType.IMAGE },
{ type: AdvancedType.DIVIDER },
{ type: AdvancedType.SPACER },
{ type: AdvancedType.HERO },
{ type: AdvancedType.WRAPPER },
],
},
{
label: 'Layout',
active: true,
displayType: 'column',
blocks: [
{
title: '1 column',
payload: [['100%']],
},
{
title: '2 columns',
payload: [['50%', '50%']],
},
{
title: '3 columns',
payload: [['33%', '33%', '33%']],
},
{
title: '4 columns',
payload: [['25%', '25%', '25%', '25%']],
},
],
},
]

View File

@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { EmailEditorApp } from './EmailEditorApp'
// Entry point — mounts the easy-email editor app on #root.
// Template name comes from URL query: ?name=gift-email-fr
// In production the page lives at editor.gigafibre.ca and is iframed from
// the ops UI's /campaigns/templates/:name route.
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<EmailEditorApp />
</React.StrictMode>,
)

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Vite config — served at editor.gigafibre.ca behind Traefik in prod.
// In dev: `npm run dev` exposes http://localhost:5173.
// Base path is '/' since this is a standalone microservice (own domain), not
// a sub-path of another app.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: '0.0.0.0', // accessible from outside container in dev
},
build: {
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
},
})

View File

@ -9,6 +9,12 @@ services:
- ./public:/app/public:ro - ./public:/app/public:ro
- ./package.json:/app/package.json:ro - ./package.json:/app/package.json:ro
- ./data:/app/data - ./data:/app/data
# Templates RW so the campaign editor can save .html + .json + .mjml
# files via PUT /campaigns/templates/:name. Was :ro previously which
# broke save with EROFS — fixed when Unlayer started writing back.
- ./templates:/app/templates
# User-uploaded assets (images dragged into the editor)
- ./uploads:/app/uploads
- hub_modules:/app/node_modules - hub_modules:/app/node_modules
command: sh -c "npm install --production 2>&1 | tail -1 && node server.js" command: sh -c "npm install --production 2>&1 | tail -1 && node server.js"
env_file: .env env_file: .env

File diff suppressed because it is too large Load Diff

View File

@ -47,11 +47,15 @@ async function sendEmail (opts) {
} }
const mailOpts = { const mailOpts = {
from: cfg.MAIL_FROM, from: opts.from || cfg.MAIL_FROM,
to: opts.to, to: opts.to,
subject: opts.subject, subject: opts.subject,
html: opts.html, html: opts.html,
attachments: [], attachments: [],
// Custom headers (e.g. X-MJ-CustomID for Mailjet Event API webhook
// correlation — Mailjet echoes the CustomID back in every event so
// we can match webhook events to the originating recipient).
headers: opts.headers || {},
} }
if (opts.pdfBuffer && opts.pdfFilename) { if (opts.pdfBuffer && opts.pdfFilename) {
@ -65,9 +69,16 @@ async function sendEmail (opts) {
try { try {
const info = await transport.sendMail(mailOpts) const info = await transport.sendMail(mailOpts)
log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`) log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`)
return true // Return the info object (always truthy) so callers can capture
// info.messageId for tracking. Legacy `if (await sendEmail(...))`
// callers continue to work because the object is truthy.
return info || { messageId: null }
} catch (e) { } catch (e) {
log(`Email send failed to ${opts.to}: ${e.message}`) log(`Email send failed to ${opts.to}: ${e.message}`)
// Legacy contract: return false on failure. New callers that need the
// error string should check `Promise.allSettled` style or wrap in try
// (we don't throw here to preserve existing `if (await sendEmail(...))`
// call sites). The error is logged above.
return false return false
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -7,13 +7,14 @@
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"mjml": "^5.2.2",
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"mqtt": "^5.15.1", "mqtt": "^5.15.1",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"net-snmp": "^3.26.1",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"pg": "^8.13.0", "pg": "^8.13.0",
"twilio": "^5.5.0", "twilio": "^5.5.0",
"web-push": "^3.6.7", "web-push": "^3.6.7"
"net-snmp": "^3.26.1"
} }
} }

View File

@ -0,0 +1,147 @@
#!/usr/bin/env node
'use strict'
/**
* convert-html-to-unlayer.js one-time converter from our existing compiled
* .html templates into Unlayer design JSON. Run after MJMLUnlayer migration
* so the visual editor loads existing templates instead of starting blank.
*
* Strategy: wrap the entire HTML body content in a single "Custom HTML" block
* inside a minimal Unlayer design. This is the MIN VIABLE conversion the
* template renders correctly in the canvas, the user can edit the HTML
* directly, and they can incrementally replace the HTML block with native
* Unlayer blocks (Text, Image, Button) on their own schedule.
*
* Usage:
* node convert-html-to-unlayer.js gift-email-fr
* node convert-html-to-unlayer.js gift-email-en
*/
const fs = require('fs')
const path = require('path')
function htmlToUnlayer (innerBodyHtml, opts = {}) {
const preheader = opts.preheader || ''
return {
counters: { u_row: 1, u_column: 1, u_content_html: 1 },
body: {
id: 'BODY-1',
rows: [
{
id: 'ROW-1',
cells: [1],
columns: [
{
id: 'COL-1',
contents: [
{
id: 'HTML-1',
type: 'html',
values: {
html: innerBodyHtml,
hideDesktop: false,
displayCondition: null,
containerPadding: '0px',
_meta: { htmlID: 'u_content_html_1', htmlClassNames: 'u_content_html' },
selectable: true,
draggable: true,
duplicatable: true,
deletable: true,
hideable: true,
},
},
],
values: {
_meta: { htmlID: 'u_column_1', htmlClassNames: 'u_column' },
},
},
],
values: {
displayCondition: null,
columns: false,
backgroundColor: '',
columnsBackgroundColor: '',
padding: '0px',
anchor: '',
hideDesktop: false,
_meta: { htmlID: 'u_row_1', htmlClassNames: 'u_row' },
selectable: true,
draggable: true,
duplicatable: true,
deletable: true,
hideable: true,
},
},
],
values: {
popupPosition: 'center',
popupWidth: '600px',
popupHeight: 'auto',
borderRadius: '10px',
contentAlign: 'center',
contentVerticalAlign: 'center',
contentWidth: '600px',
fontFamily: {
label: 'Plus Jakarta Sans',
value: "'Plus Jakarta Sans', sans-serif",
url: 'https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700',
},
textColor: '#1B2E24',
popupBackgroundColor: '#FFFFFF',
backgroundColor: '#F5FAF7',
preheaderText: preheader,
linkStyle: {
body: true,
linkColor: '#00C853',
linkHoverColor: '#005026',
linkUnderline: true,
linkHoverUnderline: true,
},
_meta: { htmlID: 'u_body', htmlClassNames: 'u_body' },
},
},
schemaVersion: 12,
}
}
// ── CLI ──────────────────────────────────────────────────────────────────
const name = process.argv[2]
if (!name) {
console.error('Usage: convert-html-to-unlayer.js <template-name>')
process.exit(1)
}
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates')
const htmlPath = path.join(TEMPLATES_DIR, name + '.html')
const jsonPath = path.join(TEMPLATES_DIR, name + '.json')
if (!fs.existsSync(htmlPath)) {
console.error(`✗ No HTML at ${htmlPath}`)
process.exit(1)
}
const fullHtml = fs.readFileSync(htmlPath, 'utf8')
// Extract just the body inner content — Unlayer wraps everything in its own
// <html><head><body> at preview/export time, so we don't want duplicated
// doctype/head/body tags.
const bodyMatch = fullHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
const innerHtml = bodyMatch ? bodyMatch[1].trim() : fullHtml
// Pull preheader text if a hidden <div style="display:none"> is present
// (standard email preheader pattern, also what MJML's <mj-preview> compiles to)
const preheaderMatch = innerHtml.match(/<div[^>]*display:\s*none[^>]*>\s*([^<]+?)\s*<\/div>/i)
const preheader = preheaderMatch ? preheaderMatch[1].trim() : ''
const design = htmlToUnlayer(innerHtml, { preheader })
// Optional: backup existing .json
if (fs.existsSync(jsonPath)) {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
}
fs.writeFileSync(jsonPath, JSON.stringify(design, null, 2), 'utf8')
console.log(`✓ Converted ${name}.html (${fullHtml.length}b) → ${name}.json (${JSON.stringify(design).length}b)`)
console.log(` preheader: "${preheader.slice(0, 80)}${preheader.length > 80 ? '…' : ''}"`)
console.log(` inner HTML: ${innerHtml.length}b in one Custom HTML block`)

View File

@ -119,6 +119,7 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path) if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path) if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url) if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path) if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url) if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res) if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)

View File

@ -0,0 +1,880 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 620px) {
.u-row {
width: 600px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 600px !important;
}
}
@media only screen and (max-width: 620px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row {
width: 100% !important;
}
.u-row .u-col {
display: block !important;
width: 100% !important;
min-width: 320px !important;
max-width: 100% !important;
}
.u-row .u-col > div {
margin: 0 auto;
}
}
body{margin:0;padding:0}table,td,tr{border-collapse:collapse;vertical-align:top}.ie-container table,.mso-container table{table-layout:fixed}*{line-height:inherit}a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:none!important}
table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: underline; }
</style>
<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700" rel="stylesheet" type="text/css"><!--<![endif]-->
</head>
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24">
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table role="presentation" id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #F5FAF7;width:100%" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="display:none !important;visibility:hidden;mso-hide:all;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
Because great connections aren't just about fiber — they're about people too.
</td>
</tr>
<tr style="vertical-align: top">
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #F5FAF7;" bgcolor="#F5FAF7"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center" style="border-collapse: collapse;"><tr><td style="padding:0;"><![endif]-->
<div class="u-row-container" style="padding: 0px;background-color: transparent;">
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 20px 0px 0px;"><![endif]-->
<div class="u-col u-col-100" style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
<div style="height: 100%;width: 100% !important;">
<!--[if (!mso)&(!IE)]><!--><div style="box-sizing: border-box; height: 100%; padding: 20px 0px 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;"><!--<![endif]-->
<table style="font-family:'Plus Jakarta Sans', sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left">
<div>
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">Because great connections aren't just about fiber — they're about people too.</div>
<div
aria-label="Your exclusive offer from TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
>
<!-- ════════ HEADER LOGO ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:12px 12px 0 0;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border:1px solid #e5e7eb;border-bottom:none;border-radius:12px 12px 0 0;direction:ltr;font-size:0px;padding:28px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:140px;">
<img
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="140" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ GREETING + INTRO (4 blocs sémantiques) ════════
Bloc 1 — Greeting personnalisé
Bloc 2 — Manifeste brand TARGO (2 lignes qui voyagent ensemble)
Bloc 3 — Annonce du cadeau
Bloc 4 — Upsell forfaits + invitation contact
Chaque bloc est un mj-text indépendant = drag-droppable séparément. -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:26px 36px 18px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#374151;"
>Hey {{firstname}},</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;"
>Summer is here, and so is something special — for a limited time.</div>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:500;line-height:1.5;text-align:justify;color:#1B2E24;"
>Thank you for choosing local. Your support helps keep our community connected.<br />
Because great connections aren't just about fiber — they're about people too.</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;">Because our customers trust us, we're now able to offer the <strong>fastest plans around</strong>, with speeds up to <strong>3.5&nbsp;Gbit/s</strong>.<br />
Whether you're looking for more speed, want to beat another offer, or just need to optimize your gear, don't be shy! We're right next door — and we genuinely love lending a hand.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ OPTION 1 CHIP ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:18px 36px 8px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#1B2E24;"
><span style="display:inline-block;background:#E6F9EE;color:#00C853;font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;">✅ Option 1</span></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ COMPACT INFO CARD (was 3 pills) ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:18px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="border-collapse:separate;"
>
<tbody>
<tr>
<td style="background-color:#F5FAF7;border-radius:10px;vertical-align:top;border-collapse:separate;padding:18px 22px;">
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0 0 8px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:700;line-height:1.5;text-align:left;color:#1B2E24;"
>🎁 {{amount}} to spend at hundreds of your favorite stores<br><br><span style="display:inline-block;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/4b0b2a4a-5f99-416c-8873-8d3e4389b6f7/content" width="32" alt="Tim Hortons" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/14df433d-583c-4602-a403-d47ee84966a6/content" width="32" alt="Walmart" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/b8d3db5a-d39e-43ce-a84a-2f5dddbf0192/content" width="32" alt="Home Depot" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/9c9dfa18-2a3a-414a-b5ad-16d490c961b5/content" width="32" alt="IGA" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/a1e5f032-a192-4499-97ba-53b939712fa9/content" width="32" alt="Home Hardware" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><span style="font-size:13px;color:#64748B;vertical-align:middle;font-weight:400;">and more</span></span></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:0 0 4px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>⚡ Instantly available on Giftbit — just click your {{amount}} to claim it!</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>🤝 You just need to keep your subscription for {{commitment_months}} months or more.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ BIG GREEN CTA BUTTON ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:8px 36px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;width:100%;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#00C853" role="presentation" style="border:none;border-radius:12px;cursor:auto;mso-padding-alt:30px 24px;background:#00C853;" valign="middle"
>
<a
href="{{gift_url}}" style="display:inline-block;background:#00C853;color:#ffffff;font-family:Space Grotesk, Helvetica, Arial, sans-serif;font-size:32px;font-weight:700;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:30px 24px;mso-padding-alt:0px;border-radius:12px;" target="_blank"
>
🎁&nbsp;&nbsp;{{amount}}
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="center" class="cta-subtitle" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:center;color:#ffffff;"
><!-- Sub-labels inside the button: not directly supported in mjml-button,
so we render them as a styled text block immediately below.
In the actual rendered output they appear visually under the
button text. --></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ PRORATA REFUND DISCLAIMER ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:10px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#6b7280;"
>🪂 Cancellation before {{commitment_months}} months: only the prorated amount for the remaining months is refundable.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ OPTION 2 CHIP + TEXT ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:18px 36px 6px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#1B2E24;"
><span style="display:inline-block;background:#F5FAF7;color:#6b7280;font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;">⏭️ Option 2</span></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:6px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.55;text-align:left;color:#4b5563;"
>Do nothing. No changes to your current subscription.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ SIGNATURE ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:0 0 12px 12px;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border-bottom:1px solid #e5e7eb;border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;border-radius:0 0 12px 12px;direction:ltr;font-size:0px;padding:18px 36px 28px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.5;text-align:left;color:#1B2E24;"
>🤝 Thanks for helping keep our local economy buzzing!</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:8px 0 0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>The TARGO Team</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ CONTACT INFO (outside card) ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:18px 36px 8px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:528px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:12px;line-height:1.55;text-align:center;color:#64748B;"
>You're getting this email because you're a TARGO customer at <strong style="color:#1B2E24;">{{description}}</strong>.<br />
Got a question? Feel free to email us at
<a href="mailto:support@targo.ca" style="color:#00C853;text-decoration:none;">support@targo.ca</a>
or call us at
<a href="tel:5144480773" style="color:#00C853;text-decoration:none;">514-448-0773</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ DARK FOOTER BAND ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#1C1E26" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#1C1E26;background-color:#1C1E26;margin:0px auto;max-width:600px;border-radius:12px;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#1C1E26;background-color:#1C1E26;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border-radius:12px;direction:ltr;font-size:0px;padding:26px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:528px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:120px;">
<img
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="120" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="center" style="font-size:0px;padding:18px 0 0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.55;text-align:center;color:rgba(255,255,255,0.55);"
><a href="https://www.targo.ca" style="color:rgba(255,255,255,0.7);text-decoration:none;">www.targo.ca</a>
&nbsp;·&nbsp; 1867 ch. de la rivière, Ste-Clotilde, QC<br />
© {{year}} TARGO Communications · All rights reserved.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!--></div><!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,880 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 620px) {
.u-row {
width: 600px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 600px !important;
}
}
@media only screen and (max-width: 620px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row {
width: 100% !important;
}
.u-row .u-col {
display: block !important;
width: 100% !important;
min-width: 320px !important;
max-width: 100% !important;
}
.u-row .u-col > div {
margin: 0 auto;
}
}
body{margin:0;padding:0}table,td,tr{border-collapse:collapse;vertical-align:top}.ie-container table,.mso-container table{table-layout:fixed}*{line-height:inherit}a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:none!important}
table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: underline; }
</style>
<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700" rel="stylesheet" type="text/css"><!--<![endif]-->
</head>
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24">
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table role="presentation" id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #F5FAF7;width:100%" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="display:none !important;visibility:hidden;mso-hide:all;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
Comme toi, on aime les connexions stables et les relations durables.
</td>
</tr>
<tr style="vertical-align: top">
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #F5FAF7;" bgcolor="#F5FAF7"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center" style="border-collapse: collapse;"><tr><td style="padding:0;"><![endif]-->
<div class="u-row-container" style="padding: 0px;background-color: transparent;">
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 20px 0px 0px;"><![endif]-->
<div class="u-col u-col-100" style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
<div style="height: 100%;width: 100% !important;">
<!--[if (!mso)&(!IE)]><!--><div style="box-sizing: border-box; height: 100%; padding: 20px 0px 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;"><!--<![endif]-->
<table style="font-family:'Plus Jakarta Sans', sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left">
<div>
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">Comme toi, on aime les connexions stables et les relations durables.</div>
<div
aria-label="Une offre exclusive de TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
>
<!-- ════════ HEADER LOGO ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:12px 12px 0 0;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border:1px solid #e5e7eb;border-bottom:none;border-radius:12px 12px 0 0;direction:ltr;font-size:0px;padding:28px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:140px;">
<img
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="140" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ GREETING + INTRO (4 blocs sémantiques) ════════
Bloc 1 — Greeting personnalisé
Bloc 2 — Manifeste brand TARGO (2 lignes qui voyagent ensemble)
Bloc 3 — Annonce du cadeau
Bloc 4 — Upsell forfaits + invitation contact
Chaque bloc est un mj-text indépendant = drag-droppable séparément. -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:26px 36px 18px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#374151;"
>Bonjour {{firstname}},</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;"
>Avec l'arrivée de l'été, voici un <strong>cadeau pour toi, disponible pour un temps limité</strong>.</div>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:500;line-height:1.5;text-align:justify;color:#1B2E24;"
>On veut te remercier pour ta loyauté envers l'achat local.<br />
Comme toi, on aime les connexions stables et les relations durables.</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;">Grâce à la confiance de nos clients, on offre maintenant les forfaits à <strong>la plus haute vitesse dans le secteur</strong>, jusqu'à <strong>3.5&nbsp;Gbit/s</strong>.<br />
Que tu souhaites plus de vitesse, battre une autre offre ou faire optimiser des équipements, n'hésite pas. On est juste à côté et on aime aider.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ OPTION 1 CHIP ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:18px 36px 8px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#1B2E24;"
><span style="display:inline-block;background:#E6F9EE;color:#00C853;font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;">✅ Option 1</span></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ COMPACT INFO CARD (was 3 pills) ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:18px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="border-collapse:separate;"
>
<tbody>
<tr>
<td style="background-color:#F5FAF7;border-radius:10px;vertical-align:top;border-collapse:separate;padding:18px 22px;">
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0 0 8px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:700;line-height:1.5;text-align:left;color:#1B2E24;"
>🎁 {{amount}} chez des centaines de marques<br><br><span style="display:inline-block;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/4b0b2a4a-5f99-416c-8873-8d3e4389b6f7/content" width="32" alt="Tim Hortons" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/14df433d-583c-4602-a403-d47ee84966a6/content" width="32" alt="Walmart" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/b8d3db5a-d39e-43ce-a84a-2f5dddbf0192/content" width="32" alt="Home Depot" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/9c9dfa18-2a3a-414a-b5ad-16d490c961b5/content" width="32" alt="IGA" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><img src="https://xqy3m.mjt.lu/img2/xqy3m/a1e5f032-a192-4499-97ba-53b939712fa9/content" width="32" alt="Home Hardware" style="width:32px;max-width:32px;height:auto;display:inline-block;border:0;margin-right:6px;vertical-align:middle;"><span style="font-size:13px;color:#64748B;vertical-align:middle;font-weight:400;">et plus</span></span></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:0 0 4px;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>⚡ Disponible instantanément sur Giftbit en cliquant sur ton montant</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>🤝 Condition : Maintenir l'abonnement {{commitment_months}} mois ou +</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ BIG GREEN CTA BUTTON ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:8px 36px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;width:100%;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#00C853" role="presentation" style="border:none;border-radius:12px;cursor:auto;mso-padding-alt:30px 24px;background:#00C853;" valign="middle"
>
<a
href="{{gift_url}}" style="display:inline-block;background:#00C853;color:#ffffff;font-family:Space Grotesk, Helvetica, Arial, sans-serif;font-size:32px;font-weight:700;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:30px 24px;mso-padding-alt:0px;border-radius:12px;" target="_blank"
>
🎁&nbsp;&nbsp;{{amount}}
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="center" class="cta-subtitle" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:center;color:#ffffff;"
><!-- Sub-labels inside the button: not directly supported in mjml-button,
so we render them as a styled text block immediately below.
In the actual rendered output they appear visually under the
button text. --></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ PRORATA REFUND DISCLAIMER ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:10px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#6b7280;"
>🪂 Annulation avant {{commitment_months}} mois : seulement à rembourser au prorata des mois restants.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ OPTION 2 CHIP + TEXT ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:18px 36px 6px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#1B2E24;"
><span style="display:inline-block;background:#F5FAF7;color:#6b7280;font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;">⏭️ Option 2</span></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:6px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.55;text-align:left;color:#4b5563;"
>Ne rien faire. Aucun changement à ton abonnement actuel.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ SIGNATURE ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:0 0 12px 12px;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border-bottom:1px solid #e5e7eb;border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;border-radius:0 0 12px 12px;direction:ltr;font-size:0px;padding:18px 36px 28px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1.5;text-align:left;color:#1B2E24;"
>🤝 Merci de faire rouler l'économie de notre région avec nous !</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:8px 0 0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:14px;line-height:1.5;text-align:left;color:#64748B;"
>L'équipe TARGO</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ CONTACT INFO (outside card) ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:18px 36px 8px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:528px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:12px;line-height:1.55;text-align:center;color:#64748B;"
>Tu reçois ce courriel parce que tu es client(e) TARGO à <strong style="color:#1B2E24;">{{description}}</strong>.<br />
Une question ? N'hésite pas à nous écrire à
<a href="mailto:support@targo.ca" style="color:#00C853;text-decoration:none;">support@targo.ca</a>
ou nous appeler au
<a href="tel:5144480773" style="color:#00C853;text-decoration:none;">514-448-0773</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ DARK FOOTER BAND ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#1C1E26" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#1C1E26;background-color:#1C1E26;margin:0px auto;max-width:600px;border-radius:12px;overflow:hidden;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#1C1E26;background-color:#1C1E26;width:100%;border-collapse:separate;"
>
<tbody>
<tr>
<td
style="border-radius:12px;direction:ltr;font-size:0px;padding:26px 36px 22px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:528px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:120px;">
<img
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="120" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="center" style="font-size:0px;padding:18px 0 0;word-break:break-word;"
>
<div
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.55;text-align:center;color:rgba(255,255,255,0.55);"
><a href="https://www.targo.ca" style="color:rgba(255,255,255,0.7);text-decoration:none;">www.targo.ca</a>
&nbsp;·&nbsp; 1867 ch. de la rivière, Ste-Clotilde, QC<br />
© {{year}} TARGO Communications · Tous droits réservés.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!--></div><!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Une offre exclusive de TARGO</title>
<!-- Brand fonts: Space Grotesk for display, Plus Jakarta Sans for body.
Wrapped in MSO conditional comment so Outlook desktop skips the
Google Fonts request (it can't render them anyway) and falls back
to Helvetica via the font-family stack on each element. -->
<!--[if !mso]><!-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<!--<![endif]-->
</head>
<body style="margin:0; padding:0; background:#F5FAF7; font-family:'Plus Jakarta Sans','Helvetica Neue',Helvetica,Arial,sans-serif; color:#1B2E24; line-height:1.5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" style="padding:32px 16px;">
<!-- Main card -->
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
style="max-width:600px; background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; overflow:hidden;">
<!-- Logo header (clean, no colored band) -->
<tr>
<td style="padding:28px 36px 22px; border-bottom:1px solid #eef0ee;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content"
alt="TARGO" width="140"
style="display:block; border:0; outline:none; text-decoration:none; max-width:140px; height:auto;">
</td>
</tr>
<!-- Greeting + hook -->
<tr>
<td style="padding:26px 36px 4px;">
<p style="margin:0 0 14px; font-size:1rem; color:#374151;">Bonjour {{firstname}},</p>
<p style="margin:0 0 10px; font-size:1.08rem; color:#1B2E24; font-weight:500;">
Comme toi, on aime les connexions stables et les relations durables.
</p>
<p style="margin:0; font-size:1rem; color:#374151;">
Avec l'arrivée de l'été, voici ton
<strong>offre exclusive pour un temps limité</strong> :
</p>
</td>
</tr>
<!-- Info pill: gift card amount -->
<tr>
<td style="padding:18px 36px 8px;">
<div style="background:#F5FAF7; border-radius:10px; padding:14px 18px;">
<div style="font-size:0.7rem; font-weight:700; letter-spacing:0.12em; color:#9ca3af; text-transform:uppercase; margin-bottom:4px;">
Carte-cadeau numérique
</div>
<div style="font-size:1.05rem; font-weight:700; color:#1B2E24;">
🎁 {{amount}} chez des centaines de marques
</div>
</div>
</td>
</tr>
<!-- Two-column: ENVOI + CONDITION -->
<tr>
<td style="padding:6px 36px 18px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td width="50%" style="padding-right:5px; vertical-align:top;">
<div style="background:#F5FAF7; border-radius:10px; padding:14px 16px;">
<div style="font-size:0.7rem; font-weight:700; letter-spacing:0.12em; color:#9ca3af; text-transform:uppercase; margin-bottom:4px;">
Envoi
</div>
<div style="font-size:0.95rem; font-weight:700; color:#1B2E24;">
⚡ Instantané à l'activation
</div>
</div>
</td>
<td width="50%" style="padding-left:5px; vertical-align:top;">
<div style="background:#F5FAF7; border-radius:10px; padding:14px 16px;">
<div style="font-size:0.7rem; font-weight:700; letter-spacing:0.12em; color:#9ca3af; text-transform:uppercase; margin-bottom:4px;">
Condition
</div>
<div style="font-size:0.95rem; font-weight:700; color:#1B2E24;">
🤝 Rester encore {{commitment_months}} mois ou +
</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Divider -->
<tr><td style="padding:0 36px;"><div style="border-top:1px solid #eef0ee;"></div></td></tr>
<!-- Option 1 chip -->
<tr>
<td style="padding:22px 36px 10px;">
<span style="display:inline-block; background:#E6F9EE; color:#00C853; font-size:0.82rem; font-weight:700; padding:5px 12px; border-radius:6px;">
✅ Option 1
</span>
</td>
</tr>
<!-- Big green CTA card -->
<tr>
<td style="padding:0 36px 8px;">
<a href="{{gift_url}}" style="text-decoration:none; color:#ffffff; display:block;">
<!-- CTA card — gradient Targo officiel (135deg, #00C853 → #005026).
Outlook desktop will ignore the gradient and render the
solid #00C853 fallback (the gradient is the bgcolor's
fallback in nodemailer rendering). Acceptable degradation. -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"
bgcolor="#00C853"
style="background:#00C853; background-image:linear-gradient(135deg,#00C853 0%,#005026 100%); border-radius:12px;">
<tr>
<td style="padding:30px 24px; text-align:center;">
<div style="font-family:'Space Grotesk','Helvetica Neue',Helvetica,Arial,sans-serif; font-size:2.2rem; font-weight:700; line-height:1; margin-bottom:14px; color:#ffffff;">
🎁&nbsp;&nbsp;{{amount}}
</div>
<div style="font-size:1.08rem; font-weight:700; color:#ffffff;">
Activer ma carte-cadeau
</div>
<div style="font-size:0.85rem; opacity:0.9; margin-top:8px; color:#ffffff;">
Choisir ma carte sur Giftbit →
</div>
</td>
</tr>
</table>
</a>
</td>
</tr>
<!-- Prorata refund disclaimer -->
<tr>
<td style="padding:10px 36px 22px;">
<div style="font-size:0.85rem; color:#6b7280;">
🪂 En cas de départ avant {{commitment_months}} mois, le prorata du montant est remboursable.
</div>
</td>
</tr>
<!-- Divider -->
<tr><td style="padding:0 36px;"><div style="border-top:1px solid #eef0ee;"></div></td></tr>
<!-- Option 2 chip -->
<tr>
<td style="padding:22px 36px 8px;">
<span style="display:inline-block; background:#F5FAF7; color:#6b7280; font-size:0.82rem; font-weight:700; padding:5px 12px; border-radius:6px;">
⏭️ Option 2
</span>
</td>
</tr>
<tr>
<td style="padding:0 36px 22px;">
<div style="font-size:0.97rem; color:#4b5563; line-height:1.55;">
Ne rien faire. Ton abonnement mensuel se poursuit normalement,
sans engagement ni carte-cadeau.
</div>
</td>
</tr>
{{#expiry}}
<!-- Optional expiry callout -->
<tr><td style="padding:0 36px;"><div style="border-top:1px solid #eef0ee;"></div></td></tr>
<tr>
<td style="padding:18px 36px 0;">
<div style="font-size:0.85rem; color:#9ca3af;">
⏰ Cette offre expire le <strong style="color:#374151;">{{expiry}}</strong>.
</div>
</td>
</tr>
{{/expiry}}
<!-- Divider -->
<tr><td style="padding:18px 36px 0;"><div style="border-top:1px solid #eef0ee;"></div></td></tr>
<!-- Signature -->
<tr>
<td style="padding:22px 36px 28px;">
<div style="font-size:0.97rem; color:#1B2E24;">
🤝 Merci de faire rouler l'économie de notre région avec nous !
</div>
<div style="font-size:0.9rem; color:#6b7280; margin-top:6px;">
L'équipe TARGO
</div>
</td>
</tr>
</table>
<!-- Merchant brands grid — 4 cols × 3 rows = 12 logos
TO SWAP TO MAILJET-HOSTED LOGOS:
Replace each placeholder src URL below with the Mailjet CDN URL
you already have (same format as the TARGO logo:
https://xqy3m.mjt.lu/img2/xqy3m/<UUID>/content). The alt= attribute
stays as-is (used by screen readers + shown when images blocked).
Brand list in order: Amazon, IGA, Tim Hortons, $1 Plus (Dollarama),
Pizza Pizza, Home Depot, Best Buy, Walmart,
Petro-Canada, Esso, Home Hardware, Sobeys.
-->
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
style="max-width:600px; margin-top:8px;">
<tr>
<td style="padding:24px 36px 12px; text-align:center;">
<div style="font-size:1.02rem; font-weight:700; color:#00C853;">
Quelques exemples de choix pour votre carte cadeau :
</div>
</td>
</tr>
<tr>
<td style="padding:0 28px 8px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<!-- Row 1 — real Mailjet-hosted logos -->
<tr>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/31ffdf91-d2de-4ced-8b99-ad2221695abe/content" alt="Amazon" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/9c9dfa18-2a3a-414a-b5ad-16d490c961b5/content" alt="IGA" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/4b0b2a4a-5f99-416c-8873-8d3e4389b6f7/content" alt="Tim Hortons" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/162b988c-beb7-49b3-b85e-ccc12fa2c155/content" alt="$1 Plus" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
</tr>
<!-- Row 2 — Mailjet-hosted brand logos (sourced from user's Passport template) -->
<tr>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/ef3b15eb-ec08-4551-ae27-ce249688185a/content" alt="Pizza Pizza" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/b8d3db5a-d39e-43ce-a84a-2f5dddbf0192/content" alt="Home Depot" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/cb965d5a-3e92-4f16-9e5c-b4939ce3cb91/content" alt="Best Buy" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/14df433d-583c-4602-a403-d47ee84966a6/content" alt="Walmart" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
</tr>
<!-- Row 3 — Mailjet-hosted brand logos -->
<tr>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/36775a32-434a-41e1-bb7a-ec7b768e5ba0/content" alt="Petro-Canada" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/ff95b593-8ba3-4e57-9f01-f46cf0a2b33f/content" alt="Esso" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/a1e5f032-a192-4499-97ba-53b939712fa9/content" alt="Home Hardware" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
<td width="25%" style="padding:4px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#ffffff; border-radius:8px;">
<tr><td align="center" valign="middle" height="92" style="height:92px;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/67bd791a-18c7-4d65-a77a-64c86cecc2b1/content" alt="Sobeys" width="95" style="max-width:95px; height:auto; display:inline-block; border:0;">
</td></tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- "Pourquoi cet email" + coordonnées officielles (per brand guide §11) -->
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
style="max-width:600px;">
<tr>
<td style="padding:18px 36px 8px; text-align:center;">
<div style="font-size:0.78rem; color:#64748B; line-height:1.55;">
Tu reçois ce courriel parce que tu es client(e) TARGO à
<strong style="color:#1B2E24;">{{description}}</strong>.<br>
Une question ? Écris-nous à
<a href="mailto:support@targo.ca" style="color:#00C853; text-decoration:none;">support@targo.ca</a>
ou appelle au
<a href="tel:5144480773" style="color:#00C853; text-decoration:none;">514&nbsp;448-0773</a>
/ <a href="tel:18558882746" style="color:#00C853; text-decoration:none;">1&nbsp;855&nbsp;888-2746</a>.
Support&nbsp;7j/7.
</div>
</td>
</tr>
</table>
<!-- Dark footer band — logo TARGO blanc (fond sombre, per brand guide §1)
+ adresse + copyright. Pas de slogan ni de wordmark stylisé en
CSS — on utilise le vrai logo image (variante blanche, "fonds
sombres" du brand guide).
TODO: la première fois, uploader targo-logo-white.svg/png via
l'éditeur de template → /campaigns/assets/upload, puis remplacer
la `src` ci-dessous par l'URL retournée. En attendant on utilise
le logo green qui se voit OK sur fond sombre (suffisant pour
test) mais pas pixel-perfect avec le guide. -->
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
style="max-width:600px; margin-top:12px; background:#1C1E26; border-radius:12px; overflow:hidden;">
<tr>
<td style="padding:26px 36px 22px; text-align:center;">
<img src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content"
alt="TARGO" width="120"
style="display:inline-block; border:0; outline:none; text-decoration:none; max-width:120px; height:auto;">
<div style="font-size:0.7rem; color:rgba(255,255,255,0.45); margin-top:18px; line-height:1.55;">
<a href="https://www.targo.ca" style="color:rgba(255,255,255,0.7); text-decoration:none;">www.targo.ca</a>
&nbsp;·&nbsp; 1867 ch. de la rivière, Ste-Clotilde, QC<br>
© {{year}} TARGO Communications · Tous droits réservés.
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>