Compare commits
37 Commits
9b06e2df30
...
2bc9715485
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc9715485 | ||
|
|
40a2e4e8f2 | ||
|
|
8df17c823a | ||
|
|
d76a922777 | ||
|
|
3f72608a2f | ||
|
|
5327112717 | ||
|
|
f37b1d2803 | ||
|
|
2c47d3269e | ||
|
|
a6cd6ee453 | ||
|
|
1b399f65eb | ||
|
|
d716e69ef6 | ||
|
|
2f1ebae587 | ||
|
|
73e4118901 | ||
|
|
448e62177e | ||
|
|
4acb18c7df | ||
|
|
9dcd32ef6a | ||
|
|
a11fe5a115 | ||
|
|
bb88a27b90 | ||
|
|
f9971e9113 | ||
|
|
0b6377fa58 | ||
|
|
2fe8d3f50e | ||
|
|
79ae38db60 | ||
|
|
b37270c11d | ||
|
|
1af8b3a029 | ||
|
|
bbd2b31761 | ||
|
|
4a4d145465 | ||
|
|
d694d889a1 | ||
|
|
d897bcedb4 | ||
|
|
2b85735006 | ||
|
|
d6096fe1f8 | ||
|
|
8d9e190c21 | ||
|
|
0186a7318e | ||
|
|
ff629a6a85 | ||
|
|
0f78fbe27e | ||
|
|
611f4ed5a6 | ||
|
|
5d763f12ff | ||
|
|
9f2b37939d |
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -43,3 +43,7 @@ scripts/migration/ref_invoice.pdf
|
||||||
|
|
||||||
# Playwright snapshots
|
# Playwright snapshots
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Auto-generated backups from scripts/convert-html-to-unlayer.js
|
||||||
|
services/targo-hub/templates/*.bak-*.json
|
||||||
|
services/targo-hub/templates/*.bak-*.html
|
||||||
|
|
|
||||||
146
apps/ops/package-lock.json
generated
146
apps/ops/package-lock.json
generated
|
|
@ -12,6 +12,9 @@
|
||||||
"@twilio/voice-sdk": "^2.18.1",
|
"@twilio/voice-sdk": "^2.18.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cytoscape": "^3.33.2",
|
"cytoscape": "^3.33.2",
|
||||||
|
"grapesjs": "^0.22.16",
|
||||||
|
"grapesjs-mjml": "^1.0.8",
|
||||||
|
"grapesjs-preset-newsletter": "^1.0.2",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
|
@ -19,6 +22,7 @@
|
||||||
"sip.js": "^0.21.2",
|
"sip.js": "^0.21.2",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-email-editor": "^2.2.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -2661,6 +2665,16 @@
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/backbone": {
|
||||||
|
"version": "1.4.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.15.tgz",
|
||||||
|
"integrity": "sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jquery": "*",
|
||||||
|
"@types/underscore": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
|
@ -2781,6 +2795,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jquery": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-Z+to+A2VkaHq1DfI2oSwsoCdhCHMpTSgjWzNcbNlRGYzksDBpPUgEcAL+RQjOBJRaLoEAOHXxqDGBVP+BblBwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
|
|
@ -2788,6 +2808,21 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mjml": {
|
||||||
|
"version": "4.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mjml/-/mjml-4.7.4.tgz",
|
||||||
|
"integrity": "sha512-vyi1vzWgMzFMwZY7GSZYX0GU0dmtC8vLHwpgk+NWmwbwRSrlieVyJ9sn5elodwUfklJM7yGl0zQeet1brKTWaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mjml-core": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mjml-core": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-E1Rho2ZfVEqZekQoESDuPAw7C3MrzdUvS6YAiEPGdhQQqAchMXfdChXlSi6ly9YhZgUP026ujrRlEGJn9o/zAg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.5.0",
|
"version": "25.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
|
|
@ -2862,6 +2897,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/underscore": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||||
|
|
@ -2869,6 +2910,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@unlayer/types": {
|
||||||
|
"version": "1.413.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unlayer/types/-/types-1.413.0.tgz",
|
||||||
|
"integrity": "sha512-pOE9lKvP7ofnmfWZN+PTizw2GrwZNtePiMH3Yl8OSt/nYQL52X7N4SHd7dDd2c7ecJwWVWo8MfPY8QTon+44lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "2.3.4",
|
"version": "2.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.3.4.tgz",
|
||||||
|
|
@ -3373,6 +3420,26 @@
|
||||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/backbone": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/backbone/-/backbone-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"underscore": ">=1.8.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/backbone-undo": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/backbone-undo/-/backbone-undo-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"backbone": ">=1.0.0",
|
||||||
|
"underscore": ">=1.4.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
|
@ -3870,6 +3937,18 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "5.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz",
|
||||||
|
"integrity": "sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/codemirror-formatting": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror-formatting/-/codemirror-formatting-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -5770,6 +5849,38 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/grapesjs": {
|
||||||
|
"version": "0.22.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.16.tgz",
|
||||||
|
"integrity": "sha512-kCfphgpC7pqJPuMYmIhMR6ueyB3+V67isdpMZOvmuGeWDMomkgzqRWOMH3matfdqIJW7LUivHZo9GeyVQAGmLw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/backbone": "1.4.15",
|
||||||
|
"backbone": "1.4.1",
|
||||||
|
"backbone-undo": "0.2.6",
|
||||||
|
"codemirror": "5.63.0",
|
||||||
|
"codemirror-formatting": "1.0.0",
|
||||||
|
"html-entities": "~1.4.0",
|
||||||
|
"promise-polyfill": "8.3.0",
|
||||||
|
"underscore": "1.13.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/grapesjs-mjml": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/grapesjs-mjml/-/grapesjs-mjml-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-cgaKwuGcBVgFCyqqK39kraPfw5DiA8qIyHKsDoMpOUqPY+ubMznx6U2M1NC9UKwD+gDCy/VVctyhb/aJAgyPNw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mjml": "^4.7.4",
|
||||||
|
"mjml-browser": "^4.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/grapesjs-preset-newsletter": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/grapesjs-preset-newsletter/-/grapesjs-preset-newsletter-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-z8KJ1ZrTXfASSJZ/tHOcnpcWu4AMr2F/ZfQit+QjimNi3UGowwl7+Yjefuh3R7lbDTrXMMaxhCannCaJo/kPJw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/graphemer": {
|
"node_modules/graphemer": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||||
|
|
@ -5871,6 +5982,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-entities": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-minifier-terser": {
|
"node_modules/html-minifier-terser": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
|
||||||
|
|
@ -7174,6 +7291,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mjml-browser": {
|
||||||
|
"version": "4.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mjml-browser/-/mjml-browser-4.18.0.tgz",
|
||||||
|
"integrity": "sha512-Y3kpr3IFBk3Wm7AwONZ5vDAX7FxAaMk+RKbcKKewsuGI9oNCOSM2dWNcWVFdzZ9PF7awoaCgBSQudnJaJbUiBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
|
@ -7687,6 +7810,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/promise-polyfill": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
|
@ -9220,6 +9349,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/underscore": {
|
||||||
|
"version": "1.13.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||||
|
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
|
@ -9478,6 +9613,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-email-editor": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-email-editor/-/vue-email-editor-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-aEXm0OHjZgeQqGsssfukqJm7kubfGBOPo9ddwGHMXLbzegJDZ0ou2h7NmRvPR+XaoRGYHdZXf9p7zVae5ACgWA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@unlayer/types": "^1.394.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.2.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-eslint-parser": {
|
"node_modules/vue-eslint-parser": {
|
||||||
"version": "9.4.3",
|
"version": "9.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
"@twilio/voice-sdk": "^2.18.1",
|
"@twilio/voice-sdk": "^2.18.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cytoscape": "^3.33.2",
|
"cytoscape": "^3.33.2",
|
||||||
|
"grapesjs": "^0.22.16",
|
||||||
|
"grapesjs-mjml": "^1.0.8",
|
||||||
|
"grapesjs-preset-newsletter": "^1.0.2",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
|
@ -21,6 +24,7 @@
|
||||||
"sip.js": "^0.21.2",
|
"sip.js": "^0.21.2",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-email-editor": "^2.2.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
165
apps/ops/src/api/campaigns.js
Normal file
165
apps/ops/src/api/campaigns.js
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
/**
|
||||||
|
* api/campaigns.js — Client for Hub /campaigns endpoints.
|
||||||
|
*
|
||||||
|
* Mirrors services/targo-hub/lib/campaigns.js. All gift-campaign requests
|
||||||
|
* go through the Hub which handles ERPNext auth + Mailjet send + SSE
|
||||||
|
* progress broadcast.
|
||||||
|
*
|
||||||
|
* Functions:
|
||||||
|
* parseCsvs({ map_csv, giftbit_csv, multi }) → preview matched send list
|
||||||
|
* createCampaign({ name, params, recipients }) → save + return id
|
||||||
|
* listCampaigns() → summaries
|
||||||
|
* getCampaign(id) → full detail
|
||||||
|
* updateCampaign(id, patch) → edit recipients/params
|
||||||
|
* sendCampaign(id) → fire background worker
|
||||||
|
* campaignSseUrl(id) → SSE URL for live updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HUB_URL } from 'src/config/hub'
|
||||||
|
|
||||||
|
async function hubFetch (path, { method = 'GET', body } = {}) {
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
if (body) opts.body = JSON.stringify(body)
|
||||||
|
const res = await fetch(`${HUB_URL}${path}`, opts)
|
||||||
|
const text = await res.text()
|
||||||
|
let data
|
||||||
|
try { data = text ? JSON.parse(text) : {} }
|
||||||
|
catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data.error || `HTTP ${res.status}`
|
||||||
|
const err = new Error(msg)
|
||||||
|
err.status = res.status
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCsvs ({ map_csv, giftbit_csv, multi = 'first' }) {
|
||||||
|
return hubFetch('/campaigns/parse', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { map_csv, giftbit_csv, multi },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCampaign ({ name, params, recipients }) {
|
||||||
|
return hubFetch('/campaigns', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, params, recipients },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCampaigns () {
|
||||||
|
return hubFetch('/campaigns').then(r => r.campaigns || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCampaign (id) {
|
||||||
|
return hubFetch(`/campaigns/${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCampaign (id, patch) {
|
||||||
|
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: patch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendCampaign (id) {
|
||||||
|
return hubFetch(`/campaigns/${encodeURIComponent(id)}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the URL the browser hits to download the per-recipient CSV report
|
||||||
|
// (giftbit shortlink ↔ email ↔ name ↔ status). The hub serves it with the
|
||||||
|
// proper Content-Disposition so an <a download> click triggers a save.
|
||||||
|
export function campaignReportCsvUrl (id) {
|
||||||
|
return `${HUB_URL}/campaigns/${encodeURIComponent(id)}/report.csv`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image assets (self-hosted on the hub, for GrapesJS asset manager) ───────
|
||||||
|
|
||||||
|
export function listAssets () {
|
||||||
|
return hubFetch('/campaigns/assets').then(r => r.assets || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload a File / Blob from the browser via base64-encoded JSON. Bypasses
|
||||||
|
// multipart parsing on the hub side (zero new deps) at the cost of ~33%
|
||||||
|
// payload overhead. Acceptable for the ≤5 MB images we permit.
|
||||||
|
export async function uploadAsset (file) {
|
||||||
|
const dataUrl = await new Promise((resolve, reject) => {
|
||||||
|
const r = new FileReader()
|
||||||
|
r.onload = () => resolve(r.result)
|
||||||
|
r.onerror = () => reject(new Error('FileReader failed'))
|
||||||
|
r.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
return hubFetch('/campaigns/assets/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: file.name, data: dataUrl },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAsset (filename) {
|
||||||
|
return hubFetch(`/campaigns/assets/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template editing (used by the GrapesJS editor page) ─────────────────────
|
||||||
|
|
||||||
|
export function listTemplates () {
|
||||||
|
return hubFetch('/campaigns/templates').then(r => r.templates || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemplate (name) {
|
||||||
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveTemplate(name, content, opts) — content is HTML by default.
|
||||||
|
// Optional opts.design = Unlayer design JSON (persisted alongside HTML so the
|
||||||
|
// editor can re-load the visual state on next open).
|
||||||
|
// Legacy opts.format = 'mjml' still supported for older callers (sends mjml).
|
||||||
|
export function saveTemplate (name, content, { format = 'html', design = null } = {}) {
|
||||||
|
const body = format === 'mjml' ? { mjml: content } : { html: content }
|
||||||
|
if (design) body.design = design
|
||||||
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewTemplate (name, { html, vars } = {}) {
|
||||||
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { html, vars },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate the source template to a target language via Gemini.
|
||||||
|
// targetName must match the source's prefix (e.g. gift-email-fr → gift-email-en).
|
||||||
|
// override=true required if the target already exists.
|
||||||
|
export function translateTemplate (srcName, targetName, { override = false } = {}) {
|
||||||
|
return hubFetch(
|
||||||
|
`/campaigns/templates/${encodeURIComponent(srcName)}/translate-to/${encodeURIComponent(targetName)}`,
|
||||||
|
{ method: 'POST', body: { override } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ONE rendered email to a specific address for visual QA.
|
||||||
|
// Pass { to, vars, from?, subject? } — defaults filled in server-side.
|
||||||
|
export function testSendTemplate (name, { to, vars, from, subject } = {}) {
|
||||||
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { to, vars, from, subject },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the URL for the SSE channel of one campaign. The Hub broadcasts on
|
||||||
|
* topic `campaign:<id>` so we subscribe to that single topic. Use with:
|
||||||
|
* const es = new EventSource(campaignSseUrl(id))
|
||||||
|
* es.addEventListener('recipient-update', ev => { ... })
|
||||||
|
* es.addEventListener('campaign-done', ev => { ... })
|
||||||
|
*/
|
||||||
|
export function campaignSseUrl (id) {
|
||||||
|
return `${HUB_URL}/sse?topics=campaign:${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export const navItems = [
|
||||||
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
|
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
|
||||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
||||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||||
|
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
|
||||||
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,13 +123,13 @@ import { listDocs } from 'src/api/erp'
|
||||||
import { navItems as allNavItems } from 'src/config/nav'
|
import { navItems as allNavItems } from 'src/config/nav'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||||
Settings, LogOut, PanelLeftOpen, PanelLeftClose,
|
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||||
import { useConversations } from 'src/composables/useConversations'
|
import { useConversations } from 'src/composables/useConversations'
|
||||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||||
|
|
||||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
|
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
|
||||||
|
|
||||||
const { panelOpen, activeCount: convCount } = useConversations()
|
const { panelOpen, activeCount: convCount } = useConversations()
|
||||||
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
||||||
|
|
|
||||||
203
apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue
Normal file
203
apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
|
||||||
|
<div class="text-h5">{{ campaign?.name || id }}</div>
|
||||||
|
<q-chip dense class="q-ml-md" :color="statusColor(campaign?.status)" text-color="white" :label="statusLabel(campaign?.status)" />
|
||||||
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
v-if="campaign?.recipients?.length"
|
||||||
|
flat dense icon="file_download" label="CSV" class="q-mr-sm"
|
||||||
|
:href="reportCsvUrl" download
|
||||||
|
>
|
||||||
|
<q-tooltip>Télécharger le rapport (shortlinks Giftbit, emails, statuts d'envoi)</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn v-if="campaign?.status === 'draft'" unelevated color="primary" icon="send" label="Lancer l'envoi"
|
||||||
|
:loading="resending" @click="relaunch" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Counters bar -->
|
||||||
|
<div class="row q-col-gutter-sm q-mb-md" v-if="campaign">
|
||||||
|
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||||
|
<div class="text-h5">{{ campaign.counters?.total || campaign.recipients?.length || 0 }}</div>
|
||||||
|
<div class="text-caption text-grey-7">Total</div>
|
||||||
|
</q-card-section></q-card>
|
||||||
|
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||||
|
<div class="text-h5 text-positive">{{ counterFor('sent') + counterFor('opened') + counterFor('clicked') }}</div>
|
||||||
|
<div class="text-caption text-grey-7">Envoyés</div>
|
||||||
|
</q-card-section></q-card>
|
||||||
|
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||||
|
<div class="text-h5 text-blue">{{ counterFor('clicked') }}</div>
|
||||||
|
<div class="text-caption text-grey-7">Cliqués</div>
|
||||||
|
</q-card-section></q-card>
|
||||||
|
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||||
|
<div class="text-h5 text-orange">{{ counterFor('queued') }}</div>
|
||||||
|
<div class="text-caption text-grey-7">En attente</div>
|
||||||
|
</q-card-section></q-card>
|
||||||
|
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||||
|
<div class="text-h5 text-negative">{{ counterFor('failed') + counterFor('bounced') }}</div>
|
||||||
|
<div class="text-caption text-grey-7">Échecs</div>
|
||||||
|
</q-card-section></q-card>
|
||||||
|
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||||
|
<div class="text-h5 text-grey-7">{{ counterFor('pending') }}</div>
|
||||||
|
<div class="text-caption text-grey-7">Non envoyés</div>
|
||||||
|
</q-card-section></q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-linear-progress
|
||||||
|
v-if="campaign && (campaign.status === 'sending' || campaign.status === 'completed')"
|
||||||
|
:value="sentRatio" :color="campaign.counters?.failed ? 'orange' : 'positive'" size="8px" class="q-mb-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
v-if="campaign"
|
||||||
|
:rows="campaign.recipients || []"
|
||||||
|
:columns="columns" row-key="email"
|
||||||
|
flat bordered dense
|
||||||
|
:pagination="{ rowsPerPage: 50 }"
|
||||||
|
:rows-per-page-options="[25, 50, 100, 0]"
|
||||||
|
>
|
||||||
|
<template v-slot:body-cell-status="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-customer="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<span v-if="props.row.customer_id">
|
||||||
|
<q-icon name="person" size="14px" class="q-mr-xs" />
|
||||||
|
<a :href="`/#/clients/${props.row.customer_id}`" target="_blank" style="color:var(--q-primary)">
|
||||||
|
{{ props.row.customer_name || props.row.customer_id }}
|
||||||
|
</a>
|
||||||
|
<q-chip dense size="xs" outline class="q-ml-xs">{{ props.row.match_method }}</q-chip>
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-grey-6">—</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-gift_url="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<a :href="props.row.gift_url" target="_blank" class="text-grey-7" style="font-family:monospace; font-size:0.78rem">
|
||||||
|
{{ shortLink(props.row.gift_url) }}
|
||||||
|
</a>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-error="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<span v-if="props.row.error" class="text-negative text-caption">{{ props.row.error }}</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
<div v-if="!campaign && !loading" class="text-center q-pa-xl text-grey-7">
|
||||||
|
<q-icon name="error_outline" size="48px" />
|
||||||
|
<div class="text-h6 q-mt-md">Campagne introuvable</div>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
import { getCampaign, sendCampaign, campaignSseUrl, campaignReportCsvUrl } from 'src/api/campaigns'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const $q = useQuasar()
|
||||||
|
const id = route.params.id
|
||||||
|
const campaign = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const resending = ref(false)
|
||||||
|
|
||||||
|
let es = null
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
|
||||||
|
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
|
||||||
|
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
|
||||||
|
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
|
||||||
|
{ name: 'customer', label: 'Client lié', field: 'customer_name', align: 'left' },
|
||||||
|
{ name: 'gift_url', label: 'Shortlink', field: 'gift_url', align: 'left' },
|
||||||
|
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
|
||||||
|
{ name: 'error', label: 'Erreur', field: 'error', align: 'left' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function counterFor (s) { return campaign.value?.counters?.[s] || 0 }
|
||||||
|
function statusColor (s) {
|
||||||
|
return {
|
||||||
|
pending: 'grey-5', queued: 'orange', sent: 'positive', opened: 'positive',
|
||||||
|
clicked: 'blue', failed: 'negative', bounced: 'negative',
|
||||||
|
draft: 'grey', sending: 'orange', completed: 'positive',
|
||||||
|
}[s] || 'grey-5'
|
||||||
|
}
|
||||||
|
function statusLabel (s) {
|
||||||
|
return {
|
||||||
|
pending: 'En attente', queued: 'En file', sent: 'Envoyé', opened: 'Ouvert',
|
||||||
|
clicked: 'Cliqué', failed: 'Échec', bounced: 'Rejeté',
|
||||||
|
draft: 'Brouillon', sending: 'En cours', completed: 'Terminée',
|
||||||
|
}[s] || s
|
||||||
|
}
|
||||||
|
function shortLink (u) { return (u || '').replace(/^https?:\/\//, '').slice(0, 28) + ((u || '').length > 35 ? '…' : '') }
|
||||||
|
|
||||||
|
const reportCsvUrl = computed(() => campaignReportCsvUrl(id))
|
||||||
|
|
||||||
|
const sentRatio = computed(() => {
|
||||||
|
const total = campaign.value?.counters?.total || 1
|
||||||
|
const done = counterFor('sent') + counterFor('opened') + counterFor('clicked')
|
||||||
|
+ counterFor('failed') + counterFor('bounced')
|
||||||
|
return Math.min(1, done / total)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load () {
|
||||||
|
loading.value = true
|
||||||
|
try { campaign.value = await getCampaign(id) }
|
||||||
|
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeSse () {
|
||||||
|
if (es) es.close()
|
||||||
|
es = new EventSource(campaignSseUrl(id))
|
||||||
|
es.addEventListener('recipient-update', (ev) => {
|
||||||
|
const data = JSON.parse(ev.data)
|
||||||
|
if (!campaign.value?.recipients) return
|
||||||
|
// Apply patch by index; counters will be re-rendered from recipients next refresh
|
||||||
|
if (campaign.value.recipients[data.i]) {
|
||||||
|
Object.assign(campaign.value.recipients[data.i], data.recipient)
|
||||||
|
// Recompute counters in-place for live update
|
||||||
|
const counters = { total: campaign.value.recipients.length }
|
||||||
|
for (const r of campaign.value.recipients) counters[r.status] = (counters[r.status] || 0) + 1
|
||||||
|
campaign.value.counters = counters
|
||||||
|
}
|
||||||
|
})
|
||||||
|
es.addEventListener('campaign-done', () => {
|
||||||
|
$q.notify({ type: 'positive', message: 'Campagne terminée' })
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
es.addEventListener('campaign-status', (ev) => {
|
||||||
|
const data = JSON.parse(ev.data)
|
||||||
|
if (campaign.value) campaign.value.status = data.status
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relaunch () {
|
||||||
|
resending.value = true
|
||||||
|
try {
|
||||||
|
await sendCampaign(id)
|
||||||
|
await load()
|
||||||
|
subscribeSse()
|
||||||
|
} catch (e) {
|
||||||
|
$q.notify({ type: 'negative', message: e.message })
|
||||||
|
} finally {
|
||||||
|
resending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await load()
|
||||||
|
// Auto-subscribe to SSE if still running (or about to run)
|
||||||
|
if (campaign.value && ['draft','sending'].includes(campaign.value.status)) {
|
||||||
|
subscribeSse()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { if (es) es.close() })
|
||||||
|
</script>
|
||||||
772
apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
Normal file
772
apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
Normal file
|
|
@ -0,0 +1,772 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
|
||||||
|
<div class="text-h5">Nouvelle campagne</div>
|
||||||
|
<q-space />
|
||||||
|
<!-- Always-accessible jump-to-editor link. Opens in new tab so the
|
||||||
|
wizard's uploaded files + parsed recipients stay intact. Useful
|
||||||
|
when the user wants to tweak the template mid-import. -->
|
||||||
|
<q-btn flat color="primary" icon="palette" label="Éditer le template (nouvel onglet)"
|
||||||
|
type="a" href="/ops/#/campaigns/templates/gift-email-fr" target="_blank">
|
||||||
|
<q-tooltip>S'ouvre dans un nouvel onglet — tes fichiers uploadés restent intacts ici</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-stepper v-model="step" header-nav ref="stepper" color="primary" animated flat bordered class="bg-white">
|
||||||
|
|
||||||
|
<!-- Step 1 — Upload + parameters ─────────────────────────────────── -->
|
||||||
|
<q-step :name="1" title="Fichiers + paramètres" icon="upload_file" :done="step > 1" :header-nav="step > 1">
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-card flat bordered>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-subtitle1 q-mb-sm">1. Export Map CSV (brut)</div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-md">
|
||||||
|
Le fichier <code>selectionAdressesMap*.csv</code> tel qu'exporté de la sélection
|
||||||
|
d'adresses (pipe-delimited, préambule de 1 ligne accepté).
|
||||||
|
</div>
|
||||||
|
<q-file v-model="mapFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readMapFile">
|
||||||
|
<template v-slot:prepend><q-icon name="attach_file" /></template>
|
||||||
|
</q-file>
|
||||||
|
<div v-if="mapPreview" class="text-caption q-mt-sm text-grey-7">
|
||||||
|
✓ {{ countMapRows(mapPreview) }} lignes de contacts (préambule + header retirés)
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-card flat bordered>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-subtitle1 q-mb-sm">2. Shortlinks Giftbit CSV</div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-md">
|
||||||
|
Le fichier <code>giftbit-gifts-<id>.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 contact↔link 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>
|
||||||
88
apps/ops/src/modules/campaigns/pages/CampaignsListPage.vue
Normal file
88
apps/ops/src/modules/campaigns/pages/CampaignsListPage.vue
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<div class="text-h5">Campagnes</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat color="primary" icon="palette" label="Éditer le template" :to="'/campaigns/templates/gift-email-fr'" class="q-mr-sm" />
|
||||||
|
<q-btn unelevated color="primary" icon="add" label="Nouvelle campagne" :to="'/campaigns/new'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-card flat bordered v-if="!loading && campaigns.length === 0" class="q-pa-xl text-center text-grey-7">
|
||||||
|
<q-icon name="card_giftcard" size="48px" class="q-mb-md" />
|
||||||
|
<div class="text-h6">Aucune campagne pour le moment</div>
|
||||||
|
<div class="q-mt-sm">
|
||||||
|
Une campagne envoie des cartes-cadeaux Giftbit par courriel personnalisé.
|
||||||
|
Importer 2 CSV (export Map + shortlinks Giftbit) et lancer l'envoi via Mailjet.
|
||||||
|
</div>
|
||||||
|
<q-btn class="q-mt-lg" color="primary" icon="add" label="Créer la première" :to="'/campaigns/new'" />
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
v-else
|
||||||
|
:rows="campaigns"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="id"
|
||||||
|
:loading="loading"
|
||||||
|
flat bordered
|
||||||
|
:pagination="{ rowsPerPage: 25, sortBy: 'created_at', descending: true }"
|
||||||
|
>
|
||||||
|
<template v-slot:body-cell-status="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-chip dense :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-progress="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="row items-center q-gutter-xs">
|
||||||
|
<span class="text-grey-7">{{ (props.row.counters?.sent || 0) }} / {{ props.row.total || 0 }}</span>
|
||||||
|
<q-linear-progress
|
||||||
|
:value="(props.row.counters?.sent || 0) / Math.max(1, props.row.total || 1)"
|
||||||
|
size="6px"
|
||||||
|
:color="props.row.counters?.failed ? 'negative' : 'positive'"
|
||||||
|
style="min-width:80px"
|
||||||
|
/>
|
||||||
|
<span v-if="props.row.counters?.failed" class="text-negative text-caption">
|
||||||
|
{{ props.row.counters.failed }} échec(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-actions="props">
|
||||||
|
<q-td :props="props" class="text-right">
|
||||||
|
<q-btn flat dense color="primary" icon="visibility" :to="`/campaigns/${props.row.id}`" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { listCampaigns } from 'src/api/campaigns'
|
||||||
|
|
||||||
|
const campaigns = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
|
||||||
|
{ name: 'created_at', label: 'Créée', field: r => new Date(r.created_at).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' }), align: 'left', sortable: true },
|
||||||
|
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
|
||||||
|
{ name: 'progress', label: 'Envois', field: 'counters', align: 'left' },
|
||||||
|
{ name: 'actions', label: '', field: '', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusColor (s) {
|
||||||
|
return { draft: 'grey', sending: 'orange', completed: 'positive', failed: 'negative' }[s] || 'grey'
|
||||||
|
}
|
||||||
|
function statusLabel (s) {
|
||||||
|
return { draft: 'Brouillon', sending: 'En cours', completed: 'Terminée', failed: 'Échec' }[s] || s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load () {
|
||||||
|
loading.value = true
|
||||||
|
try { campaigns.value = await listCampaigns() }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
500
apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
Normal file
500
apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<!-- Top bar: template selector + saved chip + quick actions. The Unlayer
|
||||||
|
editor below has its own toolbar for blocks/preview/etc. -->
|
||||||
|
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
|
||||||
|
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
|
||||||
|
<div class="text-h6 q-mr-md">Éditeur de template</div>
|
||||||
|
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
|
||||||
|
style="min-width:240px" @update:model-value="onSelectTemplate" />
|
||||||
|
<q-btn flat dense icon="add" label="Nouveau" color="primary" class="q-ml-sm" @click="newTemplateOpen = true">
|
||||||
|
<q-tooltip>Créer un nouveau template (vide ou copié depuis un existant)</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-chip v-if="lastSavedTs" dense size="sm" color="grey-2" text-color="grey-9" class="q-ml-sm" icon="cloud_done">
|
||||||
|
Sauvegardé · {{ lastSavedLabel }}
|
||||||
|
</q-chip>
|
||||||
|
<q-btn flat dense icon="code" class="q-ml-sm" color="grey-7">
|
||||||
|
<q-tooltip max-width="320px">
|
||||||
|
<strong>9 variables disponibles</strong> (Client / Offre / Système).
|
||||||
|
Insertion : clic dans un texte → barre flottante → icône <code>{}</code> Merge Tags.
|
||||||
|
Marche aussi dans les champs URL (boutons, images, mailto).
|
||||||
|
</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm" @click="openPreview">
|
||||||
|
<q-tooltip>Voir le HTML rendu (substitué) tel que reçu par le destinataire</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat color="purple-7" icon="translate" label="Traduire (AI)" class="q-mr-sm"
|
||||||
|
:disable="!aiTranslateTargetName" @click="aiTranslateOpen = true">
|
||||||
|
<q-tooltip>{{ aiTranslateTargetName ? `Traduire vers ${aiTranslateTargetName} via Gemini` : 'Disponible pour les templates avec suffixe -fr ou -en' }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm" @click="testSendOpen = true">
|
||||||
|
<q-tooltip>Envoyer un courriel réel à une adresse de test</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn unelevated color="primary" icon="save" label="Enregistrer"
|
||||||
|
:loading="saving" @click="saveTemplate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unlayer editor — Vue 3 native, no iframe, full features:
|
||||||
|
responsive preview, AMP blocks, Unsplash, file manager, dark mode,
|
||||||
|
layers/structure panel, design tokens, etc.
|
||||||
|
Wrapped in an explicit-sized container so the inner iframe gets
|
||||||
|
enough height/width (Quasar's q-page doesn't propagate dimensions
|
||||||
|
that easyEditor's nested iframe can pick up automatically). -->
|
||||||
|
<div style="height: calc(100vh - 60px); width: 100%; overflow: hidden; position: relative;">
|
||||||
|
<EmailEditor
|
||||||
|
ref="editor"
|
||||||
|
:options="editorOptions"
|
||||||
|
:min-height="'100%'"
|
||||||
|
style="height: 100%; width: 100%;"
|
||||||
|
@load="onEditorLoad"
|
||||||
|
@ready="onEditorReady"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aperçu dialog — renders the latest saved HTML with sample vars -->
|
||||||
|
<q-dialog v-model="previewOpen" maximized persistent>
|
||||||
|
<q-card class="bg-grey-2">
|
||||||
|
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
|
||||||
|
<q-icon name="visibility" class="q-mr-sm" />
|
||||||
|
<q-toolbar-title>Aperçu inbox · {{ currentName }}</q-toolbar-title>
|
||||||
|
<q-btn flat dense round icon="close" @click="previewOpen = false" />
|
||||||
|
</q-toolbar>
|
||||||
|
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9"><q-spinner size="sm" /> Rendu en cours…</q-banner>
|
||||||
|
<q-card-section style="height: calc(100vh - 60px); overflow:hidden">
|
||||||
|
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- New template dialog — name + starter -->
|
||||||
|
<q-dialog v-model="newTemplateOpen" persistent>
|
||||||
|
<q-card style="min-width: 500px; max-width: 90vw">
|
||||||
|
<q-card-section class="row items-center bg-blue-1 text-blue-9">
|
||||||
|
<q-icon name="add" class="q-mr-sm" />
|
||||||
|
<div class="text-h6">Nouveau template</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat dense round icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input v-model="newTemplateForm.suffix" outlined dense autofocus
|
||||||
|
label="Nom (suffixe après gift-email-)" :prefix="newTemplateForm.prefix + '-'"
|
||||||
|
:rules="[v => /^[a-z0-9-]+$/.test(v) || 'Lettres minuscules, chiffres et tirets seulement']"
|
||||||
|
hint="Exemple: summer-2026, automne-promo, anniversaire" class="q-mb-md" />
|
||||||
|
<q-select v-model="newTemplateForm.prefix" :options="['gift-email','newsletter','transactional']"
|
||||||
|
label="Type" outlined dense class="q-mb-md" />
|
||||||
|
<q-select v-model="newTemplateForm.starter" :options="starterOptions" emit-value map-options
|
||||||
|
label="Démarrer depuis" outlined dense class="q-mb-md" />
|
||||||
|
<q-banner v-if="newTemplateFinal" class="bg-grey-2 text-grey-8 q-mt-sm" rounded dense>
|
||||||
|
<q-icon name="info" class="q-mr-xs" />
|
||||||
|
Le template sera créé sous le nom <strong>{{ newTemplateFinal }}</strong>
|
||||||
|
<span v-if="newTemplateForm.starter !== 'blank'">
|
||||||
|
· contenu copié depuis <em>{{ newTemplateForm.starter }}</em>
|
||||||
|
</span>
|
||||||
|
</q-banner>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Annuler" v-close-popup />
|
||||||
|
<q-btn unelevated color="primary" icon="add" label="Créer le template"
|
||||||
|
:loading="creating" :disable="!newTemplateValid" @click="createTemplate" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- AI translate dialog -->
|
||||||
|
<q-dialog v-model="aiTranslateOpen" persistent>
|
||||||
|
<q-card style="min-width: 500px; max-width: 90vw">
|
||||||
|
<q-card-section class="row items-center bg-purple-1 text-purple-9">
|
||||||
|
<q-icon name="translate" class="q-mr-sm" />
|
||||||
|
<div class="text-h6">Traduire avec Gemini AI</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat dense round icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="q-mb-md">
|
||||||
|
Le contenu de <strong>{{ currentName }}</strong> sera traduit vers
|
||||||
|
<strong>{{ aiTranslateTargetName }}</strong> via Gemini Flash.
|
||||||
|
</div>
|
||||||
|
<q-banner class="bg-grey-2 text-grey-8 q-mb-md" rounded dense>
|
||||||
|
<q-icon name="info" class="q-mr-xs" />
|
||||||
|
<!-- v-pre on this span so Vue doesn't try to compile the literal
|
||||||
|
{{...}} braces inside the explanatory <code> tag. -->
|
||||||
|
<span v-pre>
|
||||||
|
Le AI préserve la structure HTML, les variables <code>{{...}}</code>,
|
||||||
|
les URLs, les noms de marque (TARGO, Giftbit) et les emojis.
|
||||||
|
Il traduit seulement le texte visible.
|
||||||
|
</span>
|
||||||
|
</q-banner>
|
||||||
|
<q-banner v-if="targetTemplateExists" class="bg-amber-1 text-amber-9 q-mb-md" rounded dense>
|
||||||
|
<q-icon name="warning" class="q-mr-xs" />
|
||||||
|
Le template <strong>{{ aiTranslateTargetName }}</strong> existe déjà.
|
||||||
|
La traduction va l'écraser (backup automatique avant).
|
||||||
|
<q-toggle v-model="aiTranslateOverride" label="Confirmer l'écrasement" class="q-mt-sm" />
|
||||||
|
</q-banner>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Annuler" v-close-popup />
|
||||||
|
<q-btn unelevated color="purple-7" icon="translate" label="Traduire maintenant"
|
||||||
|
:loading="aiTranslating"
|
||||||
|
:disable="targetTemplateExists && !aiTranslateOverride"
|
||||||
|
@click="doAiTranslate" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- Test-send dialog -->
|
||||||
|
<q-dialog v-model="testSendOpen" persistent>
|
||||||
|
<q-card style="min-width: 500px; max-width: 90vw">
|
||||||
|
<q-card-section class="row items-center bg-orange-1 text-orange-9">
|
||||||
|
<q-icon name="send" class="q-mr-sm" />
|
||||||
|
<div class="text-h6">Envoyer un test du courriel</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat dense round icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense type="email" autofocus class="q-mb-md" />
|
||||||
|
<q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" />
|
||||||
|
<div class="text-subtitle2 q-mb-sm">Variables</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<q-input v-model="testSendForm.vars.firstname" label="firstname" outlined dense class="col-6" />
|
||||||
|
<q-input v-model="testSendForm.vars.lastname" label="lastname" outlined dense class="col-6" />
|
||||||
|
<q-input v-model="testSendForm.vars.amount" label="amount" outlined dense class="col-6" />
|
||||||
|
<q-input v-model="testSendForm.vars.commitment_months" label="commitment_months" outlined dense class="col-6" />
|
||||||
|
<q-input v-model="testSendForm.vars.gift_url" label="gift_url" outlined dense class="col-12" />
|
||||||
|
<q-input v-model="testSendForm.vars.description" label="description" outlined dense class="col-12" />
|
||||||
|
<q-input v-model="testSendForm.vars.expiry" label="expiry" outlined dense class="col-12" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="bg-grey-2">
|
||||||
|
<div class="text-caption text-grey-8">
|
||||||
|
Sauvegarde l'éditeur d'abord pour tester la dernière version.
|
||||||
|
Envoyé via Mailjet depuis <code>TARGO <support@targointernet.com></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>
|
||||||
|
|
@ -38,6 +38,13 @@ const routes = [
|
||||||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||||
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
|
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
|
||||||
|
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
|
||||||
|
{ path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') },
|
||||||
|
{ path: 'campaigns/new', component: () => import('src/modules/campaigns/pages/CampaignNewPage.vue') },
|
||||||
|
// Template editor route must be ABOVE /campaigns/:id otherwise the
|
||||||
|
// ':id' wildcard captures 'templates/...' and shows the detail page.
|
||||||
|
{ path: 'campaigns/templates/:name?', component: () => import('src/modules/campaigns/pages/TemplateEditorPage.vue'), props: true },
|
||||||
|
{ path: 'campaigns/:id', component: () => import('src/modules/campaigns/pages/CampaignDetailPage.vue'), props: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ domain level. The two known-validated senders on this account are:
|
||||||
The default for gift campaigns:
|
The default for gift campaigns:
|
||||||
|
|
||||||
```
|
```
|
||||||
--from "Gigafibre Support <support@targointernet.com>"
|
--from "TARGO <support@targointernet.com>"
|
||||||
```
|
```
|
||||||
|
|
||||||
Reasoning for `support@` over `noreply@`: campaigns INVITE a reply
|
Reasoning for `support@` over `noreply@`: campaigns INVITE a reply
|
||||||
|
|
@ -129,10 +129,11 @@ node send_gift_campaign.js \
|
||||||
--gifts /path/to/giftbit-gifts.csv \
|
--gifts /path/to/giftbit-gifts.csv \
|
||||||
--contacts /path/to/giftbit-contacts-A-first-email.csv \
|
--contacts /path/to/giftbit-contacts-A-first-email.csv \
|
||||||
--template ./templates/gift-email-fr.html \
|
--template ./templates/gift-email-fr.html \
|
||||||
--subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \
|
--subject "🎁 Un cadeau pour vous, de la part de TARGO" \
|
||||||
--amount "50 $" \
|
--amount "60 $" \
|
||||||
--expiry "31 décembre 2026" \
|
--expiry "31 décembre 2026" \
|
||||||
--from "Gigafibre Support <support@targointernet.com>" \
|
--commitment-months 3 \
|
||||||
|
--from "TARGO <support@targointernet.com>" \
|
||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -151,10 +152,11 @@ node send_gift_campaign.js \
|
||||||
--gifts /path/to/giftbit-gifts.csv \
|
--gifts /path/to/giftbit-gifts.csv \
|
||||||
--contacts /path/to/giftbit-contacts-A-first-email.csv \
|
--contacts /path/to/giftbit-contacts-A-first-email.csv \
|
||||||
--template ./templates/gift-email-fr.html \
|
--template ./templates/gift-email-fr.html \
|
||||||
--subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \
|
--subject "🎁 Un cadeau pour vous, de la part de TARGO" \
|
||||||
--amount "50 $" \
|
--amount "60 $" \
|
||||||
--expiry "31 décembre 2026" \
|
--expiry "31 décembre 2026" \
|
||||||
--from "Gigafibre Support <support@targointernet.com>" \
|
--commitment-months 3 \
|
||||||
|
--from "TARGO <support@targointernet.com>" \
|
||||||
--smtp-host in-v3.mailjet.com --smtp-port 587 \
|
--smtp-host in-v3.mailjet.com --smtp-port 587 \
|
||||||
--smtp-user "$SMTP_USER" --smtp-pass "$SMTP_PASS" \
|
--smtp-user "$SMTP_USER" --smtp-pass "$SMTP_PASS" \
|
||||||
--throttle-ms 600
|
--throttle-ms 600
|
||||||
|
|
@ -185,11 +187,13 @@ Variables resolved at send time:
|
||||||
| `{{gift_url}}` | matched from the gifts CSV |
|
| `{{gift_url}}` | matched from the gifts CSV |
|
||||||
| `{{amount}}` | `--amount` CLI flag (e.g. `"50 $"`) |
|
| `{{amount}}` | `--amount` CLI flag (e.g. `"50 $"`) |
|
||||||
| `{{expiry}}` | `--expiry` CLI flag (e.g. `"31 décembre 2026"`) |
|
| `{{expiry}}` | `--expiry` CLI flag (e.g. `"31 décembre 2026"`) |
|
||||||
|
| `{{commitment_months}}` | `--commitment-months` CLI flag (default `3`) — used in the "Condition" pill and prorata-refund disclaimer of the retention offer |
|
||||||
|
|
||||||
The template uses a vintage `{{#expiry}} ... {{/expiry}}` block for the
|
The template uses a Mustache-style `{{#expiry}} ... {{/expiry}}` block for
|
||||||
optional expiry line — currently rendered as plain text (the script's
|
the optional expiry line. The renderer keeps the contents when the
|
||||||
simple `{{var}}` renderer doesn't strip the tags). If you don't want the
|
matching variable is truthy and drops them entirely otherwise — so if you
|
||||||
expiry sentence, edit the template directly to remove that block.
|
omit `--expiry` from the CLI, the "Le lien expire le …" sentence
|
||||||
|
disappears cleanly with no orphan tags showing.
|
||||||
|
|
||||||
## Source data — the two CSVs
|
## Source data — the two CSVs
|
||||||
|
|
||||||
|
|
|
||||||
25
scripts/campaigns/package-lock.json
generated
Normal file
25
scripts/campaigns/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "campaigns",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "campaigns",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"nodemailer": "^8.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||||
|
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
scripts/campaigns/package.json
Normal file
16
scripts/campaigns/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "campaigns",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "One-shot tool to send Giftbit gift cards to a list of contacts with a branded French email, bypassing Giftbit's English-only built-in delivery.",
|
||||||
|
"main": "create_giftbit_campaign.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"nodemailer": "^8.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
* --subject "Votre cadeau Gigafibre" \
|
* --subject "Votre cadeau Gigafibre" \
|
||||||
* --amount "50 $" \
|
* --amount "50 $" \
|
||||||
* --expiry "31 décembre 2026" \
|
* --expiry "31 décembre 2026" \
|
||||||
|
* --commitment-months 3 \
|
||||||
* --from "Gigafibre <noreply@gigafibre.ca>" \
|
* --from "Gigafibre <noreply@gigafibre.ca>" \
|
||||||
* --smtp-host in-v3.mailjet.com --smtp-port 587 \
|
* --smtp-host in-v3.mailjet.com --smtp-port 587 \
|
||||||
* --smtp-user $SMTP_USER --smtp-pass $SMTP_PASS \
|
* --smtp-user $SMTP_USER --smtp-pass $SMTP_PASS \
|
||||||
|
|
@ -75,6 +76,9 @@ const AMOUNT = args.amount || '50 $'
|
||||||
const EXPIRY = args.expiry || ''
|
const EXPIRY = args.expiry || ''
|
||||||
const SUBJECT = args.subject
|
const SUBJECT = args.subject
|
||||||
const FROM = args.from
|
const FROM = args.from
|
||||||
|
// Retention commitment used in the template's "Condition" pill and prorata
|
||||||
|
// disclaimer. Default 3 months. Override with --commitment-months 6 etc.
|
||||||
|
const COMMITMENT_MONTHS = args['commitment-months'] || '3'
|
||||||
|
|
||||||
// ── CSV parsing ────────────────────────────────────────────────────────────
|
// ── CSV parsing ────────────────────────────────────────────────────────────
|
||||||
// Minimal RFC-4180-ish parser. Handles quoted fields with embedded commas
|
// Minimal RFC-4180-ish parser. Handles quoted fields with embedded commas
|
||||||
|
|
@ -164,7 +168,18 @@ function matchByEmail (gifts, contacts, urlCol) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Template rendering ─────────────────────────────────────────────────────
|
// ── Template rendering ─────────────────────────────────────────────────────
|
||||||
|
// Supports two constructs:
|
||||||
|
// {{var}} simple substitution
|
||||||
|
// {{#var}}...{{/var}} section block: kept if var is truthy, dropped otherwise
|
||||||
|
//
|
||||||
|
// The section pass runs FIRST so that variable expansion can fill in the
|
||||||
|
// kept body. Non-greedy match with [\s\S] handles multi-line blocks (HTML
|
||||||
|
// templates span many lines between the open/close tags).
|
||||||
function render (tpl, vars) {
|
function render (tpl, vars) {
|
||||||
|
// Pass 1: section blocks (truthy → keep body, falsy → drop everything)
|
||||||
|
tpl = tpl.replace(/\{\{\s*#\s*(\w+)\s*\}\}([\s\S]*?)\{\{\s*\/\s*\1\s*\}\}/g,
|
||||||
|
(_, k, body) => (vars[k] ? body : ''))
|
||||||
|
// Pass 2: simple variable substitution
|
||||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => {
|
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => {
|
||||||
const v = vars[k]
|
const v = vars[k]
|
||||||
return v == null ? '' : String(v)
|
return v == null ? '' : String(v)
|
||||||
|
|
@ -247,13 +262,14 @@ async function main () {
|
||||||
for (let i = 0; i < matched.length; i++) {
|
for (let i = 0; i < matched.length; i++) {
|
||||||
const { contact, gift_url } = matched[i]
|
const { contact, gift_url } = matched[i]
|
||||||
const vars = {
|
const vars = {
|
||||||
firstname: contact.firstname || 'cher client',
|
firstname: contact.firstname || 'cher client',
|
||||||
lastname: contact.lastname || '',
|
lastname: contact.lastname || '',
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
description: contact.description || '',
|
description: contact.description || '',
|
||||||
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()
|
||||||
|
|
|
||||||
200
scripts/campaigns/setup_mailjet_webhook.js
Executable file
200
scripts/campaigns/setup_mailjet_webhook.js
Executable file
|
|
@ -0,0 +1,200 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* setup_mailjet_webhook.js — Register the Hub's /campaigns/webhook URL with
|
||||||
|
* Mailjet's Event API for every event type we care about.
|
||||||
|
*
|
||||||
|
* Mailjet's API uses ONE eventcallbackurl record PER event type. We want to
|
||||||
|
* be notified about: sent, open, click, bounce, blocked, spam, unsub. So this
|
||||||
|
* script idempotently registers (POST) or updates (PUT) one record per type.
|
||||||
|
*
|
||||||
|
* Auth: SMTP_USER + SMTP_PASS env vars (same creds work for the REST API on
|
||||||
|
* Mailjet — they call them API_PUBLIC_KEY / API_PRIVATE_KEY but the values
|
||||||
|
* are identical to the SMTP credentials).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* export SMTP_USER=<MJ_APIKEY_PUBLIC>
|
||||||
|
* export SMTP_PASS=<MJ_APIKEY_PRIVATE>
|
||||||
|
* node setup_mailjet_webhook.js --url https://msg.gigafibre.ca/campaigns/webhook
|
||||||
|
*
|
||||||
|
* # Production-safe defaults:
|
||||||
|
* # --is-backup false primary (not backup) callback URL
|
||||||
|
* # --group-events true send events as a JSON array (recommended,
|
||||||
|
* # minimizes hub load — one POST per ~50 events
|
||||||
|
* # instead of one POST per event)
|
||||||
|
*
|
||||||
|
* To inspect / delete what's registered:
|
||||||
|
* node setup_mailjet_webhook.js --list
|
||||||
|
* node setup_mailjet_webhook.js --delete <id>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('https')
|
||||||
|
|
||||||
|
const ALL_EVENTS = ['sent', 'open', 'click', 'bounce', 'blocked', 'spam', 'unsub']
|
||||||
|
// Safe defaults: only events that aren't typically already claimed by other
|
||||||
|
// integrations (WP-Mail-SMTP on targo.ca currently owns sent/bounce/blocked
|
||||||
|
// — see `--list` output). open + click are the events the gift campaign
|
||||||
|
// actually needs for tracking; spam + unsub are nice-to-have signals.
|
||||||
|
const SAFE_EVENTS = ['open', 'click', 'spam', 'unsub']
|
||||||
|
|
||||||
|
function parseArgs (argv) {
|
||||||
|
const out = {}
|
||||||
|
for (let i = 2; i < argv.length; i++) {
|
||||||
|
const a = argv[i]
|
||||||
|
if (a.startsWith('--')) {
|
||||||
|
const k = a.slice(2); const next = argv[i + 1]
|
||||||
|
if (!next || next.startsWith('--')) out[k] = true
|
||||||
|
else { out[k] = next; i++ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function mjApi (method, urlPath, body, { user, pass }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = body ? JSON.stringify(body) : null
|
||||||
|
const auth = Buffer.from(`${user}:${pass}`).toString('base64')
|
||||||
|
const req = https.request({
|
||||||
|
host: 'api.mailjet.com',
|
||||||
|
path: '/v3/REST' + urlPath,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic ' + auth,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}),
|
||||||
|
},
|
||||||
|
}, res => {
|
||||||
|
let chunks = ''
|
||||||
|
res.on('data', c => { chunks += c })
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve({ status: res.statusCode, body: chunks ? JSON.parse(chunks) : {} }) }
|
||||||
|
catch (e) { resolve({ status: res.statusCode, body: chunks }) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
if (data) req.write(data)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCallbacks (creds) {
|
||||||
|
const r = await mjApi('GET', '/eventcallbackurl?Limit=100', null, creds)
|
||||||
|
if (r.status !== 200) throw new Error(`GET eventcallbackurl ${r.status}: ${JSON.stringify(r.body)}`)
|
||||||
|
return r.body.Data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCallback (id, creds) {
|
||||||
|
const r = await mjApi('DELETE', `/eventcallbackurl/${id}`, null, creds)
|
||||||
|
return r.status === 204 || r.status === 200
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsert (eventType, url, isBackup, creds, existing) {
|
||||||
|
// existing array is the result of GET — find a matching record (same
|
||||||
|
// EventType + IsBackup combination). Mailjet only allows ONE primary +
|
||||||
|
// ONE backup URL per event, so this combination is the unique key.
|
||||||
|
const match = existing.find(r => r.EventType === eventType && Boolean(r.IsBackup) === isBackup)
|
||||||
|
const payload = { EventType: eventType, IsBackup: isBackup, Url: url, Status: 'alive', Version: 2 }
|
||||||
|
if (match) {
|
||||||
|
const r = await mjApi('PUT', `/eventcallbackurl/${match.ID}`, payload, creds)
|
||||||
|
return { action: 'updated', id: match.ID, status: r.status, ok: r.status === 200 }
|
||||||
|
} else {
|
||||||
|
const r = await mjApi('POST', '/eventcallbackurl', payload, creds)
|
||||||
|
const id = r.body.Data?.[0]?.ID
|
||||||
|
return { action: 'created', id, status: r.status, ok: r.status === 201 || r.status === 200 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main () {
|
||||||
|
const args = parseArgs(process.argv)
|
||||||
|
const user = process.env.SMTP_USER || process.env.MJ_APIKEY_PUBLIC
|
||||||
|
const pass = process.env.SMTP_PASS || process.env.MJ_APIKEY_PRIVATE
|
||||||
|
if (!user || !pass) {
|
||||||
|
console.error('Set SMTP_USER + SMTP_PASS (or MJ_APIKEY_PUBLIC + MJ_APIKEY_PRIVATE).')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const creds = { user, pass }
|
||||||
|
|
||||||
|
// --list — dump current config and exit
|
||||||
|
if (args.list) {
|
||||||
|
const callbacks = await listCallbacks(creds)
|
||||||
|
console.log(`\n── Registered event callbacks: ${callbacks.length} ──`)
|
||||||
|
for (const c of callbacks) {
|
||||||
|
const flag = c.IsBackup ? '[BACKUP]' : '[PRIMARY]'
|
||||||
|
console.log(` ${flag} id=${c.ID} event=${c.EventType.padEnd(10)} status=${c.Status} v${c.Version} → ${c.Url}`)
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --delete <id>
|
||||||
|
if (args.delete && args.delete !== true) {
|
||||||
|
const ok = await deleteCallback(args.delete, creds)
|
||||||
|
console.log(ok ? ` ✓ deleted callback ${args.delete}` : ` ✗ delete failed`)
|
||||||
|
process.exit(ok ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = args.url
|
||||||
|
if (!url || url === true) {
|
||||||
|
console.error('Missing --url <callback-url>')
|
||||||
|
console.error('Example: --url https://msg.gigafibre.ca/campaigns/webhook')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (!url.startsWith('https://')) {
|
||||||
|
console.error('Mailjet requires HTTPS. Got:', url)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const isBackup = args['is-backup'] === 'true' || args['is-backup'] === true
|
||||||
|
|
||||||
|
// Resolve which events to configure
|
||||||
|
let events
|
||||||
|
if (args.all) {
|
||||||
|
events = ALL_EVENTS
|
||||||
|
} else if (args.events && args.events !== true) {
|
||||||
|
events = args.events.split(',').map(e => e.trim()).filter(Boolean)
|
||||||
|
} else {
|
||||||
|
events = SAFE_EVENTS
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await listCallbacks(creds)
|
||||||
|
|
||||||
|
// Pre-flight: detect conflicts with existing PRIMARY records pointing
|
||||||
|
// elsewhere. Refuse to overwrite unless --force-takeover is passed.
|
||||||
|
const conflicts = events
|
||||||
|
.map(ev => ({ ev, hit: existing.find(r => r.EventType === ev && Boolean(r.IsBackup) === isBackup) }))
|
||||||
|
.filter(c => c.hit && c.hit.Url !== url)
|
||||||
|
|
||||||
|
console.log(`\n── Mailjet Event API webhook setup ──`)
|
||||||
|
console.log(` callback URL: ${url}`)
|
||||||
|
console.log(` type: ${isBackup ? 'BACKUP' : 'PRIMARY'}`)
|
||||||
|
console.log(` events: ${events.join(', ')}`)
|
||||||
|
console.log(` existing: ${existing.length} records on the account`)
|
||||||
|
|
||||||
|
if (conflicts.length && !args['force-takeover']) {
|
||||||
|
console.log(`\n ⚠ Conflicts detected — these events already point elsewhere:`)
|
||||||
|
for (const c of conflicts) {
|
||||||
|
console.log(` • ${c.ev.padEnd(10)} → ${c.hit.Url} (id=${c.hit.ID})`)
|
||||||
|
}
|
||||||
|
console.log(`\n Refusing to overwrite without --force-takeover. Either:`)
|
||||||
|
console.log(` • Exclude the conflicting events: --events open,click`)
|
||||||
|
console.log(` • Or override the existing config: --force-takeover`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
let okCount = 0
|
||||||
|
for (const ev of events) {
|
||||||
|
process.stdout.write(` ${ev.padEnd(10)} ... `)
|
||||||
|
const r = await upsert(ev, url, isBackup, creds, existing)
|
||||||
|
if (r.ok) {
|
||||||
|
okCount++
|
||||||
|
console.log(`✓ ${r.action} (id=${r.id})`)
|
||||||
|
} else {
|
||||||
|
console.log(`✗ status=${r.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n ${okCount}/${events.length} events configured.`)
|
||||||
|
console.log(`\n Verify with: node setup_mailjet_webhook.js --list`)
|
||||||
|
console.log(` Mailjet dashboard: Account Settings → REST API → Event tracking\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('Fatal:', e); process.exit(1) })
|
||||||
880
scripts/campaigns/templates/gift-email-en.html
Normal file
880
scripts/campaigns/templates/gift-email-en.html
Normal file
|
|
@ -0,0 +1,880 @@
|
||||||
|
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
|
||||||
|
@media only screen and (min-width: 620px) {
|
||||||
|
.u-row {
|
||||||
|
width: 600px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.u-row .u-col-100 {
|
||||||
|
width: 600px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 620px) {
|
||||||
|
.u-row-container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding-left: 0px !important;
|
||||||
|
padding-right: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col {
|
||||||
|
display: block !important;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 320px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col > div {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
body{margin:0;padding:0}table,td,tr{border-collapse:collapse;vertical-align:top}.ie-container table,.mso-container table{table-layout:fixed}*{line-height:inherit}a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:none!important}
|
||||||
|
|
||||||
|
|
||||||
|
table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700" rel="stylesheet" type="text/css"><!--<![endif]-->
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24">
|
||||||
|
<!--[if IE]><div class="ie-container"><![endif]-->
|
||||||
|
<!--[if mso]><div class="mso-container"><![endif]-->
|
||||||
|
<table role="presentation" id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #F5FAF7;width:100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="display:none !important;visibility:hidden;mso-hide:all;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
|
||||||
|
Because great connections aren't just about fiber — they're about people too.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr style="vertical-align: top">
|
||||||
|
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
|
||||||
|
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #F5FAF7;" bgcolor="#F5FAF7"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center" style="border-collapse: collapse;"><tr><td style="padding:0;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="u-row-container" style="padding: 0px;background-color: transparent;">
|
||||||
|
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
|
||||||
|
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
|
||||||
|
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr style="background-color: transparent;"><![endif]-->
|
||||||
|
|
||||||
|
<!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 20px 0px 0px;"><![endif]-->
|
||||||
|
<div class="u-col u-col-100" style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
|
||||||
|
<div style="height: 100%;width: 100% !important;">
|
||||||
|
<!--[if (!mso)&(!IE)]><!--><div style="box-sizing: border-box; height: 100%; padding: 20px 0px 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;"><!--<![endif]-->
|
||||||
|
|
||||||
|
<table style="font-family:'Plus Jakarta Sans', sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">Because great connections aren't just about fiber — they're about people too.</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label="Your exclusive offer from TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
|
||||||
|
>
|
||||||
|
<!-- ════════ HEADER LOGO ════════ -->
|
||||||
|
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:12px 12px 0 0;overflow:hidden;">
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="border:1px solid #e5e7eb;border-bottom:none;border-radius:12px 12px 0 0;direction:ltr;font-size:0px;padding:28px 36px 22px;text-align:center;"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:0;word-break:break-word;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:140px;">
|
||||||
|
|
||||||
|
<img
|
||||||
|
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="140" height="auto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
|
||||||
|
<!-- ════════ GREETING + INTRO (4 blocs sémantiques) ════════
|
||||||
|
Bloc 1 — Greeting personnalisé
|
||||||
|
Bloc 2 — Manifeste brand TARGO (2 lignes qui voyagent ensemble)
|
||||||
|
Bloc 3 — Annonce du cadeau
|
||||||
|
Bloc 4 — Upsell forfaits + invitation contact
|
||||||
|
Chaque bloc est un mj-text indépendant = drag-droppable séparément. -->
|
||||||
|
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:26px 36px 18px;text-align:center;"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#374151;"
|
||||||
|
>Hey {{firstname}},</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;"
|
||||||
|
>Summer is here, and so is something special — for a limited time.</div>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:500;line-height:1.5;text-align:justify;color:#1B2E24;"
|
||||||
|
>Thank you for choosing local. Your support helps keep our community connected.<br />
|
||||||
|
Because great connections aren't just about fiber — they're about people too.</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;">Because our customers trust us, we're now able to offer the <strong>fastest plans around</strong>, with speeds up to <strong>3.5 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"
|
||||||
|
>
|
||||||
|
🎁 {{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>
|
||||||
|
· 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>
|
||||||
98
scripts/campaigns/templates/gift-email-en.json
Normal file
98
scripts/campaigns/templates/gift-email-en.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,99 +1,880 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
<html lang="fr">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<!--[if gte mso 9]>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<xml>
|
||||||
<title>Un cadeau de Gigafibre</title>
|
<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>
|
</head>
|
||||||
<body style="margin:0; padding:0; background:#f5f6fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; color:#1f2937; line-height:1.5;">
|
|
||||||
|
|
||||||
<!-- Spacer above the card -->
|
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24">
|
||||||
<div style="height:32px;"></div>
|
<!--[if IE]><div class="ie-container"><![endif]-->
|
||||||
|
<!--[if mso]><div class="mso-container"><![endif]-->
|
||||||
|
<table role="presentation" 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]-->
|
||||||
|
|
||||||
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
|
||||||
|
<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 align="center">
|
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left">
|
||||||
|
|
||||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
|
<div>
|
||||||
style="max-width:600px; background:#ffffff; border-radius:14px; overflow:hidden; box-shadow:0 6px 24px rgba(15,23,42,0.07);">
|
<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>
|
||||||
|
|
||||||
<!-- Header band -->
|
<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>
|
<tr>
|
||||||
<td style="background:linear-gradient(135deg,#4f46e5 0%, #7c3aed 100%); padding:36px 32px 28px; text-align:center; color:#ffffff;">
|
<td style="width:140px;">
|
||||||
<div style="font-size:0.78rem; font-weight:700; letter-spacing:0.12em; text-transform:uppercase; opacity:0.85;">
|
|
||||||
Gigafibre · Récompense
|
<img
|
||||||
</div>
|
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"
|
||||||
<div style="font-size:2.2rem; line-height:1.1; margin-top:10px; font-weight:800;">
|
/>
|
||||||
🎁 Un cadeau pour vous
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</tbody>
|
||||||
<!-- Body -->
|
</table>
|
||||||
<tr>
|
|
||||||
<td style="padding:36px 36px 12px;">
|
</td>
|
||||||
<p style="margin:0 0 16px; font-size:1.05rem;">Bonjour {{firstname}},</p>
|
</tr>
|
||||||
|
|
||||||
<p style="margin:0 0 16px;">
|
</tbody>
|
||||||
Merci de faire partie de la famille Gigafibre. Pour vous remercier
|
</table>
|
||||||
de votre fidélité, voici une carte-cadeau d'une valeur de
|
|
||||||
<strong>{{amount}}</strong>, utilisable sur les marchands de votre choix.
|
</div>
|
||||||
</p>
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
<!-- CTA button -->
|
</td>
|
||||||
<div style="text-align:center; margin:32px 0 28px;">
|
</tr>
|
||||||
<a href="{{gift_url}}"
|
</tbody>
|
||||||
style="display:inline-block; padding:16px 36px; background:#4f46e5; color:#ffffff;
|
|
||||||
text-decoration:none; font-weight:700; font-size:1.05rem;
|
|
||||||
border-radius:10px; box-shadow:0 4px 12px rgba(79,70,229,0.35);">
|
|
||||||
Récupérer mon cadeau →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="margin:0 0 6px; font-size:0.9rem; color:#6b7280;">
|
|
||||||
Le lien vous mène à une page sécurisée où vous pourrez choisir la
|
|
||||||
marque qui vous fait plaisir (Amazon, Tim Hortons, SAQ, App Store,
|
|
||||||
et plusieurs autres).
|
|
||||||
</p>
|
|
||||||
{{#expiry}}
|
|
||||||
<p style="margin:6px 0 0; font-size:0.85rem; color:#9ca3af;">
|
|
||||||
⏰ Le lien expire le <strong>{{expiry}}</strong>.
|
|
||||||
</p>
|
|
||||||
{{/expiry}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Why this email -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 36px 28px;">
|
|
||||||
<div style="border-top:1px solid #e5e7eb; padding-top:20px; font-size:0.82rem; color:#6b7280;">
|
|
||||||
Vous recevez ce cadeau parce que vous êtes client(e) Gigafibre à
|
|
||||||
l'adresse <strong style="color:#374151;">{{description}}</strong>.
|
|
||||||
Si vous avez la moindre question, écrivez-nous à
|
|
||||||
<a href="mailto:facturation@targointernet.com" style="color:#4f46e5;">facturation@targointernet.com</a>
|
|
||||||
ou appelez-nous au <a href="tel:5142421500" style="color:#4f46e5;">514 242-1500</a>.
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Footer band -->
|
|
||||||
<tr>
|
|
||||||
<td style="background:#f9fafb; padding:18px 32px; text-align:center; border-top:1px solid #e5e7eb;">
|
|
||||||
<div style="font-size:0.75rem; color:#9ca3af;">
|
|
||||||
Gigafibre — Internet fibre optique au Québec<br>
|
|
||||||
<a href="https://www.gigafibre.ca" style="color:#9ca3af; text-decoration:underline;">www.gigafibre.ca</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
</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 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"
|
||||||
|
>
|
||||||
|
🎁 {{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>
|
||||||
|
· 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>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
|
<!--[if mso]></div><![endif]-->
|
||||||
<!-- Spacer below the card -->
|
<!--[if IE]></div><![endif]-->
|
||||||
<div style="height:48px;"></div>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
98
scripts/campaigns/templates/gift-email-fr.json
Normal file
98
scripts/campaigns/templates/gift-email-fr.json
Normal file
File diff suppressed because one or more lines are too long
5
services/email-editor/.gitignore
vendored
Normal file
5
services/email-editor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
52
services/email-editor/Dockerfile
Normal file
52
services/email-editor/Dockerfile
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Multi-stage Dockerfile for the email-editor microservice.
|
||||||
|
# Stage 1: Vite build into dist/
|
||||||
|
# Stage 2: nginx serving the static files
|
||||||
|
|
||||||
|
# ── Stage 1: build ──
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install only what's needed for build (no native deps required)
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install --silent
|
||||||
|
|
||||||
|
COPY tsconfig.json vite.config.ts index.html ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Inline the prod hub URL at build time. Override via --build-arg on docker
|
||||||
|
# build if running against a different hub (e.g. staging).
|
||||||
|
ARG VITE_HUB_URL=https://msg.gigafibre.ca
|
||||||
|
ENV VITE_HUB_URL=$VITE_HUB_URL
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: nginx serve ──
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Drop the default nginx config and inject a minimal SPA-friendly one
|
||||||
|
# (all routes serve index.html — easy-email is a SPA, query params drive
|
||||||
|
# which template to load).
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
COPY <<EOF /etc/nginx/conf.d/default.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Long cache for hashed assets (vite outputs them with content hash in name)
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — every request returns index.html so the React app handles
|
||||||
|
# routing client-side based on ?name=... query
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
63
services/email-editor/README.md
Normal file
63
services/email-editor/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# TARGO Email Editor
|
||||||
|
|
||||||
|
Standalone email template editor microservice — React + Vite + [easy-email](https://github.com/zalify/easy-email)
|
||||||
|
(OSS WYSIWYG email builder, MJML-based).
|
||||||
|
|
||||||
|
Embedded as an iframe in the ops UI's `/campaigns/templates/:name` page.
|
||||||
|
Talks to the hub's `/campaigns/templates/*` REST endpoints for load/save.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ops UI (Vue) → iframe → editor.gigafibre.ca → REST → msg.gigafibre.ca (hub)
|
||||||
|
└─ writes .mjml + .html
|
||||||
|
to /opt/targo-hub/templates/
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL params
|
||||||
|
|
||||||
|
- `?name=gift-email-fr` — which template to load (defaults to gift-email-fr)
|
||||||
|
|
||||||
|
## postMessage protocol (to parent window)
|
||||||
|
|
||||||
|
Emitted on save success:
|
||||||
|
```js
|
||||||
|
{ type: 'email-editor:saved', template: 'gift-email-fr', ts: 1700000000000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
The parent ops UI listens via:
|
||||||
|
```js
|
||||||
|
window.addEventListener('message', (e) => {
|
||||||
|
if (e.data.type === 'email-editor:saved') {
|
||||||
|
// refresh preview / show toast
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173?name=gift-email-fr
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `VITE_HUB_URL` env to point at a non-prod hub if needed.
|
||||||
|
|
||||||
|
## Build + deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Traefik auto-provisions HTTPS at `editor.gigafibre.ca` (Let's Encrypt via
|
||||||
|
the `letsencrypt` resolver shared with the rest of our stack).
|
||||||
|
|
||||||
|
## Known limitations (Phase 1)
|
||||||
|
|
||||||
|
- Existing MJML templates from the hub are NOT auto-imported into the
|
||||||
|
easy-email JSON tree (no MJML → JSON parser in easy-email out of the box).
|
||||||
|
The editor starts from an empty page. User rebuilds visually with the
|
||||||
|
hub's compiled HTML as visual reference.
|
||||||
|
- TODO Phase 3: integrate an MJML → easy-email-JSON parser (likely fork or
|
||||||
|
reverse-engineer JsonToMjml).
|
||||||
28
services/email-editor/docker-compose.yml
Normal file
28
services/email-editor/docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
services:
|
||||||
|
email-editor:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
# Override at build time if pointing at staging:
|
||||||
|
# docker compose build --build-arg VITE_HUB_URL=https://staging-msg.gigafibre.ca
|
||||||
|
VITE_HUB_URL: https://msg.gigafibre.ca
|
||||||
|
container_name: email-editor
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=proxy"
|
||||||
|
# Public route — same Authentik forwardAuth pattern as ops UI could be
|
||||||
|
# added here too, but for now the editor is iframed from the (already
|
||||||
|
# authenticated) ops UI so external auth is layered through the parent.
|
||||||
|
# Leaving it open means anyone with the URL can edit templates — fine
|
||||||
|
# for the iframe-only use case; harden later if exposed standalone.
|
||||||
|
- "traefik.http.routers.email-editor.rule=Host(`editor.gigafibre.ca`)"
|
||||||
|
- "traefik.http.routers.email-editor.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.email-editor.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.email-editor.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
16
services/email-editor/index.html
Normal file
16
services/email-editor/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TARGO email editor</title>
|
||||||
|
<!-- easy-email's required styles. Order matters: extensions first, then editor. -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/easy-email-editor@4.16.6/lib/style.css" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/easy-email-extensions@4.16.5/lib/style.css" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@arco-themes/react-easy-email-theme/css/arco.css" />
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3219
services/email-editor/package-lock.json
generated
Normal file
3219
services/email-editor/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
services/email-editor/package.json
Normal file
25
services/email-editor/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "targo-email-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Standalone email editor microservice — easy-email (React) embedded via iframe in the ops UI. Talks to the targo-hub /campaigns/templates/* endpoints to load and save campaign templates.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"easy-email-core": "^4.16.5",
|
||||||
|
"easy-email-editor": "^4.16.6",
|
||||||
|
"easy-email-extensions": "^4.16.5",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
234
services/email-editor/src/EmailEditorApp.tsx
Normal file
234
services/email-editor/src/EmailEditorApp.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { EmailEditorProvider, EmailEditor, IEmailTemplate } from 'easy-email-editor'
|
||||||
|
import { ExtensionProps, StandardLayout } from 'easy-email-extensions'
|
||||||
|
import { BasicType, AdvancedType, JsonToMjml } from 'easy-email-core'
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Targo email editor — wraps easy-email-editor with our hub integration:
|
||||||
|
//
|
||||||
|
// 1. On mount: read ?name=<template-name> from URL, GET its MJML from the hub
|
||||||
|
// 2. Render easy-email with the loaded MJML
|
||||||
|
// 3. On save (Cmd-S or button): convert easy-email JSON → MJML, PUT to hub
|
||||||
|
// 4. postMessage to parent window so the wrapping ops UI knows we saved
|
||||||
|
//
|
||||||
|
// Hub URL is read from VITE_HUB_URL env (defaults to msg.gigafibre.ca in prod).
|
||||||
|
// The hub does the MJML → HTML compilation server-side; we just send the MJML.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HUB_URL = (import.meta as any).env?.VITE_HUB_URL || 'https://msg.gigafibre.ca'
|
||||||
|
|
||||||
|
// Merge tags exposed to the editor's "Variables" panel. These map to the
|
||||||
|
// Mustache variables the hub renders at send time.
|
||||||
|
const MERGE_TAGS = {
|
||||||
|
firstname: '{{firstname}}',
|
||||||
|
lastname: '{{lastname}}',
|
||||||
|
email: '{{email}}',
|
||||||
|
amount: '{{amount}}',
|
||||||
|
gift_url: '{{gift_url}}',
|
||||||
|
description: '{{description}}',
|
||||||
|
expiry: '{{expiry}}',
|
||||||
|
commitment_months: '{{commitment_months}}',
|
||||||
|
year: '{{year}}',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal initial template returned when the hub has no content yet (rare —
|
||||||
|
// since we always pre-create gift-email-fr.mjml). Kept defensive.
|
||||||
|
function emptyTemplate(): IEmailTemplate {
|
||||||
|
return {
|
||||||
|
subject: 'Une offre exclusive de TARGO',
|
||||||
|
subTitle: 'Comme toi, on aime les connexions stables et les relations durables.',
|
||||||
|
content: {
|
||||||
|
type: BasicType.PAGE,
|
||||||
|
data: {
|
||||||
|
value: {
|
||||||
|
breakpoint: '480px',
|
||||||
|
headAttributes: '',
|
||||||
|
'font-size': '16px',
|
||||||
|
'line-height': '1.5',
|
||||||
|
'font-family': "'Plus Jakarta Sans', Helvetica, Arial, sans-serif",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attributes: {
|
||||||
|
'background-color': '#F5FAF7',
|
||||||
|
width: '600px',
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
} as any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailEditorApp() {
|
||||||
|
const [templateName, setTemplateName] = useState<string>('')
|
||||||
|
const [initialValues, setInitialValues] = useState<IEmailTemplate | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Read template name from URL and fetch its MJML content from the hub on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const name = params.get('name') || 'gift-email-fr'
|
||||||
|
setTemplateName(name)
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`)
|
||||||
|
if (!res.ok) throw new Error(`Hub returned ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
// Three sources of truth, in priority order:
|
||||||
|
// 1. .json file → easy-email JSON tree (fast, full restore)
|
||||||
|
// 2. .mjml file → MJML source (no auto-importer, start blank)
|
||||||
|
// 3. nothing → empty page
|
||||||
|
if (data.json) {
|
||||||
|
try {
|
||||||
|
const parsed = typeof data.json === 'string' ? JSON.parse(data.json) : data.json
|
||||||
|
setInitialValues(parsed as IEmailTemplate)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(`Stored JSON is invalid (${e.message}) — starting blank`)
|
||||||
|
setInitialValues(emptyTemplate())
|
||||||
|
}
|
||||||
|
} else if (data.mjml) {
|
||||||
|
setError(`Existing MJML (${(data.mjml || '').length}b) cannot be auto-imported into easy-email. ` +
|
||||||
|
`Reconstructing this template once with the drag-drop blocks here will save an editable JSON snapshot for next time.`)
|
||||||
|
setInitialValues(emptyTemplate())
|
||||||
|
} else {
|
||||||
|
setInitialValues(emptyTemplate())
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(`Could not load template "${name}": ${e.message}`)
|
||||||
|
setInitialValues(emptyTemplate())
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Save → convert easy-email's JSON tree to MJML, PUT to hub
|
||||||
|
const onSave = useCallback(async (values: IEmailTemplate) => {
|
||||||
|
if (!templateName) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const mjmlSource = JsonToMjml({
|
||||||
|
data: values.content as any,
|
||||||
|
mode: 'production',
|
||||||
|
context: values.content as any,
|
||||||
|
})
|
||||||
|
// Send BOTH the compiled MJML (for send-worker) AND the raw easy-email
|
||||||
|
// JSON tree (for next-load restore). Hub persists .mjml + .html + .json
|
||||||
|
// — the JSON file is the canonical editing source going forward.
|
||||||
|
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mjml: mjmlSource, json: values }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(errBody.error || `Hub returned ${res.status}`)
|
||||||
|
}
|
||||||
|
// Notify parent window (the ops UI iframing us) that we saved
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ type: 'email-editor:saved', template: templateName, ts: Date.now() },
|
||||||
|
'*',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Visual confirmation (toast handled by easy-email's own UI)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(`Save failed: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [templateName])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: 32, textAlign: 'center' }}>Loading template…</div>
|
||||||
|
}
|
||||||
|
if (!initialValues) {
|
||||||
|
return <div style={{ padding: 32, color: 'red' }}>{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Top bar — shows template name + save state + parent communication */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#1B2E24',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<strong>TARGO Email Editor</strong>
|
||||||
|
<span style={{ opacity: 0.7 }}>· {templateName}</span>
|
||||||
|
{saving && <span style={{ color: '#00C853' }}>· Saving…</span>}
|
||||||
|
{error && (
|
||||||
|
<span style={{ color: '#fbbf24', fontSize: 12, marginLeft: 'auto', maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Editor */}
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<EmailEditorProvider
|
||||||
|
data={initialValues}
|
||||||
|
height="100%"
|
||||||
|
autoComplete
|
||||||
|
dashed={false}
|
||||||
|
mergeTags={MERGE_TAGS}
|
||||||
|
mergeTagGenerate={(tag: string) => `{{${tag}}}`}
|
||||||
|
onSubmit={onSave}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<StandardLayout
|
||||||
|
showSourceCode
|
||||||
|
categories={DEFAULT_CATEGORIES}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EmailEditorProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block categories shown in the left sidebar — same set easy-email uses by
|
||||||
|
// default, organized for email composition.
|
||||||
|
const DEFAULT_CATEGORIES: ExtensionProps['categories'] = [
|
||||||
|
{
|
||||||
|
label: 'Content',
|
||||||
|
active: true,
|
||||||
|
blocks: [
|
||||||
|
{ type: AdvancedType.TEXT },
|
||||||
|
{ type: AdvancedType.BUTTON },
|
||||||
|
{ type: AdvancedType.IMAGE },
|
||||||
|
{ type: AdvancedType.DIVIDER },
|
||||||
|
{ type: AdvancedType.SPACER },
|
||||||
|
{ type: AdvancedType.HERO },
|
||||||
|
{ type: AdvancedType.WRAPPER },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Layout',
|
||||||
|
active: true,
|
||||||
|
displayType: 'column',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
title: '1 column',
|
||||||
|
payload: [['100%']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '2 columns',
|
||||||
|
payload: [['50%', '50%']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '3 columns',
|
||||||
|
payload: [['33%', '33%', '33%']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '4 columns',
|
||||||
|
payload: [['25%', '25%', '25%', '25%']],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
14
services/email-editor/src/main.tsx
Normal file
14
services/email-editor/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { EmailEditorApp } from './EmailEditorApp'
|
||||||
|
|
||||||
|
// Entry point — mounts the easy-email editor app on #root.
|
||||||
|
// Template name comes from URL query: ?name=gift-email-fr
|
||||||
|
// In production the page lives at editor.gigafibre.ca and is iframed from
|
||||||
|
// the ops UI's /campaigns/templates/:name route.
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<EmailEditorApp />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
20
services/email-editor/tsconfig.json
Normal file
20
services/email-editor/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
19
services/email-editor/vite.config.ts
Normal file
19
services/email-editor/vite.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// Vite config — served at editor.gigafibre.ca behind Traefik in prod.
|
||||||
|
// In dev: `npm run dev` exposes http://localhost:5173.
|
||||||
|
// Base path is '/' since this is a standalone microservice (own domain), not
|
||||||
|
// a sub-path of another app.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: '0.0.0.0', // accessible from outside container in dev
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsDir: 'assets',
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -9,6 +9,12 @@ services:
|
||||||
- ./public:/app/public:ro
|
- ./public:/app/public:ro
|
||||||
- ./package.json:/app/package.json:ro
|
- ./package.json:/app/package.json:ro
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
# Templates RW so the campaign editor can save .html + .json + .mjml
|
||||||
|
# files via PUT /campaigns/templates/:name. Was :ro previously which
|
||||||
|
# broke save with EROFS — fixed when Unlayer started writing back.
|
||||||
|
- ./templates:/app/templates
|
||||||
|
# User-uploaded assets (images dragged into the editor)
|
||||||
|
- ./uploads:/app/uploads
|
||||||
- hub_modules:/app/node_modules
|
- hub_modules:/app/node_modules
|
||||||
command: sh -c "npm install --production 2>&1 | tail -1 && node server.js"
|
command: sh -c "npm install --production 2>&1 | tail -1 && node server.js"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
|
|
||||||
1589
services/targo-hub/lib/campaigns.js
Normal file
1589
services/targo-hub/lib/campaigns.js
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -47,11 +47,15 @@ async function sendEmail (opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailOpts = {
|
const mailOpts = {
|
||||||
from: cfg.MAIL_FROM,
|
from: opts.from || cfg.MAIL_FROM,
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
subject: opts.subject,
|
subject: opts.subject,
|
||||||
html: opts.html,
|
html: opts.html,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
// Custom headers (e.g. X-MJ-CustomID for Mailjet Event API webhook
|
||||||
|
// correlation — Mailjet echoes the CustomID back in every event so
|
||||||
|
// we can match webhook events to the originating recipient).
|
||||||
|
headers: opts.headers || {},
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.pdfBuffer && opts.pdfFilename) {
|
if (opts.pdfBuffer && opts.pdfFilename) {
|
||||||
|
|
@ -65,9 +69,16 @@ async function sendEmail (opts) {
|
||||||
try {
|
try {
|
||||||
const info = await transport.sendMail(mailOpts)
|
const info = await transport.sendMail(mailOpts)
|
||||||
log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`)
|
log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`)
|
||||||
return true
|
// Return the info object (always truthy) so callers can capture
|
||||||
|
// info.messageId for tracking. Legacy `if (await sendEmail(...))`
|
||||||
|
// callers continue to work because the object is truthy.
|
||||||
|
return info || { messageId: null }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`Email send failed to ${opts.to}: ${e.message}`)
|
log(`Email send failed to ${opts.to}: ${e.message}`)
|
||||||
|
// Legacy contract: return false on failure. New callers that need the
|
||||||
|
// error string should check `Promise.allSettled` style or wrap in try
|
||||||
|
// (we don't throw here to preserve existing `if (await sendEmail(...))`
|
||||||
|
// call sites). The error is logged above.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3015
services/targo-hub/package-lock.json
generated
3015
services/targo-hub/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -7,13 +7,14 @@
|
||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"mjml": "^5.2.2",
|
||||||
"mongodb": "^6.12.0",
|
"mongodb": "^6.12.0",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
"net-snmp": "^3.26.1",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"twilio": "^5.5.0",
|
"twilio": "^5.5.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7"
|
||||||
"net-snmp": "^3.26.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
147
services/targo-hub/scripts/convert-html-to-unlayer.js
Executable file
147
services/targo-hub/scripts/convert-html-to-unlayer.js
Executable file
|
|
@ -0,0 +1,147 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* convert-html-to-unlayer.js — one-time converter from our existing compiled
|
||||||
|
* .html templates into Unlayer design JSON. Run after MJML→Unlayer 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`)
|
||||||
|
|
@ -119,6 +119,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
|
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
|
||||||
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
|
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
|
||||||
if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
|
if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
|
||||||
|
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
|
||||||
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
|
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
|
||||||
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
|
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
|
||||||
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
|
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
|
||||||
|
|
|
||||||
880
services/targo-hub/templates/gift-email-en.html
Normal file
880
services/targo-hub/templates/gift-email-en.html
Normal file
|
|
@ -0,0 +1,880 @@
|
||||||
|
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
|
||||||
|
@media only screen and (min-width: 620px) {
|
||||||
|
.u-row {
|
||||||
|
width: 600px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.u-row .u-col-100 {
|
||||||
|
width: 600px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 620px) {
|
||||||
|
.u-row-container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding-left: 0px !important;
|
||||||
|
padding-right: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col {
|
||||||
|
display: block !important;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 320px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col > div {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
body{margin:0;padding:0}table,td,tr{border-collapse:collapse;vertical-align:top}.ie-container table,.mso-container table{table-layout:fixed}*{line-height:inherit}a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:none!important}
|
||||||
|
|
||||||
|
|
||||||
|
table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700" rel="stylesheet" type="text/css"><!--<![endif]-->
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24">
|
||||||
|
<!--[if IE]><div class="ie-container"><![endif]-->
|
||||||
|
<!--[if mso]><div class="mso-container"><![endif]-->
|
||||||
|
<table role="presentation" id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #F5FAF7;width:100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="display:none !important;visibility:hidden;mso-hide:all;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
|
||||||
|
Because great connections aren't just about fiber — they're about people too.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr style="vertical-align: top">
|
||||||
|
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
|
||||||
|
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #F5FAF7;" bgcolor="#F5FAF7"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center" style="border-collapse: collapse;"><tr><td style="padding:0;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="u-row-container" style="padding: 0px;background-color: transparent;">
|
||||||
|
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
|
||||||
|
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
|
||||||
|
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr style="background-color: transparent;"><![endif]-->
|
||||||
|
|
||||||
|
<!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 20px 0px 0px;"><![endif]-->
|
||||||
|
<div class="u-col u-col-100" style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
|
||||||
|
<div style="height: 100%;width: 100% !important;">
|
||||||
|
<!--[if (!mso)&(!IE)]><!--><div style="box-sizing: border-box; height: 100%; padding: 20px 0px 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;"><!--<![endif]-->
|
||||||
|
|
||||||
|
<table style="font-family:'Plus Jakarta Sans', sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">Because great connections aren't just about fiber — they're about people too.</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label="Your exclusive offer from TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
|
||||||
|
>
|
||||||
|
<!-- ════════ HEADER LOGO ════════ -->
|
||||||
|
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:12px 12px 0 0;overflow:hidden;">
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="border:1px solid #e5e7eb;border-bottom:none;border-radius:12px 12px 0 0;direction:ltr;font-size:0px;padding:28px 36px 22px;text-align:center;"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:0;word-break:break-word;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:140px;">
|
||||||
|
|
||||||
|
<img
|
||||||
|
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="140" height="auto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
|
||||||
|
<!-- ════════ GREETING + INTRO (4 blocs sémantiques) ════════
|
||||||
|
Bloc 1 — Greeting personnalisé
|
||||||
|
Bloc 2 — Manifeste brand TARGO (2 lignes qui voyagent ensemble)
|
||||||
|
Bloc 3 — Annonce du cadeau
|
||||||
|
Bloc 4 — Upsell forfaits + invitation contact
|
||||||
|
Chaque bloc est un mj-text indépendant = drag-droppable séparément. -->
|
||||||
|
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:26px 36px 18px;text-align:center;"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#374151;"
|
||||||
|
>Hey {{firstname}},</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;"
|
||||||
|
>Summer is here, and so is something special — for a limited time.</div>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:500;line-height:1.5;text-align:justify;color:#1B2E24;"
|
||||||
|
>Thank you for choosing local. Your support helps keep our community connected.<br />
|
||||||
|
Because great connections aren't just about fiber — they're about people too.</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;">Because our customers trust us, we're now able to offer the <strong>fastest plans around</strong>, with speeds up to <strong>3.5 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"
|
||||||
|
>
|
||||||
|
🎁 {{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>
|
||||||
|
· 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>
|
||||||
98
services/targo-hub/templates/gift-email-en.json
Normal file
98
services/targo-hub/templates/gift-email-en.json
Normal file
File diff suppressed because one or more lines are too long
880
services/targo-hub/templates/gift-email-fr.html
Normal file
880
services/targo-hub/templates/gift-email-fr.html
Normal file
|
|
@ -0,0 +1,880 @@
|
||||||
|
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
|
||||||
|
@media only screen and (min-width: 620px) {
|
||||||
|
.u-row {
|
||||||
|
width: 600px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.u-row .u-col-100 {
|
||||||
|
width: 600px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 620px) {
|
||||||
|
.u-row-container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding-left: 0px !important;
|
||||||
|
padding-right: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col {
|
||||||
|
display: block !important;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 320px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-row .u-col > div {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
body{margin:0;padding:0}table,td,tr{border-collapse:collapse;vertical-align:top}.ie-container table,.mso-container table{table-layout:fixed}*{line-height:inherit}a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:none!important}
|
||||||
|
|
||||||
|
|
||||||
|
table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700" rel="stylesheet" type="text/css"><!--<![endif]-->
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="clean-body u_body" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #F5FAF7;color: #1B2E24">
|
||||||
|
<!--[if IE]><div class="ie-container"><![endif]-->
|
||||||
|
<!--[if mso]><div class="mso-container"><![endif]-->
|
||||||
|
<table role="presentation" id="u_body" style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #F5FAF7;width:100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="display:none !important;visibility:hidden;mso-hide:all;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
|
||||||
|
Comme toi, on aime les connexions stables et les relations durables.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr style="vertical-align: top">
|
||||||
|
<td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
|
||||||
|
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #F5FAF7;" bgcolor="#F5FAF7"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center" style="border-collapse: collapse;"><tr><td style="padding:0;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="u-row-container" style="padding: 0px;background-color: transparent;">
|
||||||
|
<div class="u-row" style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;">
|
||||||
|
<div style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
|
||||||
|
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr style="background-color: transparent;"><![endif]-->
|
||||||
|
|
||||||
|
<!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 20px 0px 0px;"><![endif]-->
|
||||||
|
<div class="u-col u-col-100" style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
|
||||||
|
<div style="height: 100%;width: 100% !important;">
|
||||||
|
<!--[if (!mso)&(!IE)]><!--><div style="box-sizing: border-box; height: 100%; padding: 20px 0px 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;"><!--<![endif]-->
|
||||||
|
|
||||||
|
<table style="font-family:'Plus Jakarta Sans', sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="overflow-wrap:break-word;word-break:break-word;padding:0px;font-family:'Plus Jakarta Sans', sans-serif;" align="left">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">Comme toi, on aime les connexions stables et les relations durables.</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label="Une offre exclusive de TARGO" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;"
|
||||||
|
>
|
||||||
|
<!-- ════════ HEADER LOGO ════════ -->
|
||||||
|
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;border-radius:12px 12px 0 0;overflow:hidden;">
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;border-collapse:separate;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="border:1px solid #e5e7eb;border-bottom:none;border-radius:12px 12px 0 0;direction:ltr;font-size:0px;padding:28px 36px 22px;text-align:center;"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:0;word-break:break-word;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:140px;">
|
||||||
|
|
||||||
|
<img
|
||||||
|
alt="TARGO" src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="140" height="auto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
|
||||||
|
<!-- ════════ GREETING + INTRO (4 blocs sémantiques) ════════
|
||||||
|
Bloc 1 — Greeting personnalisé
|
||||||
|
Bloc 2 — Manifeste brand TARGO (2 lignes qui voyagent ensemble)
|
||||||
|
Bloc 3 — Annonce du cadeau
|
||||||
|
Bloc 4 — Upsell forfaits + invitation contact
|
||||||
|
Chaque bloc est un mj-text indépendant = drag-droppable séparément. -->
|
||||||
|
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
|
||||||
|
|
||||||
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;border-top:1px solid #eef0ee;direction:ltr;font-size:0px;padding:26px 36px 18px;text-align:center;"
|
||||||
|
>
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:526px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<table
|
||||||
|
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:left;color:#374151;"
|
||||||
|
>Bonjour {{firstname}},</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:14px;word-break:break-word;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;"
|
||||||
|
>Avec l'arrivée de l'été, voici un <strong>cadeau pour toi, disponible pour un temps limité</strong>.</div>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;font-weight:500;line-height:1.5;text-align:justify;color:#1B2E24;"
|
||||||
|
>On veut te remercier pour ta loyauté envers l'achat local.<br />
|
||||||
|
Comme toi, on aime les connexions stables et les relations durables.</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0;word-break:break-word;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;text-align:justify;color:#374151;">Grâce à la confiance de nos clients, on offre maintenant les forfaits à <strong>la plus haute vitesse dans le secteur</strong>, jusqu'à <strong>3.5 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"
|
||||||
|
>
|
||||||
|
🎁 {{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>
|
||||||
|
· 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>
|
||||||
98
services/targo-hub/templates/gift-email-fr.json
Normal file
98
services/targo-hub/templates/gift-email-fr.json
Normal file
File diff suppressed because one or more lines are too long
362
services/targo-hub/templates/gift-email-fr.legacy-rich.html.bak
Normal file
362
services/targo-hub/templates/gift-email-fr.legacy-rich.html.bak
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Une offre exclusive de TARGO</title>
|
||||||
|
<!-- Brand fonts: Space Grotesk for display, Plus Jakarta Sans for body.
|
||||||
|
Wrapped in MSO conditional comment so Outlook desktop skips the
|
||||||
|
Google Fonts request (it can't render them anyway) and falls back
|
||||||
|
to Helvetica via the font-family stack on each element. -->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<!--<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#F5FAF7; font-family:'Plus Jakarta Sans','Helvetica Neue',Helvetica,Arial,sans-serif; color:#1B2E24; line-height:1.5;">
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:32px 16px;">
|
||||||
|
|
||||||
|
<!-- Main card -->
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
|
||||||
|
style="max-width:600px; background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Logo header (clean, no colored band) -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 36px 22px; border-bottom:1px solid #eef0ee;">
|
||||||
|
<img src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content"
|
||||||
|
alt="TARGO" width="140"
|
||||||
|
style="display:block; border:0; outline:none; text-decoration:none; max-width:140px; height:auto;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Greeting + hook -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:26px 36px 4px;">
|
||||||
|
<p style="margin:0 0 14px; font-size:1rem; color:#374151;">Bonjour {{firstname}},</p>
|
||||||
|
<p style="margin:0 0 10px; font-size:1.08rem; color:#1B2E24; font-weight:500;">
|
||||||
|
Comme toi, on aime les connexions stables et les relations durables.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0; font-size:1rem; color:#374151;">
|
||||||
|
Avec l'arrivée de l'été, voici ton
|
||||||
|
<strong>offre exclusive pour un temps limité</strong> :
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Info pill: gift card amount -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 36px 8px;">
|
||||||
|
<div style="background:#F5FAF7; border-radius:10px; padding:14px 18px;">
|
||||||
|
<div style="font-size:0.7rem; font-weight:700; letter-spacing:0.12em; color:#9ca3af; text-transform:uppercase; margin-bottom:4px;">
|
||||||
|
Carte-cadeau numérique
|
||||||
|
</div>
|
||||||
|
<div style="font-size:1.05rem; font-weight:700; color:#1B2E24;">
|
||||||
|
🎁 {{amount}} chez des centaines de marques
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Two-column: ENVOI + CONDITION -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 36px 18px;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td width="50%" style="padding-right:5px; vertical-align:top;">
|
||||||
|
<div style="background:#F5FAF7; border-radius:10px; padding:14px 16px;">
|
||||||
|
<div style="font-size:0.7rem; font-weight:700; letter-spacing:0.12em; color:#9ca3af; text-transform:uppercase; margin-bottom:4px;">
|
||||||
|
Envoi
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.95rem; font-weight:700; color:#1B2E24;">
|
||||||
|
⚡ Instantané à l'activation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td width="50%" style="padding-left:5px; vertical-align:top;">
|
||||||
|
<div style="background:#F5FAF7; border-radius:10px; padding:14px 16px;">
|
||||||
|
<div style="font-size:0.7rem; font-weight:700; letter-spacing:0.12em; color:#9ca3af; text-transform:uppercase; margin-bottom:4px;">
|
||||||
|
Condition
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.95rem; font-weight:700; color:#1B2E24;">
|
||||||
|
🤝 Rester encore {{commitment_months}} mois ou +
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<tr><td style="padding:0 36px;"><div style="border-top:1px solid #eef0ee;"></div></td></tr>
|
||||||
|
|
||||||
|
<!-- Option 1 chip -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:22px 36px 10px;">
|
||||||
|
<span style="display:inline-block; background:#E6F9EE; color:#00C853; font-size:0.82rem; font-weight:700; padding:5px 12px; border-radius:6px;">
|
||||||
|
✅ Option 1
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Big green CTA card -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 36px 8px;">
|
||||||
|
<a href="{{gift_url}}" style="text-decoration:none; color:#ffffff; display:block;">
|
||||||
|
<!-- CTA card — gradient Targo officiel (135deg, #00C853 → #005026).
|
||||||
|
Outlook desktop will ignore the gradient and render the
|
||||||
|
solid #00C853 fallback (the gradient is the bgcolor's
|
||||||
|
fallback in nodemailer rendering). Acceptable degradation. -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"
|
||||||
|
bgcolor="#00C853"
|
||||||
|
style="background:#00C853; background-image:linear-gradient(135deg,#00C853 0%,#005026 100%); border-radius:12px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:30px 24px; text-align:center;">
|
||||||
|
<div style="font-family:'Space Grotesk','Helvetica Neue',Helvetica,Arial,sans-serif; font-size:2.2rem; font-weight:700; line-height:1; margin-bottom:14px; color:#ffffff;">
|
||||||
|
🎁 {{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 448-0773</a>
|
||||||
|
/ <a href="tel:18558882746" style="color:#00C853; text-decoration:none;">1 855 888-2746</a>.
|
||||||
|
Support 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>
|
||||||
|
· 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>
|
||||||
Loading…
Reference in New Issue
Block a user