@@ -142,7 +183,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { EmailEditor } from 'vue-email-editor'
import { listTemplates, getTemplate, saveTemplate as saveTemplateApi,
- previewTemplate, testSendTemplate } from 'src/api/campaigns'
+ previewTemplate, testSendTemplate, translateTemplate } from 'src/api/campaigns'
const route = useRoute()
const router = useRouter()
@@ -231,6 +272,50 @@ 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)
diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js
index 09ea6b2..0c5da32 100644
--- a/services/targo-hub/lib/campaigns.js
+++ b/services/targo-hub/lib/campaigns.js
@@ -45,6 +45,16 @@ const sse = require('./sse')
// save time so the send-worker only ever reads pre-compiled HTML.
const mjml2html = require('mjml')
+// AI service (Gemini Flash) — used by the template translation endpoint to
+// convert email content between FR ↔ EN while preserving HTML structure.
+// Lazy require because lib/ai.js requires AI_API_KEY which may not be set
+// in dev environments — we only fail when the endpoint is actually called.
+let aiLib = null
+function getAi () {
+ if (!aiLib) aiLib = require('./ai')
+ return aiLib
+}
+
const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
@@ -1147,6 +1157,159 @@ async function handle (req, res, method, path) {
}
}
+ // POST /campaigns/templates/:name/translate-to/:targetName — translate the
+ // source template's HTML content via Gemini Flash and save as targetName.
+ // Preserves HTML structure, Mustache {{vars}}, URLs, brand names, emojis.
+ // Body (optional): { override: true } to overwrite an existing target.
+ const tplTranslate = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/translate-to\/([a-zA-Z0-9_-]+)$/)
+ if (tplTranslate && method === 'POST') {
+ const srcName = tplTranslate[1]
+ const targetName = tplTranslate[2]
+ if (!isValidTemplateName(srcName) || !isValidTemplateName(targetName)) {
+ return json(res, 400, { error: 'invalid source or target template name' })
+ }
+ if (srcName === targetName) {
+ return json(res, 400, { error: 'source and target must differ' })
+ }
+ // Detect direction from name suffix (-fr → translate to en, -en → fr)
+ // Falls back to the explicit target language inferred from target name.
+ const srcLang = srcName.match(/-([a-z]{2})(-|$)/)?.[1] || 'fr'
+ const tgtLang = targetName.match(/-([a-z]{2})(-|$)/)?.[1] || (srcLang === 'fr' ? 'en' : 'fr')
+ let srcHtml
+ try { srcHtml = fs.readFileSync(templatePath(srcName), 'utf8') }
+ catch { return json(res, 404, { error: 'source template not found' }) }
+
+ const body = await parseBody(req).catch(() => ({}))
+ const targetHtmlPath = templatePath(targetName)
+ if (fs.existsSync(targetHtmlPath) && !body?.override) {
+ return json(res, 409, {
+ error: 'target template already exists',
+ hint: 'POST again with { "override": true } to overwrite',
+ })
+ }
+
+ const langNames = { fr: 'French Canadian (français du Québec)', en: 'English' }
+ const systemPrompt = [
+ `You are a professional bilingual translator for TARGO, a Quebec-based`,
+ `fiber Internet ISP. You translate marketing email content between`,
+ `French Canadian and English while strictly preserving HTML structure.`,
+ ``,
+ `STRICT RULES — DO NOT VIOLATE:`,
+ `1. PRESERVE every HTML tag, attribute, class, inline style, and Outlook`,
+ ` conditional comment () byte-for-byte.`,
+ `2. PRESERVE Mustache variables: {{firstname}}, {{amount}}, {{gift_url}},`,
+ ` {{commitment_months}}, {{description}}, {{expiry}}, {{year}}, etc.`,
+ ` DO NOT translate the content inside {{ }}.`,
+ `3. PRESERVE URLs (href, src), email addresses, phone numbers, hex colors,`,
+ ` CSS values, font names, brand names (TARGO, Gigafibre, Giftbit, Mailjet,`,
+ ` Amazon, IGA, Tim Hortons, etc.).`,
+ `4. PRESERVE all emojis as-is (🎁 ⚡ 🤝 🪂 ✅ ⏭️ ⏰).`,
+ `5. KEEP the warm conversational tone — use "tu" (informal) in French and`,
+ ` "you" (informal) in English. Marketing-friendly, not corporate.`,
+ `6. TRANSLATE only the visible text content inside elements: paragraphs,`,
+ ` headings, button labels, link text, alt attributes.`,
+ `7. Output the COMPLETE translated HTML document (starting from or to the closing ). NO explanation, NO markdown`,
+ ` code fence, NO commentary — just the HTML.`,
+ ].join('\n')
+
+ const userPrompt = `Translate the following ${langNames[srcLang]} email HTML to ${langNames[tgtLang]}. ` +
+ `Apply the rules from the system prompt strictly.\n\n${srcHtml}`
+
+ let translated
+ try {
+ // Non-JSON mode (we want raw HTML back), high token limit (~35 KB output)
+ translated = await getAi().aiCall(systemPrompt, userPrompt, {
+ jsonMode: false,
+ maxTokens: 32768,
+ temperature: 0.2, // low temp = stable, less creative translation
+ })
+ } catch (e) {
+ return json(res, 502, { error: 'AI translation failed', detail: e.message })
+ }
+
+ // Sanity checks — refuse to save garbled output
+ if (typeof translated !== 'string' || translated.length < srcHtml.length * 0.5) {
+ return json(res, 502, {
+ error: 'AI returned truncated or invalid output',
+ src_bytes: srcHtml.length,
+ out_bytes: typeof translated === 'string' ? translated.length : 0,
+ })
+ }
+ // Strip optional markdown code fences if Gemini still added them despite the prompt
+ translated = translated.replace(/^```(?:html)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim()
+
+ // Backup existing target before overwriting
+ try {
+ if (fs.existsSync(targetHtmlPath)) {
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
+ fs.copyFileSync(targetHtmlPath, targetHtmlPath.replace(/\.html$/, `.bak-${ts}.html`))
+ const jsonPath = templateJsonPath(targetName)
+ if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
+ }
+ } catch (e) { log('translate backup failed:', e.message) }
+
+ // Write translated HTML
+ fs.writeFileSync(targetHtmlPath, translated, 'utf8')
+
+ // Regenerate the Unlayer design JSON via the converter so the editor
+ // can reload the translated template visually. Inline equivalent of
+ // running scripts/convert-html-to-unlayer.js for this target.
+ const bodyMatch = translated.match(/]*>([\s\S]*?)<\/body>/i)
+ const innerHtml = bodyMatch ? bodyMatch[1].trim() : translated
+ const preheaderMatch = innerHtml.match(/]*display:\s*none[^>]*>\s*([^<]+?)\s*<\/div>/i)
+ const preheader = preheaderMatch ? preheaderMatch[1].trim() : ''
+ const design = {
+ 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: innerHtml, 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: '', padding: '0px',
+ _meta: { htmlID: 'u_row_1', htmlClassNames: 'u_row' },
+ selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true,
+ },
+ }],
+ values: {
+ contentWidth: '600px',
+ fontFamily: { label: 'Plus Jakarta Sans', value: "'Plus Jakarta Sans', sans-serif" },
+ textColor: '#1B2E24',
+ backgroundColor: '#F5FAF7',
+ preheaderText: preheader,
+ linkStyle: { body: true, linkColor: '#00C853', linkUnderline: true },
+ _meta: { htmlID: 'u_body', htmlClassNames: 'u_body' },
+ },
+ },
+ schemaVersion: 12,
+ }
+ fs.writeFileSync(templateJsonPath(targetName), JSON.stringify(design, null, 2), 'utf8')
+
+ log(`translate: ${srcName} (${srcHtml.length}b ${srcLang}) → ${targetName} (${translated.length}b ${tgtLang})`)
+ return json(res, 200, {
+ source: srcName,
+ target: targetName,
+ from_lang: srcLang,
+ to_lang: tgtLang,
+ src_bytes: srcHtml.length,
+ out_bytes: translated.length,
+ saved: true,
+ })
+ }
+
// POST /campaigns/templates/:name/test-send — send ONE rendered email to a
// specific address for visual QA. Uses the campaign Mailjet sender +
// throttle. NOT linked to any campaign — purely for template testing.