Compare commits

..

No commits in common. "2bc971548573305c547a97401bc09e7e1aee3e25" and "9b06e2df3018a7a01fc176d8dcb804fa555e76aa" have entirely different histories.

43 changed files with 100 additions and 14891 deletions

4
.gitignore vendored
View File

@ -43,7 +43,3 @@ 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,9 +12,6 @@
"@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",
@ -22,7 +19,6 @@
"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"
}, },
@ -2665,16 +2661,6 @@
"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",
@ -2795,12 +2781,6 @@
"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",
@ -2808,21 +2788,6 @@
"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",
@ -2897,12 +2862,6 @@
"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",
@ -2910,12 +2869,6 @@
"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",
@ -3420,26 +3373,6 @@
"@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",
@ -3937,18 +3870,6 @@
"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",
@ -5849,38 +5770,6 @@
"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",
@ -5982,12 +5871,6 @@
"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",
@ -7291,12 +7174,6 @@
"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",
@ -7810,12 +7687,6 @@
"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",
@ -9349,12 +9220,6 @@
"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",
@ -9613,17 +9478,6 @@
} }
} }
}, },
"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,9 +14,6 @@
"@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",
@ -24,7 +21,6 @@
"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

@ -1,165 +0,0 @@
/**
* 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,7 +7,6 @@ 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,
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, 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, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose } const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, 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

@ -1,203 +0,0 @@
<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

@ -1,772 +0,0 @@
<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

@ -1,88 +0,0 @@
<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

@ -1,500 +0,0 @@
<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,13 +38,6 @@ 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 "TARGO <support@targointernet.com>" --from "Gigafibre Support <support@targointernet.com>"
``` ```
Reasoning for `support@` over `noreply@`: campaigns INVITE a reply Reasoning for `support@` over `noreply@`: campaigns INVITE a reply
@ -129,11 +129,10 @@ 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 TARGO" \ --subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \
--amount "60 $" \ --amount "50 $" \
--expiry "31 décembre 2026" \ --expiry "31 décembre 2026" \
--commitment-months 3 \ --from "Gigafibre Support <support@targointernet.com>" \
--from "TARGO <support@targointernet.com>" \
--dry-run --dry-run
``` ```
@ -152,11 +151,10 @@ 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 TARGO" \ --subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \
--amount "60 $" \ --amount "50 $" \
--expiry "31 décembre 2026" \ --expiry "31 décembre 2026" \
--commitment-months 3 \ --from "Gigafibre Support <support@targointernet.com>" \
--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
@ -187,13 +185,11 @@ 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 Mustache-style `{{#expiry}} ... {{/expiry}}` block for The template uses a vintage `{{#expiry}} ... {{/expiry}}` block for the
the optional expiry line. The renderer keeps the contents when the optional expiry line — currently rendered as plain text (the script's
matching variable is truthy and drops them entirely otherwise — so if you simple `{{var}}` renderer doesn't strip the tags). If you don't want the
omit `--expiry` from the CLI, the "Le lien expire le …" sentence expiry sentence, edit the template directly to remove that block.
disappears cleanly with no orphan tags showing.
## Source data — the two CSVs ## Source data — the two CSVs

View File

@ -1,25 +0,0 @@
{
"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

@ -1,16 +0,0 @@
{
"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,7 +18,6 @@
* --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 \
@ -76,9 +75,6 @@ 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
@ -168,18 +164,7 @@ 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)
@ -269,7 +254,6 @@ 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

@ -1,200 +0,0 @@
#!/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

@ -1,880 +0,0 @@
<!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,880 +1,99 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> <html lang="fr">
<head> <head>
<!--[if gte mso 9]> <meta charset="UTF-8">
<xml> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<o:OfficeDocumentSettings> <title>Un cadeau de Gigafibre</title>
<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> </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;">
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24"> <!-- Spacer above the card -->
<!--[if IE]><div class="ie-container"><![endif]--> <div style="height:32px;"></div>
<!--[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"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tbody>
<tr> <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;"> <td align="center">
Comme toi, on aime les connexions stables et les relations durables.
</td>
</tr>
<tr style="vertical-align: top"> <table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top"> style="max-width:600px; background:#ffffff; border-radius:14px; overflow:hidden; box-shadow:0 6px 24px rgba(15,23,42,0.07);">
<!--[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]-->
<!-- Header band -->
<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="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left"> <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;">
<div> Gigafibre &middot; Récompense
<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> </div>
<div style="font-size:2.2rem; line-height:1.1; margin-top:10px; font-weight:800;">
<!--[if mso | IE]></td></tr></table><![endif]--> 🎁 Un cadeau pour vous
</td>
</tr>
</tbody>
</table>
</div> </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> </td>
</tr> </tr>
<!-- Body -->
<tr> <tr>
<td <td style="padding:36px 36px 12px;">
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;" <p style="margin:0 0 16px; font-size:1.05rem;">Bonjour {{firstname}},</p>
>
<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> <p style="margin:0 0 16px;">
</tr> Merci de faire partie de la famille Gigafibre. Pour vous remercier
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 -->
<tr> <div style="text-align:center; margin:32px 0 28px;">
<td <a href="{{gift_url}}"
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;" style="display:inline-block; padding:16px 36px; background:#4f46e5; color:#ffffff;
> text-decoration:none; font-weight:700; font-size:1.05rem;
<div border-radius:10px; box-shadow:0 4px 12px rgba(79,70,229,0.35);">
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 /> Récupérer mon cadeau →
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>
</td> </div>
</tr>
</tbody>
</table>
<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>
<!-- Why this email -->
<tr> <tr>
<td <td style="padding:0 36px 28px;">
align="center" class="cta-subtitle" style="font-size:0px;padding:0;word-break:break-word;" <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>.
<div Si vous avez la moindre question, écrivez-nous à
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:center;color:#ffffff;" <a href="mailto:facturation@targointernet.com" style="color:#4f46e5;">facturation@targointernet.com</a>
><!-- Sub-labels inside the button: not directly supported in mjml-button, ou appelez-nous au <a href="tel:5142421500" style="color:#4f46e5;">514 242-1500</a>.
so we render them as a styled text block immediately below. </div>
In the actual rendered output they appear visually under the
button text. --></div>
</td> </td>
</tr> </tr>
</tbody> <!-- Footer band -->
</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> <tr>
<td <td style="background:#f9fafb; padding:18px 32px; text-align:center; border-top:1px solid #e5e7eb;">
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:10px 36px 22px;text-align:center;" <div style="font-size:0.75rem; color:#9ca3af;">
> Gigafibre — Internet fibre optique au Québec<br>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]--> <a href="https://www.gigafibre.ca" style="color:#9ca3af; text-decoration:underline;">www.gigafibre.ca</a>
<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> </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> </td>
</tr> </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> </table>
</td> </td>
</tr> </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> </table>
</div> <!-- Spacer below the card -->
<div style="height:48px;"></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

View File

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

View File

@ -1,52 +0,0 @@
# 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

@ -1,63 +0,0 @@
# 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

@ -1,28 +0,0 @@
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

@ -1,16 +0,0 @@
<!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>

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +0,0 @@
{
"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

@ -1,234 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,20 +0,0 @@
{
"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

@ -1,19 +0,0 @@
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,12 +9,6 @@ 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,15 +47,11 @@ async function sendEmail (opts) {
} }
const mailOpts = { const mailOpts = {
from: opts.from || cfg.MAIL_FROM, 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) {
@ -69,16 +65,9 @@ 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 the info object (always truthy) so callers can capture return true
// 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,14 +7,13 @@
"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

@ -1,147 +0,0 @@
#!/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,7 +119,6 @@ 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

@ -1,880 +0,0 @@
<!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,880 +0,0 @@
<!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

@ -1,362 +0,0 @@
<!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>