diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
index 8037055..226638a 100644
--- a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
+++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
@@ -119,8 +119,27 @@
Liens en surplus
+
+
+ {{ namesNeedingReview }}
+ Noms à vérifier
+
+
+
+
+
+
+ {{ namesAutoCorrected }} nom(s) auto-corrigés (Title Case, accents québécois,
+ prénoms composés séparés). L'icône ✨ verte dans le tableau indique les changements.
+
+
+ {{ namesNeedingReview }} nom(s) suspects — icône ⚠ amber : cliquer la cellule
+ pour éditer en place.
+
+
+
@@ -151,6 +170,44 @@
#{{ props.row.row_index }}
+
+
+
+
+ {{ props.row.name_warnings.firstname }}
+
+
+ Auto-corrigé depuis "{{ props.row.firstname_raw }}"
+
+ {{ props.row.firstname }}
+
+
+
+
+
+
+
+
+ {{ props.row.name_warnings.lastname }}
+
+
+ Auto-corrigé depuis "{{ props.row.lastname_raw }}"
+
+ {{ props.row.lastname }}
+
+
+
+
+
@@ -380,6 +437,16 @@ const excludedCount = computed(() => recipients.value.filter(r => r.excluded).le
// Net number of emails that will actually be fired off (paired AND not excluded)
const sendableCount = computed(() => recipients.value.filter(r => !r.excluded && r.gift_url).length)
+// Names that the auto-cleaner couldn't confidently fix. Heuristic warnings
+// from the backend (digit in name, two names possibly stuck together, etc.).
+// User should glance at these before sending.
+const namesNeedingReview = computed(() =>
+ recipients.value.filter(r => r.name_warnings?.firstname || r.name_warnings?.lastname).length
+)
+const namesAutoCorrected = computed(() =>
+ recipients.value.filter(r => r.cleaned_changed).length
+)
+
// FR / EN breakdown of the sendable recipients — useful preview before launch
// so the user knows which template will actually be used and how many.
const langBreakdown = computed(() => {
diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js
index 289b445..5dda758 100644
--- a/services/targo-hub/lib/campaigns.js
+++ b/services/targo-hub/lib/campaigns.js
@@ -87,6 +87,134 @@ const LOWER_WORDS = new Set(['de','du','des','la','le','les','au','aux','à',
'et','sur','en'])
const EMAIL_SPLIT = /\s*[;,]\s*/
+// ── French Québécois first-name accent restoration ──────────────────────────
+// Top ~100 names from MIQ Service Canada Québec baby names + manual additions
+// for older generations (legacy customer base skews 40+). Lookup is on the
+// LOWERCASE no-accent form → returns the properly accented version.
+// Missing from this list: rare names, English names that don't need fixing,
+// already-correctly-accented names (handled by Title Case alone).
+const FR_NAME_FIXES = {
+ // É (acute)
+ 'andre': 'André', 'andree': 'Andrée', 'aimee': 'Aimée',
+ 'amelie': 'Amélie', 'angele': 'Angèle', 'audrey': 'Audrey',
+ 'beatrice': 'Béatrice', 'benedict': 'Bénédict',
+ 'cecile': 'Cécile', 'cedric': 'Cédric', 'celine': 'Céline', 'chloe': 'Chloé',
+ 'clemence': 'Clémence', 'clement': 'Clément',
+ 'desire': 'Désiré', 'desiree': 'Désirée',
+ 'edith': 'Édith', 'edouard': 'Édouard', 'eliane': 'Éliane',
+ 'elie': 'Élie', 'elisa': 'Élisa', 'elise': 'Élise',
+ 'eloi': 'Éloi', 'emile': 'Émile', 'emilie': 'Émilie',
+ 'emmanuel': 'Emmanuel', 'eric': 'Éric', 'ethan': 'Éthan', 'eugene': 'Eugène',
+ 'felix': 'Félix', 'fleur': 'Fleur', 'francois': 'François', 'francoise': 'Françoise',
+ 'frederic': 'Frédéric', 'frederique': 'Frédérique',
+ 'gabrielle': 'Gabrielle', 'gaetan': 'Gaétan', 'gaetane': 'Gaétane',
+ 'genevieve': 'Geneviève', 'gerald': 'Gérald', 'gerard': 'Gérard',
+ 'helena': 'Helena', 'helene': 'Hélène', 'herve': 'Hervé',
+ 'irene': 'Irène', 'jeremie': 'Jérémie', 'jeremy': 'Jérémie',
+ 'jerome': 'Jérôme', 'jose': 'José', 'josee': 'Josée',
+ 'leandre': 'Léandre', 'leon': 'Léon', 'leo': 'Léo', 'leonard': 'Léonard',
+ 'magali': 'Magali', 'medard': 'Médard',
+ 'melanie': 'Mélanie', 'michele': 'Michèle',
+ 'noemie': 'Noémie', 'noel': 'Noël', 'noella': 'Noëlla',
+ 'pamela': 'Paméla', 'pierre': 'Pierre',
+ 'raphael': 'Raphaël', 'raphaele': 'Raphaëlle', 'regina': 'Régina',
+ 'regine': 'Régine', 'rejean': 'Réjean', 'rejeanne': 'Réjeanne',
+ 'remi': 'Rémi', 'rene': 'René', 'renee': 'Renée',
+ 'salome': 'Salomé', 'sebastien': 'Sébastien', 'severin': 'Séverin',
+ 'severine': 'Séverine', 'solange': 'Solange',
+ 'stephane': 'Stéphane', 'stephanie': 'Stéphanie',
+ 'theo': 'Théo', 'theodore': 'Théodore', 'therese': 'Thérèse',
+ 'valerie': 'Valérie', 'veronique': 'Véronique', 'zoe': 'Zoé',
+}
+
+// Parts known to form compound first names in QC (Marie-André, Jean-Philippe,
+// etc.). When two parts appear concatenated with no separator (e.g. "Marcandre"
+// or "Mariejose"), we split + hyphenate them. Ordered roughly by frequency so
+// the longest match wins on lookups.
+const COMPOUND_PARTS = [
+ 'jean', 'marie', 'anne', 'pierre', 'paul', 'louis', 'claude', 'marc',
+ 'andre', 'philippe', 'francois', 'francoise', 'jose', 'josee',
+ 'luc', 'olivier', 'antoine', 'sebastien', 'michel', 'francois',
+ 'christian', 'henri', 'denis', 'rene', 'roger',
+]
+
+function splitCompoundName (lower) {
+ for (const a of COMPOUND_PARTS) {
+ if (!lower.startsWith(a)) continue
+ const rest = lower.slice(a.length)
+ if (!rest) continue
+ if (COMPOUND_PARTS.includes(rest)) {
+ return [a, rest]
+ }
+ }
+ return null
+}
+
+// Title-case a single token. Handles apostrophes (O'Brien, L'Heureux) and
+// hyphens (Marie-André). Returns input unchanged if empty.
+function titleCaseToken (tok) {
+ if (!tok) return tok
+ return tok.replace(/(^|[\s\-'])([\p{L}])/gu, (_, sep, ch) => sep + ch.toUpperCase())
+ .replace(/(?<=[\p{L}])([\p{L}]+)/gu, m => m.toLowerCase())
+}
+
+// Clean a person name: trim, Title Case, accent-restore from dictionary,
+// split known compound concatenations. Returns the cleaned name.
+function cleanName (raw) {
+ if (!raw) return ''
+ let s = raw.trim()
+ if (!s) return ''
+ // First pass: Title Case (so 'MARC' → 'Marc', 'marc' → 'Marc')
+ s = s.split(/\s+/).map(titleCaseToken).join(' ')
+
+ // Apply dictionary fixes to each space-separated word
+ s = s.split(' ').map(word => {
+ // For hyphenated compounds, try each part separately
+ if (word.includes('-')) {
+ return word.split('-').map(p => {
+ const fix = FR_NAME_FIXES[p.toLowerCase()]
+ return fix || titleCaseToken(p)
+ }).join('-')
+ }
+ const lower = word.toLowerCase()
+ if (FR_NAME_FIXES[lower]) return FR_NAME_FIXES[lower]
+ // Try compound split: "Marcandre" → "Marc-Andre" → "Marc-André"
+ const parts = splitCompoundName(lower)
+ if (parts) {
+ return parts.map(p => FR_NAME_FIXES[p] || titleCaseToken(p)).join('-')
+ }
+ return word
+ }).join(' ')
+
+ return s
+}
+
+// Heuristics to flag names the cleaner may not have caught — surface to the
+// user in the preview UI so they can manually verify / edit before sending.
+// Returns null if the name looks fine, or a short FR string explaining why.
+function nameWarning (name) {
+ if (!name) return null
+ const trimmed = name.trim()
+ if (!trimmed) return null
+ if (/\d/.test(trimmed)) return 'contient un chiffre'
+ if (trimmed.length === 1) return 'une seule lettre'
+ if (trimmed.length > 25) return 'trop long, peut-être plusieurs noms collés'
+ // After cleanName, common-name lookups should have hit. If we still see a
+ // name in the unaccented form, it's likely an uncommon name OR a typo.
+ // We can't tell which, so just inform the user.
+ const SUSPECT_PATTERNS = [
+ // Common unaccented forms that the dict should have caught — if they
+ // survive, the name is unusual and worth a glance
+ /^(ana|ari|cleo|elo|jul|leo|maw|oce|pen|sof|the|val)/i,
+ ]
+ // Likely two names mashed (no separator, length 13-25, lowercase or with
+ // unexpected capitalisation pattern like 'Marcandre')
+ if (/^[\p{Lu}][\p{Ll}]+[\p{Ll}]{8,}$/u.test(trimmed) && !/[\s\-']/.test(trimmed)) {
+ return 'peut-être deux prénoms collés (vérifier)'
+ }
+ return null
+}
+
function titleAddress (addr) {
if (!addr) return ''
return addr.split(/\s+/).map((word, i) => {
@@ -165,8 +293,20 @@ function parseMapCsv (text, multi = 'first') {
const full = (r['nom au compte'] || r["nom à l'adresse"] || '').trim()
if (!full) { skippedNoName++; continue }
const parts = full.split(/\s+/, 2)
- const firstname = parts[0] || ''
- const lastname = parts.length > 1 ? full.slice(parts[0].length).trim() : ''
+ const firstnameRaw = parts[0] || ''
+ const lastnameRaw = parts.length > 1 ? full.slice(parts[0].length).trim() : ''
+
+ // Auto-clean: Title Case, accent restoration from QC dictionary, compound
+ // name detection. The user sees these cleaned versions in Step 2 and can
+ // edit inline. Original raw values are kept on the recipient (firstname_raw
+ // + lastname_raw) so we know whether the auto-cleaner touched anything.
+ const firstname = cleanName(firstnameRaw)
+ const lastname = cleanName(lastnameRaw)
+ const name_warnings = {
+ firstname: nameWarning(firstname),
+ lastname: nameWarning(lastname),
+ }
+ const cleaned_changed = (firstname !== firstnameRaw || lastname !== lastnameRaw)
const civic_address = titleAddress(r['adresse dans F'] || '')
const postal_code = normalizePostal(r['code postal au compte'] || r["code postal à l'adresse"] || '')
@@ -177,6 +317,9 @@ function parseMapCsv (text, multi = 'first') {
seen.add(em)
contacts.push({
firstname, lastname,
+ firstname_raw: firstnameRaw, lastname_raw: lastnameRaw,
+ cleaned_changed,
+ name_warnings,
email: em,
phone,
civic_address,