'use strict' /** * Évaluation client par étoiles (review-gating) — insérable dans un courriel via le marqueur {{rating}}. * * - Bloc 5 étoiles : chaque étoile = un lien (les courriels n'exécutent pas de JS). * Survol = remplit les étoiles à GAUCHE (truc CSS reverse-order + direction:rtl + `a:hover ~ a`). * Marche dans l'aperçu du composeur Ops + les clients qui gardent
TARGO
${bodyHtml}
TARGO — merci pour votre confiance
` } function feedbackPage (token, stars) { const filled = '★'.repeat(Math.max(0, stars)) + '☆'.repeat(Math.max(0, 5 - stars)) return shell('Merci pour votre évaluation', `
${filled}

Merci pour votre évaluation !

On vise toujours le 5 étoiles. En quelques mots, qu'est-ce qui n'a pas été à la hauteur — ou comment pourrait-on faire mieux ? Votre réponse va directement à notre équipe.

`) } // ── Invitation d'évaluation bilingue (FR/EN) : renvoi courriel/SMS + page d'étoiles autonome (lien SMS) ── // Invitation TUTOIEMENT (« tu ») — plus chaleureux/efficace que le vouvoiement. const INVITE = { fr: { subject: 'Ton avis compte pour nous', title: 'Ton avis compte pour nous', lead: 'Comment évaluerais-tu ton expérience avec TARGO ?', greet: (n) => n ? `Bonjour ${n},` : 'Bonjour,', intro: "Merci de faire confiance à TARGO. Si tu as quelques secondes pour nous évaluer, ce serait vraiment apprécié :", outro: "C'est grâce à ta rétroaction qu'on peut toujours mieux te servir. Si quelque chose ne va pas, réponds simplement à ce courriel — on est là pour t'aider.", foot: 'TARGO — merci pour ta confiance', sms: "Merci d'avoir choisi TARGO ! Évalue ton expérience en quelques secondes : " }, en: { subject: 'Your feedback matters to us', title: 'Your feedback matters', lead: 'How would you rate your experience with TARGO?', greet: (n) => n ? `Hi ${n},` : 'Hi,', intro: 'Thanks for choosing TARGO. If you have a few seconds to rate us, it would mean a lot:', outro: "Your feedback helps us serve you better. If something went wrong, just reply to this email — we're here to help.", foot: 'TARGO — thank you for your trust', sms: 'Thanks for choosing TARGO! Rate your experience in seconds: ' }, } const INVITE_LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png' function normLang (l) { return /^en/i.test(String(l || '')) ? 'en' : 'fr' } function firstName (n) { return String(n || '').trim().split(/\s+/)[0] || '' } function inviteSubject (lang) { return INVITE[normLang(lang)].subject } // Courriel d'invitation MIS EN PAGE (même charte que le transactionnel d'excuse : carte blanche 600px + logo + pied), // rehaussé d'un filet vert TARGO et des étoiles dans un encart vert. `stars` = ratingBlock(token) OU le marqueur {{rating}}. function inviteEmailDesign (lang, name, stars) { const t = INVITE[normLang(lang)] return `` + `` + `
` + `` + `` + `` + `` + `` + `` + `` + `
TARGO
 

${esc(t.title)}

` + `

${esc(t.greet(firstName(name)))}

` + `

${esc(t.intro)}

${stars}

${esc(t.outro)}

${esc(t.foot)}
` } // Modèle ÉDITABLE DANS UNLAYER : templates/transactional-rating-invite-.html (envoyé tel quel), // avec marqueurs {{firstname}} (prénom du destinataire) + {{rating}} (bloc étoiles tokenisé à l'envoi). // Repli sur le design codé en dur (inviteEmailDesign) si le fichier est absent/illisible. // PAS de cache : une modif sauvegardée dans Unlayer (qui réécrit le .html) prend effet sans redémarrer le hub. function loadInviteTpl (lang) { try { return fs.readFileSync(path.join(TPL_DIR, `transactional-rating-invite-${normLang(lang)}.html`), 'utf8') } catch { return null } } function mergeName (html, name) { const fn = esc(firstName(name)) return String(html).replace(/\s*\{\{\s*firstname\s*\}\}/gi, fn ? ' ' + fn : '') } function inviteEmailHtml (token, lang, name) { const tpl = loadInviteTpl(lang) if (!tpl) return inviteEmailDesign(lang, name, ratingBlock(token)) return mergeName(tpl, name).replace(/\{\{\s*rating\s*\}\}/gi, ratingBlock(token)) } function inviteEmailMarkerHtml (lang, name) { const tpl = loadInviteTpl(lang) if (!tpl) return inviteEmailDesign(lang, name, '{{rating}}') return mergeName(tpl, name) // {{rating}} laissé en place → expansé à l'envoi par expandRatingMarker } function inviteSmsText (token, lang) { return INVITE[normLang(lang)].sms + `${pub()}/rate/start?t=${encodeURIComponent(token)}` } function invitePage (token, lang) { const t = INVITE[normLang(lang)]; return shell(t.lead, `

${esc(t.lead)}

${esc(t.intro)}

${ratingBlock(token)}`) } // ── Relance « écrire au client » (suite à un avis, surtout mauvais) — brouillon bilingue, PAS de lien d'évaluation ── const FOLLOWUP = { fr: { subject: 'Suite à votre évaluation', html: (n) => `

Bonjour${n ? ' ' + n : ''},

Merci d'avoir pris le temps de nous évaluer. On aimerait mieux comprendre votre expérience et voir comment on peut s'améliorer.

N'hésitez pas à répondre à ce courriel — on est là pour vous aider.

`, sms: (n) => `Bonjour${n ? ' ' + n : ''}, merci pour votre évaluation. On aimerait mieux comprendre votre expérience — comment peut-on vous aider ?` }, en: { subject: 'Following up on your review', html: (n) => `

Hi${n ? ' ' + n : ''},

Thank you for taking the time to rate us. We'd like to better understand your experience and see how we can improve.

Feel free to reply to this email — we're here to help.

`, sms: (n) => `Hi${n ? ' ' + n : ''}, thanks for your review. We'd like to understand your experience better — how can we help?` }, } function feedbackSubject (lang) { return FOLLOWUP[normLang(lang)].subject } function feedbackHtml (name, lang) { return FOLLOWUP[normLang(lang)].html(name || '') } function feedbackSms (name, lang) { return FOLLOWUP[normLang(lang)].sms(name || '') } async function handle (req, res, method, path, url) { try { // GET /rate?t=&s= — enregistre la note + redirige (5 → Google, sinon → feedback interne) if (path === '/rate' && method === 'GET') { const token = url.searchParams.get('t') || '' const s = Math.max(0, Math.min(5, parseInt(url.searchParams.get('s'), 10) || 0)) // Anti-prefetch : les scanners de sécurité / pré-chargeurs de liens (Gmail, Proofpoint, Slackbot…) GETtent les liens // et FAUSSERAIENT la note. On n'enregistre PAS sur ces requêtes ; un vrai clic humain n'a pas ces signaux. (On redirige quand même.) const ua = String(req.headers['user-agent'] || '') const isPrefetch = /prefetch|preview/i.test(String(req.headers.purpose || req.headers['x-purpose'] || req.headers['x-moz'] || '')) || /bot|crawler|spider|scan|preview|proofpoint|mimecast|barracuda|googleimageproxy|google-read-aloud|slackbot|facebookexternalhit|whatsapp|bingbot|linkpreview|curl|wget|python-requests|headless/i.test(ua) const m = load(); const rec = m[token] if (rec && !isPrefetch) { // Filet : si le lien fiche manque (conv non liée à l'envoi), on résout par courriel au moment du clic. if (!rec.customer && rec.email) { try { const cr = await lookupCustomersByEmail(rec.email, 1); if (cr && cr[0]) { rec.customer = cr[0].name; if (!rec.name) rec.name = cr[0].customer_name || '' } } catch (e) { /* */ } } const changed = rec.stars !== s if (changed) { // re-notation permise → historique (évolution) + note courante mise à jour à chaque clic rec.history = (rec.history || []).slice(-19) rec.history.push({ stars: s, ts: new Date().toISOString() }) rec.stars = s; rec.stars_ts = new Date().toISOString() } save(m) if (changed) { log(`Rating ${s}★ — customer=${rec.customer || '?'} conv=${rec.conv || '?'}`) try { sse.broadcast('outbox', 'rating', { token, stars: s, customer: rec.customer, name: rec.name, low: s < 5 }) } catch (e) { /* */ } } } const dest = s >= 5 ? googleUrl() : `${pub()}/rate/feedback?t=${encodeURIComponent(token)}&s=${s}` res.writeHead(302, { Location: dest }); return res.end() } // GET /rate/feedback?t=&s= — page interne (merci + commentaire) if (path === '/rate/feedback' && method === 'GET') { const token = url.searchParams.get('t') || '' const s = Math.max(0, Math.min(5, parseInt(url.searchParams.get('s'), 10) || 0)) res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(feedbackPage(token, s)) } // GET /rate/start?t= — page d'étoiles AUTONOME (ouverte depuis le lien SMS d'invitation) ; mêmes étoiles cliquables que le courriel. if (path === '/rate/start' && method === 'GET') { const token = url.searchParams.get('t') || '' const rec = load()[token] res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(invitePage(token, (rec && rec.lang) || 'fr')) } // POST /rate/comment {token, comment} — capte le commentaire (avis privé) if (path === '/rate/comment' && method === 'POST') { const b = await parseBody(req); const token = String(b.token || ''); const comment = String(b.comment || '').trim().slice(0, 2000) const m = load(); const rec = m[token] if (rec && comment) { // ne JAMAIS écraser un commentaire par du vide (soumission accidentelle / double-envoi) rec.comment = comment; rec.comment_ts = new Date().toISOString(); save(m) try { sse.broadcast('outbox', 'rating-comment', { token, comment, stars: rec.stars, customer: rec.customer, name: rec.name }) } catch (e) { /* */ } } return json(res, 200, { ok: true }) } return json(res, 404, { error: 'not found' }) } catch (e) { log('rating handle: ' + e.message); return json(res, 500, { error: e.message }) } } // Liste des évaluations (récentes d'abord) — pour la vue Ops « Évaluations » (basses prioritaires). function listRatings ({ limit = 150, customer = '' } = {}) { const m = load() let out = Object.entries(m) .map(([token, r]) => ({ token, ...r })) .filter(r => r.stars != null || r.comment) // seulement celles où le client a agi if (customer) out = out.filter(r => r.customer === customer) // évaluations D'UN client précis (pour sa fiche) out.sort((a, b) => String(b.stars_ts || b.comment_ts || b.created || '').localeCompare(String(a.stars_ts || a.comment_ts || a.created || ''))) const low = out.filter(r => r.stars != null && r.stars < 5).length return { ratings: out.slice(0, limit), total: out.length, low } } // Supprimer une évaluation (ex. avis de test → pollution visuelle dans /Évaluations). function deleteRating (token) { const m = load(); if (m[token]) { delete m[token]; save(m); return true } return false } module.exports = { handle, ratingBlock, newRatingToken, expandRatingMarker, listRatings, deleteRating, inviteSubject, inviteEmailHtml, inviteEmailMarkerHtml, inviteSmsText, normLang, feedbackSubject, feedbackHtml, feedbackSms }