'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 ` return hover + `

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 `` + `` + `| ` + ` |
${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 }