diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index bdaae66..a52402f 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -127,6 +127,16 @@ export function previewTemplate (name, { 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 } = {}) { diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue index dac085a..9165777 100644 --- a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue +++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue @@ -24,6 +24,10 @@ Voir le HTML rendu (substitué) tel que reçu par le destinataire + + {{ aiTranslateTargetName ? `Traduire vers ${aiTranslateTargetName} via Gemini` : 'Disponible pour les templates avec suffixe -fr ou -en' }} + Envoyer un courriel réel à une adresse de test @@ -97,6 +101,43 @@ + + + + + +
Traduire avec Gemini AI
+ + +
+ +
+ Le contenu de {{ currentName }} sera traduit vers + {{ aiTranslateTargetName }} via Gemini Flash. +
+ + + Le AI préserve la structure HTML, les variables {{ '{{...}}' }}, + les URLs, les noms de marque (TARGO, Giftbit) et les emojis. + Il traduit seulement le texte visible. + + + + Le template {{ aiTranslateTargetName }} existe déjà. + La traduction va l'écraser (backup automatique avant). + + +
+ + + + +
+
+ @@ -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.