diff --git a/apps/ops/quasar.config.js b/apps/ops/quasar.config.js index 8f88588..a6902da 100644 --- a/apps/ops/quasar.config.js +++ b/apps/ops/quasar.config.js @@ -3,7 +3,7 @@ const { configure } = require('quasar/wrappers') module.exports = configure(function () { return { - boot: ['pinia'], + boot: ['pinia', 'tooltip-ux'], css: ['app.scss', 'tech.scss'], diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index aefb323..967d78b 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -200,10 +200,10 @@ export function translateTemplate (srcName, targetName, { override = false } = { // 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 } = {}) { +export function testSendTemplate (name, { to, vars, from, subject, via } = {}) { return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, { method: 'POST', - body: { to, vars, from, subject }, + body: { to, vars, from, subject, via }, }) } diff --git a/apps/ops/src/boot/tooltip-ux.js b/apps/ops/src/boot/tooltip-ux.js new file mode 100644 index 0000000..c8ef3c3 --- /dev/null +++ b/apps/ops/src/boot/tooltip-ux.js @@ -0,0 +1,37 @@ +import { boot } from 'quasar/wrappers' + +// Comportement GLOBAL des bulles d'aide (q-tooltip), app-wide : +// 1) Le CLIC a PRÉSÉANCE — un pointerdown masque la bulle immédiatement et tant qu'on reste sur le même +// élément ; elle ne réapparaît qu'en survolant un AUTRE élément. +// 2) JAMAIS deux bulles à la fois — sur des éléments imbriqués (ex. cellule de planif + bloc job, ou +// timeline + job), on ne garde que la PLUS RÉCENTE (la plus interne). Anti-empilement. +// +// Quasar monte chaque tooltip comme élément `.q-tooltip` (téléporté dans
). On agit au niveau DOM +// (display) sans toucher chaque q-tooltip individuellement → fix unique pour toute l'app. Entièrement +// défensif : si l'hypothèse DOM change, le pire cas est un no-op (bulles inchangées), jamais une casse. +export default boot(() => { + if (typeof document === 'undefined' || !document.body) return + + let suppressed = false // vrai juste après un clic → on masque tout jusqu'au prochain survol d'un autre élément + let clicked = null + + const apply = () => { + const tips = Array.from(document.querySelectorAll('.q-tooltip')) + if (!tips.length) return + if (suppressed) { for (const t of tips) t.style.display = 'none'; return } + // ne garder QUE la plus récente (dernière dans le DOM = la plus interne / dernière survolée) + tips.forEach((t, i) => { t.style.display = i === tips.length - 1 ? '' : 'none' }) + } + + // 1) clic = masquer la bulle (préséance) + document.addEventListener('pointerdown', (e) => { suppressed = true; clicked = e.target; apply() }, true) + // … jusqu'à ce qu'on survole un élément DIFFÉRENT + document.addEventListener('pointerover', (e) => { + if (suppressed && clicked && e.target !== clicked && !(clicked.contains && clicked.contains(e.target))) { + suppressed = false; clicked = null; apply() + } + }, true) + + // 2) anti-empilement : à chaque ajout/retrait de bulle (téléport dans body), garder une seule bulle + try { new MutationObserver(apply).observe(document.body, { childList: true }) } catch (e) { /* no-op */ } +}) diff --git a/apps/ops/src/components/settings/CannedResponsesCard.vue b/apps/ops/src/components/settings/CannedResponsesCard.vue index 2d9c106..77020fe 100644 --- a/apps/ops/src/components/settings/CannedResponsesCard.vue +++ b/apps/ops/src/components/settings/CannedResponsesCard.vue @@ -1,12 +1,15 @@{{client}}, {{agent}} (remplacées à l'insertion). Associez une mailbox (ex. factures@ → « Comptes Payables »).
TARGO <support@targointernet.com>.
+ Envoyé via le compte Gmail surveillé — même canal que les vraies invitations (étoiles d'évaluation cliquables incluses).
+ Envoyé via Mailjet depuis TARGO <support@targointernet.com>.
| {{rating}} |
| {{rating}} |

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 } diff --git a/services/targo-hub/lib/ticket-collab.js b/services/targo-hub/lib/ticket-collab.js index dea3239..b373725 100644 --- a/services/targo-hub/lib/ticket-collab.js +++ b/services/targo-hub/lib/ticket-collab.js @@ -27,6 +27,8 @@ async function searchCustomers (q) { const found = new Map() // name -> { matched, address? } // 1) Téléphone (REST multi-champs) if (phone) { try { for (const c of await lookupCustomersByPhone(phone, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'téléphone' }) } catch (e) { /* */ } } + // 1b) Courriel — la requête ressemble à une adresse e-mail + if (/.+@.+/.test(s)) { try { for (const c of await lookupCustomersByEmail(s, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'courriel' }) } catch (e) { /* */ } } let p = null try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ } // 2) No civique → adresse (Service Location → client) @@ -61,6 +63,21 @@ async function searchCustomers (q) { for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null }) } } + // 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). FUZZY sur les champs legacy. + // try/catch : si les colonnes custom n'existent pas encore, on ignore silencieusement (pas de régression). + if (p && found.size < 8) { + const qn = normName(text) + if (qn.length >= 3) { + const NORMM = "regexp_replace(unaccent(lower(coalesce(mandataire,'') || ' ' || coalesce(contact_name_legacy,''))), '[^a-z0-9]', '', 'g')" + try { + const rows = (await p.query( + `SELECT name, customer_name, word_similarity($1, ${NORMM}) AS score FROM "tabCustomer" + WHERE (${NORMM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORMM}) > 0.45) + ORDER BY (${NORMM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 6`, [qn])).rows + for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'mandataire', score: row.score != null ? Math.round(row.score * 100) / 100 : null }) + } catch (e) { log('searchCustomers mandataire: ' + e.message) } + } + } if (!found.size) return [] // Enrichissement contacts (1 requête) : courriel + téléphones → résumé + « SMS dispo ? » (mobile présent). const names = [...found.keys()].slice(0, 8) @@ -520,10 +537,95 @@ async function handle (req, res, method, p, url) { if (p === '/collab/orchestrate/run' && method === 'POST') { const b = await parseBody(req); return json(res, 200, await orchestrateRun(b.actions, req.headers['x-authentik-email'] || b.agent || '')) } - // GET /collab/customer-search?q= — autosuggest client par texte libre (nom / téléphone / adresse) + // GET /collab/customer-search?q= — autosuggest client par texte libre (nom / courriel / téléphone / adresse) if (p === '/collab/customer-search' && method === 'GET') { return json(res, 200, { matches: await searchCustomers(url.searchParams.get('q') || '') }) } + // GET /collab/ratings — évaluations clients (basses prioritaires) pour la vue Ops « satisfaction » + if (p === '/collab/ratings' && method === 'GET') { + return json(res, 200, require('./rating').listRatings({ limit: Number(url.searchParams.get('limit')) || 150, customer: url.searchParams.get('customer') || '' })) + } + // DELETE /collab/ratings/| {{rating}} |
| ` + + ` |