Même classe de bug que 187115c, sur deux flux SSE qui manquaient la reconnexion :
- TechTasksPage (app terrain) : AUCUN onerror → au restart du hub (502→CLOSED) le technicien perdait TOUT le
temps réel (nouvelles tâches, changements de statut) jusqu'au rechargement manuel. Ajout reconnexion 3 s + resync
loadTasks() à la RE-connexion (le hub ne rejoue pas les événements manqués).
- OutageAlertsPanel : au restart, tombait en repli poll 30 s et n'en sortait JAMAIS (ni retour temps réel, ni
resync immédiat). Ajout reconnexion 3 s + à la RE-connexion : arrêt du poll + fetchActive() pour rattraper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- field-app.html (app tech) : carte Mapbox GL → MapLibre GL + tuiles raster OSM (aucun jeton). C'était le DERNIER Mapbox vivant.
- NOUVEAU « 🚀 Meilleur trajet du jour (trafic en direct) » : ordre optimal des arrêts via NOTRE OSRM /table + plus-proche-voisin (le plugin /trip n'est pas compilé), puis handoff à Google Maps multi-arrêts → trafic EN DIRECT gratuit (OSM seul n'a pas de trafic). Endpoint hub GET /field/route?t=&from=lat,lon.
- config/erpnext.js MAPBOX_TOKEN vidé + JobEditModal (module mort) : vignette statique api.mapbox.com → lien OSM. Bundle SPA désormais SANS jeton Mapbox ni api.mapbox.com (vérifié).
- serveFieldApp : retrait de l'injection du jeton Mapbox.
Vérifié live : field-app rendu = MapLibre (0 api.mapbox.com) ; OSRM /table Ok ; ordre A→C→B correct depuis origine ouest ; /field/route 200. Reste (co-édité, déployé non-commité) : tooltips PlanificationPage « Mapbox »→« OSRM/OSM » + retrait carte réglages Mapbox.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The recipient row's customer link was href="/#/clients/<id>" — with the app on
hash routing served under /ops/, a leading slash before # resolves against the
site root, dropping /ops/ and landing on ERPNext desk. Drop the leading slash
so it stays scoped under the current /ops/ path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Tableau de bord : 2 bandes d'occupation (« Cette semaine » / « Semaine prochaine », 14 j depuis lundi), jour courant marqué (liseré indigo dans OccupancyStrip). Clic jour → /planification?day=ISO
- Planif : deep-link `?day=` (cadre la semaine + sélectionne le jour grille/mobile/tournées) & `?skill=` (filtre compétence, match casse-insensible vs allSkills) via focusDay() lu au montage
- Slot-finder (SuggestSlotsDialog) : état « Aucun créneau » → bouton « Planifier des quarts pour « X » » → émet plan-shifts → Planif ouvre la timeline (vue grille) au jour cherché, filtrée sur la compétence, + toast « crée un quart pour ouvrir des créneaux »
Vérifié préview bout-en-bout : 2×7 jours + today, clic → ?day=2026-07-09 cadre 06→12/07, recherche vide → bouton → dialog fermé + grille + toast compétence. Build vert + leak-check 0. Aucun changement hub.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VITE_DEV_USER (.env.local = louis@targo.ca) was inlined by Vite into
auth.js's localhost fallback, shipping personal PII in every prod build.
Gate that branch behind import.meta.env.DEV so esbuild dead-code-eliminates
it (and the inlined string) from production builds — permanent, no reliance
on a build flag. Also neutralize the two hardcoded louis@targo.ca in the
campaign TemplateEditor (merge-tag sample → exemple@gigafibre.ca; test-send
default → empty, send button already guards on non-empty). Leak-check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
suggestSlots was skill-blind and assumed a default 8-17 shift for every
tech. Now:
- SKILL FILTER: only techs whose skills (or _user_tags) include the
requested skill are considered. Dialog passes the picked chip's skill.
- REAL SHIFTS: slot windows come from published Shift Assignment ×
Shift Template (garde/on_call excluded). A tech with no regular shift
that day yields no slot — so it reflects reality (needs shifts created).
- WEEKENDS reserved for repairs: Sat/Sun skipped unless the skill is a
repair/dépannage. Removed SLOT_DEFAULT_SHIFT.
- Empty-state message hints to publish a shift / weekend rule.
Verified: with only garde shifts in the horizon, correctly returns 0
(regular_count=0). Skill/weekend/garde-exclusion logic confirmed via
data diagnostic. Produces slots once a regular day-shift is published for
a qualified tech.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applying the "one-click defaults + reveal-on-input + less clutter" pattern
the user asked for, on the SuggestSlotsDialog:
- Skill chips (4 main, from assignTypes + getTagColor, passed by parent):
one click picks the skill AND sets the default duration (SLOT_SKILL_DUR:
install 2h, réparation/tv 1h, téléphonie 0.5h…), adjustable.
- Progressive disclosure: duration/date/search appear only after an
address is chosen (hint otherwise). Less useless info up front.
- Selected skill flows into the created job as a tag.
Fix (autosuggest broken everywhere): the dialog used a q-menu (needs a
click trigger, so v-if never opened it) → switched to the q-list-below
pattern. More importantly, useAddressSearch pointed at ERPNext
/api/method/search_address (403/perm) → repointed at the reliable hub
/rpc/search_addresses (Postgres rqa_addresses, works in dev+prod via the
/hub proxy). Fixes autosuggest in UnifiedCreateModal + DispatchPage too.
Verified end-to-end: type "54 chateauguay huntingdon" → suggestions
appear → pick → Installation chip sets 2h → search returns 8 slots
(08:15–10:15). Chips + reveal + green ✓ on validated address.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hub /dispatch/suggest-slots (gap-finding + travel buffer + proximity
ranking) existed but had NO UI (Gemini claimed SuggestSlotsDialog.vue was
coded — it did not exist). Built it:
- api/dispatch.js: suggestSlots(payload) → POST /dispatch/suggest-slots.
- SuggestSlotsDialog.vue: address (RQA geocode via useAddressSearch) +
duration + earliest date → ranked slots (tech, date, time, travel min,
reasons). Selecting a slot emits it.
- DispatchPage: "📅 Créneau" button in the header opens the dialog;
onSlotSelected prefills the WO create modal (tech + date + address +
duration) and confirmWo injects the slot's start_time + geocoded coords
into createJob. Manual "+ WO" clears the booking slot.
Verified: dialog compiles/opens with inputs; suggest-slots returns ranked
slots (e.g. Gilles Drolet 08:15–09:45, journée libre, 15 min). Note: the
address geocode (/api/method/search_address) 403s in the dev preview
because the dev proxy doesn't inject the ERP token that prod nginx adds —
works in prod (same call the create modal already uses).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).
Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Comms: sélecteur d'expéditeur « De: » (défaut groupe Support TARGO) via resolveSendFrom + alias vérifiés
- Notifs: prefs de feeds PAR utilisateur (/conversations/notif-prefs) + cloche à bascules ; boot tooltip-ux (clic prioritaire + anti-empilement)
- Courriel: invitation à évaluer = modèle Unlayer éditable (transactional-rating-invite-*) ; test-send via Gmail + expansion {{rating}} ; logo TARGO auto-hébergé sur le magasin d'actifs du hub
- Planif: bloc « sans déplacement » (damier, début de quart, alerte si pas de quart), quart éditable dans l'éditeur de jour, icônes de compétence en vue jour (TV pour télé), clic cellule → éditeur, clic gauche lane → liste / clic droit → menu quart, lien ↗ ticket par job
- Tickets: défaut « Non fermés » + correction du filtre « Mes tickets » (owner)
- Inbox: poll Gmail 1 min + rafraîchir à la demande (poll-now)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Couleurs liées aux skills (éditable/cohérent) : hub deptToSkill() déduit une compétence du type legacy
→ /roster/unassigned-jobs renvoie required_skill ; PlanificationPage colore la carte par getTagColor(required_skill)
(même couleur que le chip skill) ; bordure 5px
- Fil complet du ticket : hub /dispatch/legacy-sync/ticket-thread (ticket_msg + auteur staff, HTML nettoyé) ;
api legacyTicketThread ; RightPanel bouton « 💬 Voir le fil / commentaires » (chargé au clic, messages+auteurs+dates)
- Order-by du pool dispatch : useBottomPanel.bottomSort (date|city|priority) + dropdown ⇅ dans BottomPanel
(ville = 2e segment adresse / token sujet avant |)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- pont : extrait le lien connect_ministra.php du ticket_msg (déjà posté par le wizard legacy à la vente)
→ champ legacy_activation_url sur le Dispatch Job (backfill des 10 tickets TV). Read-only legacy, aucun 2e
chemin d'écriture → zéro risque de double-facturation (cf. analyse processus).
- store _mapJob : expose legacyActivationUrl ; RightPanel : bouton violet « 📺 Activer STB (Ministra) »
(MÊME lien que le tech reçoit, ouvre connect_ministra.php avec tid/did/sid/cr/m intacts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- useHelpers.legacyReplyUrl(job, staffId?) → https://store.targo.ca/targo/reply_ticket.php?ticket=<legacy_ticket_id>&staff=<3301 par défaut = Tech Targo>
- RightPanel : n° ticket legacy cliquable + bouton d'action « 📝 Répondre dans legacy »
- permet au tech d'écrire dans le ticket legacy depuis ERPNext (lecture seule de notre côté)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BottomPanel : pastille couleur (jobColor → type legacy) par ligne + badge ⏰ EN RETARD
sur les groupes de date passée (le pool est déjà groupé/trié par date)
- RightPanel : badge « en retard » près de la date planifiée (hors Completed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- pont : stocke legacy_dept (dept osTicket) sur le Dispatch Job + backfill des 70 existants
- useHelpers.jobColor/legacyDeptColor : palette comme legacy (Installation vert, Réparation or,
Télé rose, Téléphonie vert pâle, Désinstallation rouge foncé) ; tech en pause = rouge vif prioritaire
- store _mapJob : expose legacyDept + legacyTicketId
- RightPanel : champ « Ticket legacy » (#id · dept) — le client est déjà un lien cliquable vers la fiche
- doc mise à jour
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A reminder campaign is a deep-copy of its parent's non-clicked
recipients with NEW gift_tokens. Clicks on the reminder were flagging
the CHILD recipient's gift_link_clicked but the parent campaign's
counters never updated — operators had to check two campaigns to see
the cumulative click rate.
Hub:
- New cascadeClickToParent() helper — when a recipient with
parent_campaign_id is flagged as gift_link_clicked, mirror the flag
+ timestamp onto parent.recipients[parent_row_index] and broadcast
a recipient-update SSE event so the parent's open page refreshes
live. Adds a gift_clicked_via_reminder breadcrumb (the child
campaign id) so the parent UI can show "↩ via la relance XXX".
Idempotent — already-clicked rows are no-op.
- Three cascade call sites: applyWebhookEvent fast path (CustomID),
applyWebhookEvent fallback (msgId scan), handleGiftRedirect wrapper.
- handleGiftRedirect also now sets gift_link_clicked=true on first
successful redirect (Mailjet webhook can lag or drop; the wrapper
redirect is the most reliable click signal we have).
- GET /campaigns/:id now attaches a "reminders" array with summary
counters for every reminder child of the campaign.
Ops UI:
- "Cette campagne est une relance" banner on child detail pages with
a back-link to the parent.
- "N relance(s) envoyée(s)" banner on parent detail pages with
clickable chips showing each child's gift_clicked/total ratio.
- Recipient table: 🔁 icon next to the gift-click indicator when the
click came via reminder, plus a "↩ via la relance XXX" line in the
tooltip so the operator can trace the engagement channel.
One-time backfill applied on prod to mirror clicks that happened
between reminder send and this deploy (1 click cascaded —
cmp-20260522-2d4605 gift_clicked 27 → 28).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The edit-params picker was showing "2026-06-22" for an expiry stored
as 2026-06-22T03:59:59Z because it sliced the UTC string. But that
UTC instant is actually 23:59 EDT on June 21 in Montreal, which is
what the email recipient sees (and what the operator picked).
Fixes both sides of the round-trip:
DISPLAY (UTC → picker)
- Convert stored ISO UTC to YYYY-MM-DD interpreted in America/Montreal
using en-CA locale (which returns ISO-style YYYY-MM-DD).
SAVE (picker → ISO UTC)
- New endOfDayMontreal() helper that probes Montreal's offset for the
target date (noon UTC always lands in morning Montreal, never spans
a day) and anchors at 23:59:59.999 local. Handles EDT/EST swaps
automatically — verified with edge cases 2026-03-08 (post-DST-spring),
2026-06-21 (mid-summer), 2026-11-01 (post-DST-fall), 2026-12-31 (winter).
Previously the save path relied on the BROWSER's local TZ inference
(new Date('YYYY-MM-DDT23:59:59').toISOString()) which is fine for
Quebec operators but quietly wrong for anyone editing from elsewhere.
The bulk email send was already correct because the worker's
toLocaleDateString uses timeZone: 'America/Montreal' (last commit).
This commit only fixes what the OPERATOR sees in the picker.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Operator can now choose an exact date for the wrapper expiry (e.g.
"valid until June 15") instead of computing days from today. Useful
when communicating a specific deadline to recipients.
Worker resolution order:
1. params.gift_expires_at (full ISO datetime, set by the date picker)
— all recipients of this campaign get THIS exact date, regardless
of when the worker fires the send.
2. Fallback: now() + gift_expiry_days (relative deadline, shifts
forward by queue lag).
UI in both wizard (new campaign) and edit-params dialog (draft):
- Date picker at the top with cursor-pointer event icon + clear (x)
- Preset toggle (15/30/60/90/180/Custom days) below — auto-disabled
when explicit date is set so the operator picks ONE mode
- Indicator "≈ N jours à partir d'aujourd'hui" when explicit date is
active so the operator sees both representations
UI carries the picker value as YYYY-MM-DD (gift_expires_at_display);
launchSend / saveEditParams translate to ISO YYYY-MM-DDT23:59:59Z
before PATCH/POST. Anchoring at end-of-day local means "until June 15"
stays valid through all of June 15, not just the start.
dateAfterToday validator blocks past dates in the picker.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The reminder copy read as pushy on test sends ("Hâte-toi! ... Tu n'as
encore rien fait, et le délai approche"). Toned down to factual and
friendly: state availability + offer the no-pressure path.
FR before / after:
⏰ Hâte-toi! Ton cadeau de 60 $ expire le ___. (red bold)
→ 🎁 Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026.
(brand dark green)
Tu n'as encore rien fait, et le délai approche. Si tu n'utilises
pas ton cadeau d'ici là, il ne pourra plus être réclamé.
→ On voulait juste s'assurer que tu ne l'as pas manqué — la carte-
cadeau qu'on t'a envoyée peut s'utiliser chez des centaines de
marques canadiennes, en quelques clics.
Si tu préfères ne pas l'utiliser, aucun souci — pas besoin de
répondre à ce courriel.
EN copy mirrored.
Also: {{expires_at_date}} was rendering empty in test sends and
previews because neither the test-send endpoint, the preview
endpoint, nor the editor's testSendForm.vars seeded it. Three fixes:
- Hub preview endpoint: compute now+30d as default sample date.
- Hub test-send endpoint: same default + expose view_url='' so the
Mustache section block collapses cleanly in internal tests.
- Editor test-send dialog: pre-fill expires_at_date (and expires_in_
days) with the same now+30d value, plus expose both fields as
editable inputs so the operator can override per-test.
Verified live on prod: the preview endpoint with no vars now renders
"Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026."
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new buttons on the campaign detail page header — both visible only
when campaign.status === 'draft' to keep operators from accidentally
mutating a campaign mid-send.
"Éditer les paramètres" → q-dialog with:
- name (internal)
- subject (the email Subject: line)
- from (sender)
- amount displayed in the body (overrides per-recipient default)
- commitment_months
- expiry text
- template_fr / template_en dropdowns (refresh on popup-show so newly
created templates show up without a page reload)
Saves via the existing PATCH /campaigns/:id, which merges into
params. A live load() refresh updates the Confirmation recap and any
visible counters.
"Éditer le template" → opens the Unlayer editor in a new tab on the
campaign's configured template_fr (most TARGO customers FR). For
campaign-specific tweaks the dialog tells the operator to create a
variant template (+ Nouveau) and select it here.
Addresses the gap a user hit on a reminder draft — they wanted to add
a condition to the body before launching but had no edit affordance
on the detail page.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a "Créer une relance" button on the campaign detail page that
clones the parent campaign into a new draft, targeting only the
recipients who haven't clicked the Giftbit gift link yet.
Backend (POST /campaigns/:id/reminder):
- Filters parent recipients: status sent/opened, not excluded, not
revoked, wrapper not yet expired, has a gift_url.
- Builds a fresh recipients array — same gift_url (Giftbit shortlink),
same name/email/language/amount, but cleared gift_token so the worker
generates a brand-new wrapper at send time. Each campaign owns its
own click metrics.
- New campaign starts as 'draft' so the operator can review, tweak
subject/template, and click "Lancer l'envoi" when ready.
- Tracks parent_campaign_id + parent_row_index on each reminder row
for traceability in CSV reports and debugging.
Templates (gift-email-reminder-fr / gift-email-reminder-en):
- Header swap: "Petit rappel pour {firstname}" / "Quick reminder, X"
- Bold orange urgency line: "⏰ Hâte-toi! Ton cadeau de X expire le Y"
using the existing {{expires_at_date}} and {{amount}} merge vars
- Body shortened — drops the manifesto, focuses on "you have a gift,
redeem before it's gone"
- Same CTA button + prorata disclaimer + signature + footer as the
main templates so brand stays consistent.
UI:
- Button visible when campaign is sending/completed AND it's not
itself a reminder AND there's ≥ 1 eligible non-clicker.
- Confirmation dialog spells out the mechanics: same Giftbit URLs,
new wrapper tokens, reminder template, sample expiry date pulled
from the campaign's first recipient with a gift_expires_at.
- On OK, redirects to the new campaign's detail page.
Click stats on the existing campaign (cmp-20260522-2d4605) verified
intact before+after deploy (109 opens, 15 generic clicks, 27 gift CTA
clicks) — saveCampaign persists per-event so the hub restart was a
no-op for accumulated data.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The send worker used to write "SMTP send returned false (see hub logs)"
on every failure, forcing the operator to SSH into the box to find the
actual cause. Now we capture the real reason and surface it in the UI.
Three changes:
1. lib/email.js exposes getLastError() — a side-channel for the most
recent nodemailer error message, cleared at the start of every
sendEmail call. Legacy "if (await sendEmail(...))" callers stay on
the false-return contract; only the campaign worker reads the
side-channel for detailed error capture.
2. The worker now retries each recipient up to 3 times (initial +
2 retries with 2s/5s backoff). Most "Unexpected socket close"-style
transient Mailjet errors recover on the second attempt. We observed
exactly this case for Myriam Bergevin in cmp-20260522-2d4605 — a
single socket close interrupted 1 of 202 sends; auto-retry would
have caught it. retry_count is now stored on the recipient.
3. POST /campaigns/:id/recipients/:row/retry resets a single failed
row back to pending and re-fires the worker. Surfaced in the
detail-page table as a small 🔁 button next to the error text on
any row with status=failed. Useful when auto-retry exhausted its
3 attempts on a one-off transient.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parseMapCsv now collects the actual rows it drops (capped at 200),
each with its skip reason and the raw source-CSV columns (full_name,
email, phone, address, postal). Returned alongside the existing
counters as skipped_rows on the parse response.
Wizard Step 2 adds an "N ligne(s) du Map CSV non importée(s)"
expansion below the imbalance banner, showing:
Ligne # | Raison | Nom au CSV | Email au CSV | Adresse | CP | →
The action column has a "Ajouter manuellement" button on rows that
have an email (duplicate, multi_skip) — clicking opens the manual-
add dialog pre-filled from the dropped row, so the operator can
recover the contact in two clicks. no_email rows can't be recovered
that way and don't get the button.
The source_row index is the Excel-relative line number (counting the
header) so the operator can cross-reference the actual file.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous breakdown only rendered when at least one of the drop
counters was > 0. When the Map CSV cleanly parses every row and the
imbalance comes purely from the Giftbit CSV having more entries than
the Map CSV, the operator was left with "13 surplus gifts" and no
explanation.
The summary now always shows "Map CSV: N raw rows → M contacts paired"
and, when no rows were dropped, explicitly states that the imbalance
must come from the Giftbit side (asks the operator to confirm the
generated gift count matches the Map file).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Map CSV rows that had a valid email but no name in the source column
were silently dropped at parsing — that's why a campaign would end up
with N unpaired Giftbit shortlinks for N "missing" contacts that
weren't actually missing, just nameless.
The send worker already handles a missing firstname by substituting
"cher client" / "dear customer", so dropping the row was wasteful.
Now we keep the contact and surface a name_warning on the row so the
operator can either edit the firstname in Step 2 or accept the
default.
Also added counters for previously-silent skip paths:
- duplicate: row's email was already seen above (1 gift / household
consolidation, depending on the multi setting)
- multi_skip: couple skipped because multi='skip' was selected
Wizard Step 2 imbalance banner now ventilates the skip breakdown so
the operator understands exactly where the N "missing" contacts went:
Ventilation des contacts droppés au parsing du Map CSV (sur 213
lignes brutes) : 8 sans email valide · 5 emails en double · 0
couples ignorés · 3 sans nom (gardés, utilisent "cher client" à
l'envoi)
Unrelated reassurance on the question that triggered this: language
fallback to French is already in place (matchCustomer returns
language:'fr' on miss, worker reads (r.language || 'fr')) so any
unmatched recipient gets the FR template, never an English one by
default.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Manual workaround for redemption status until /gifts/{uuid} polling
ships (task #25). The trailing path segment of the Giftbit shortlink
is the lookup key for Giftbit's admin search:
http://gft.link/4kpZMApLK4B
→ https://app.giftbit.com/app/rewards?search=4kpZMApLK4B
Surfaced in three places:
- Inventory page row: 🔗 button next to the copy-URL action
- Campaign detail page recipient table: same button next to the
Giftbit shortlink
- CSV report: new giftbit_admin_url column for bulk audits in Excel
(one click per row, no manual concat)
Defensive: only renders if the trailing segment is ≥4 chars (avoids
producing useless searches on malformed/test URLs).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new template variables are auto-derived from r.gift_expires_at at
render time (separately by the worker and the /view fallback to keep
them consistent):
{{expires_at_date}} locale-formatted FR/EN long date — "21 août 2026"
/ "August 21, 2026". Empty when no wrapper token.
{{expires_in_days}} remaining days as string (rounded up). Useful
for tight deadlines where a date is too distant
to convey urgency.
Templates: a small centered badge appears between the CTA button and
the prorata disclaimer, wrapped in a Mustache section so it disappears
cleanly on campaigns that pre-date the wrapper feature.
⏰ Cadeau valide jusqu'au <strong>21 août 2026</strong>
⏰ Gift valid until <strong>August 21, 2026</strong>
Editor merge-tag panel updated so authors can drop these into custom
copy without remembering the exact variable names. The legacy
{{expiry}} field stays — it's still the right tool for promotion-end
dates that don't track the gift link's own deadline.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wizard: gift_expiry_days now lives behind a preset toggle
(15/30/60/90/180 + Custom) instead of a naked number input. Operator
clicks a chip; the value flows back into the existing campaign param.
Inventory page (/campaigns/gifts):
- Cross-campaign view of every wrapper token with status taxonomy
(active / redeemed / expired / revoked / pending). Each card on
the counters strip is a click-to-filter shortcut.
- "Réassignables" highlighted in amber when > 0 — these are gifts
whose wrapper expired or was revoked but the Giftbit URL is still
unredeemed, ready for a fresh recipient.
- Search across name/email/url/token; per-status and per-campaign
filter dropdowns.
- One-click copy on the Giftbit URL with a tailored toast that walks
the operator through the reassignment workflow (paste into manual-
add dialog of a new campaign).
- Revoke action with confirmation; explicit about what survives
(the Giftbit URL stays valid on their side) vs what changes (our
wrapper stops redirecting).
Backend:
- GET /campaigns/gifts flattens every recipient with a gift across
every campaign — single-shot, no pagination yet (we're under 10k
gifts total).
- POST /campaigns/:id/recipients/:row/revoke sets gift_revoked=true
and broadcasts the recipient-update SSE event.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each campaign recipient now gets a short opaque token (10 base64url
chars, ~60 bits entropy). The email contains
https://msg.gigafibre.ca/g/<token>
which 302-redirects to the underlying Giftbit shortlink — but ONLY if
the recipient hasn't passed our own expires_at and we haven't revoked
the token. This gives us two new operational capabilities:
1. End-date control independent of Giftbit. The wizard now has a
"Expiration interne (jours)" field (default 90) that sets our
own deadline. Useful when the Giftbit gift is valid 12 months
but the campaign offer should expire in 30 days.
2. Reuse of unredeemed gifts. After our expiry, the old wrapper
stops working but the Giftbit URL is still valid on their side.
Pasting that same gift_url into a new campaign (via the manual-add
dialog) generates a NEW token pointing to the same Giftbit gift —
the original recipient's old wrapper URL says "expired", the new
recipient gets a fresh window.
Per-recipient new fields:
- gift_token short ID used in the wrapper URL
- gift_expires_at ISO timestamp of our cutoff
- gift_revoked manual kill-switch (false by default)
- gift_redirected_count clicks that successfully reached Giftbit
- gift_first_redirected_at first successful redirect timestamp
Routing:
- GET /g/:token — public, validates and 302s (or expired-page)
- Mailjet click event handler updated to recognise wrapper URLs
alongside legacy gft.link/giftbit.com URLs.
- /view (browser fallback for in-email rendering) also wraps the
gift link so expiry/revoke is honoured consistently.
Bootstrap rebuilds the in-memory token→recipient index by scanning
all campaign JSONs on startup — no separate index file to keep in
sync.
CSV report adds gift_token, gift_expires_at, gift_revoked,
gift_redirected_count, gift_first_redirected_at.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues with the per-language template dropdowns:
1. Strict filter — only -fr / -en templates appeared. Anyone naming a
template gift-email-test or gift-email-es (no recognized language
suffix) saw nothing show up in either dropdown.
2. Loaded once on mount — creating a template in another tab and
switching back to a wizard already open kept showing the stale list.
Fix:
- Templates without a -fr / -en suffix are added to BOTH dropdowns
with a "· sans suffixe de langue" tag so they're discoverable but
visually distinct from the recommended ones.
- Sort: matching-suffix templates first, then alphabetical.
- @popup-show triggers a refresh on every dropdown open.
- Visible "refresh" icon in the dropdown's append slot for manual
triggering without having to close/reopen the popup.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new dropdowns in Step 1 ("Template français" / "Template anglais")
populated from /campaigns/templates filtered by suffix (-fr / -en).
Selection is stored on campaign.params.template_fr / .template_en
and the worker resolves the actual path via a new resolveTemplatePath
helper:
1. params.template_<lang> (per-lang override, set here)
2. params.template_path (legacy single-template campaign override)
3. templateForLanguage() (default gift-email-<lang>.html)
Defensive name regex inside resolveTemplatePath blocks path traversal —
operator can pick any *-fr / *-en template that exists, nothing else.
The Step 3 summary list now shows which template will actually ship
per language so the operator can sanity-check before launch.
Use cases: seasonal variants (gift-email-2026-summer-fr), A/B tests,
draft templates that aren't ready to be the default yet.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DELETE /campaigns/:id removes the JSON from /opt/targo-hub/data/campaigns/.
The Giftbit shortlinks already issued for that campaign live on Giftbit's
side and are unaffected — this is purely about clearing internal tracking
records (typically test runs cluttering the list).
Refuses (409) while the send worker is active for that id so we never
yank the file out from under saveCampaign(). Defensive id regex
(in campaignPath) blocks path-traversal attempts before unlink runs.
UI: red trash icon on each row, disabled while status=sending.
Confirmation dialog spells out what survives the deletion (Giftbit
links) vs what's lost (tracking, opens/clicks, CSV report) so the
operator isn't surprised.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mailjet's click event includes the actual URL the recipient clicked. We
previously bumped every click — CTA button, mailto support, footer link —
to status='clicked' indiscriminately. Now we additionally flag clicks on
the Giftbit shortlink (matched by r.gift_url prefix, fallback to gft.link
or giftbit.com host) as the high-signal "gift_link_clicked" event.
Adds:
- recipient.gift_link_clicked (bool) + gift_clicked_at (ISO timestamp),
set on first matching click; later non-gift clicks don't unset
- counters.gift_clicked aggregated alongside existing status counters
- "Cadeau cliqué" counter card on detail page (deep-purple, redeem icon)
- 🎁 redeem icon next to status chip when the recipient engaged
- CSV report: new gift_link_clicked + gift_clicked_at columns
Why this matters: "opened" is noisy (Apple Mail Privacy Protection, image
proxies prefetch). A click on the CTA is the only reliable indicator
that the offer landed and the recipient is engaging.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous discoverability path was clic-text → floating toolbar → {}
icon, which assumes the user already knows how to invoke Unlayer's merge
tag UI. A direct "Variables" button now opens a dialog listing all 9
placeholders grouped by category (Client / Offre / Système) with their
sample value and a click-to-copy action. Reads from the same mergeTags
config Unlayer consumes — single source of truth, no drift risk.
Banner inside hints at the upcoming CSV-driven custom variable feature.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After Mailjet's Event API webhook moves rows from 'sent' to 'opened' or
'clicked', the counters.sent bucket empties and the list page showed
0/N even though every email had successfully landed. Use the same
sent+opened+clicked sum as the detail page so the list reflects
"emails that left our SMTP" rather than "emails still flagged sent".
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ops UI
- CampaignDetailPage: "CSV" button — downloads per-recipient report
(shortlinks, status, opened/clicked timestamps, mailjet UUID)
- CampaignNewPage: "Saisie manuelle (sans CSV)" on Step 1 and
"Ajouter manuellement" on Step 2 — both open the same dialog with
firstname / email / gift_url / city / postal_code / language /
amount override. Indigo "manuel" chip in the recipients table.
- New "Ville" column shows city OR postal_code as fallback.
Hub
- GET /campaigns/:id/report.csv — RFC 4180 CSV with UTF-8 BOM so
Excel auto-detects encoding. 20 columns including new "city".
- Worker honours per-recipient amount override:
r.amount > derive from r.gift_value_cents > params.amount > "50 $".
Fixes manual-add showing campaign default instead of typed value.
- Default subject "Un cadeau pour toi" (tutoyer).
Templates
- Order: Intro → ✅ Option 1 → 🎁 marques → CTA → prorata → ⏭️ Option 2.
- New EN intro (manifesto): "Thank you for choosing local. Your
support helps keep our community connected. / Because great
connections aren't just about fiber — they're about people too."
- Amazon logo removed (incongruent with "achat local" framing).
- Body paragraphs: text-align justify (greeting/labels stay left).
- Support line: "N'hésite pas à nous écrire / Feel free to email us"
+ dash format 514-448-0773, drop "Support 7j/7" overpromise.
- Logo style fix: inline width:32px to beat Unlayer canvas CSS that
was rendering brand pills full-width.
Ignore template converter .bak-*.json backups.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New "Traduire (AI)" button in the template editor toolbar. One click
translates the current template's HTML to the opposite language
(detected from the -fr/-en suffix), writing the translated content as
the matching companion template.
Backend (lib/campaigns.js):
- New endpoint: POST /campaigns/templates/:name/translate-to/:targetName
- Reads source .html, calls lib/ai.js aiCall() with Gemini Flash
- System prompt enforces 7 strict preservation rules:
1. Byte-preserve all HTML tags/attributes/styles/Outlook conditionals
2. Don't translate Mustache {{vars}}
3. Preserve URLs/emails/phones/hex colors/CSS/brand names (TARGO,
Gigafibre, Giftbit, Amazon, IGA, Tim Hortons, etc.)
4. Preserve emojis (🎁⚡🤝🪂✅⏭️⏰)
5. Keep the warm informal tone (tu in FR, you in EN)
6. Translate only visible text inside elements (paragraphs, buttons,
alt attributes, link text)
7. Output full HTML doc only, no markdown wrapping
- temperature=0.2 for stable output, maxTokens=32768 to fit ~35 KB HTML
- Sanity validates output isn't truncated (>50% of source size)
- Strips defensive markdown fences if AI ignored rule 7
- Auto-backs up existing target before overwrite
- Regenerates Unlayer design JSON from the translated HTML so the
editor can reload the translated template visually
- Requires { override: true } in body to overwrite existing target
(409 Conflict otherwise — protects against accidental clobber)
API client (apps/ops/src/api/campaigns.js):
- translateTemplate(srcName, targetName, { override })
Frontend (TemplateEditorPage.vue):
- "Traduire (AI)" button (purple, icon=translate) in toolbar — disabled
when current template has no -fr/-en suffix
- aiTranslateTargetName computed: detects source lang from suffix,
flips to opposite (-fr → -en, -en → -fr)
- Confirmation dialog:
• Shows source → target template names
• Info banner explaining what's preserved (HTML, vars, brands, emojis)
• Amber banner + toggle if target exists (must confirm override)
- On success: positive notification with byte counts +
"Open" action button to jump to the translated template
- Refreshes templates list after translation so the new file appears
in the selector dropdown
UX: replaces the previous manual translation workflow (where the user
or I had to maintain two parallel templates). One click now does the
whole round-trip. User reviews + adjusts wording in the EN editor if
the AI translation needs polish.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two improvements to the template editor:
1. "+ Nouveau" button + creation dialog
Users can now create new templates from the editor UI without us
re-deploying the hub. Click "Nouveau" next to the template selector,
pick a name + prefix + starter (blank or copy from existing), submit.
The hub PUTs the new template (existing endpoint, no new code needed
on the backend — just relaxed validation).
Form:
• Type (prefix): gift-email / newsletter / transactional
• Name suffix: lowercase letters/digits/dashes (e.g. summer-2026)
• Starter: "Vide" or "Copier depuis <existing template>"
On submit:
• If starter != blank: GET source template's html + design
• PUT new template name with that content
• Refresh templates list + switch editor to the new one
2. Backend: replace hardcoded EDITABLE_TEMPLATES allow-list with
regex-validated prefix matching + disk scan
• EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-',
'transactional-'] — bounds what categories users can create
• TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ — prevents path traversal
• isValidTemplateName() validates both regex + prefix membership
• scanEditableTemplates() returns all matching .html/.mjml files
currently on disk (excludes .bak-* and .legacy-* variants)
• listEditableTemplates() now scans disk instead of a static list,
so newly-created templates appear automatically in the dropdown
3. Enable Unlayer's built-in panels
• templates: true — exposes Unlayer's template library (limited
free-tier selection but ~10-20 starters available without a
projectId)
• stockImages: true — Unsplash search built into image picker
• imageEditor: true — basic crop/resize on inserted images
• undoRedo: true — history navigation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two bugs from the first prod test of the Unlayer editor:
1. `editor.value.loadBlank is not a function` — the loadBlank() method
exists in newer Unlayer versions but NOT in vue-email-editor 2.2
which wraps an older Unlayer. When no design is stored yet, just let
the editor render its default empty state ("No content here. Drag
content from left.") and show a Quasar notification telling the
user how to start. No explicit load call needed.
2. Editor renders cramped/small — the EmailEditor component's nested
iframe doesn't inherit dimensions from Quasar's q-page wrapper.
Wrap the EmailEditor in an explicit-sized container:
<div style="height: calc(100vh - 60px); width: 100%; overflow: hidden;">
Plus pass style="height: 100%; width: 100%" to the EmailEditor itself.
This gives the editor a full viewport-minus-toolbar canvas.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>