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>
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>
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>
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 /campaigns section in the ops SPA, gated by manage_users (proxy until a
dedicated manage_campaigns capability is added).
Pages (apps/ops/src/modules/campaigns/pages/):
- CampaignsListPage: table of all campaigns with status chip + progress
bar (sent/total, with fail count), "Nouvelle campagne" + "Éditer le
template" buttons. Empty state with onboarding copy.
- CampaignNewPage: 3-step Quasar Stepper wizard.
Step 1 — upload Map CSV + Giftbit CSV, configure params (name, amount,
commitment_months, sender, throttle, multi-email handling).
Step 2 — preview the matched send list from POST /campaigns/parse, with
counters (matched/unmatched/excluded), per-row match-method
chip, and exclude/include toggle. Banner warns when CSVs are
mis-aligned (leftover gifts or contacts).
Step 3 — confirmation recap with estimated send duration, then fire
POST /campaigns + POST /campaigns/:id/send and redirect to the
live detail page.
- CampaignDetailPage: per-recipient table with status chips updated live
via EventSource on the campaign:<id> SSE topic. Counters bar
(envoyés / cliqués / queued / échecs / non envoyés), progress bar,
per-row customer-link badge with deep-link into /clients/<id>.
Auto-subscribes to SSE when status is draft|sending; "Lancer l'envoi"
button for draft campaigns.
- TemplateEditorPage: GrapesJS-based visual editor for the campaign
templates. Three view modes (Visuel / HTML / Aperçu) — the HTML mode
is the fallback for our table-heavy hand-crafted template that
GrapesJS-preset-newsletter may parse imperfectly. Aperçu mode calls
POST /campaigns/templates/:name/preview on the hub for live variable
substitution. Custom GrapesJS blocks under "Variables" category for
drag-drop insertion of {{firstname}}, {{amount}}, {{gift_url}},
{{description}}, {{expiry}}, {{commitment_months}}. Saves via PUT
with hub-side backup of the previous version.
Wiring:
- api/campaigns.js: hubFetch wrapper, exports parseCsvs / createCampaign
/ listCampaigns / getCampaign / updateCampaign / sendCampaign +
campaignSseUrl(id) for EventSource subscription, + listTemplates /
getTemplate / saveTemplate / previewTemplate for the editor.
- router/index.js: three new routes under /campaigns. The
/campaigns/templates/:name? route is positioned ABOVE /campaigns/:id
to prevent the wildcard from catching template paths.
- config/nav.js + layouts/MainLayout.vue: "Campagnes" sidebar entry with
Lucide Gift icon.
- package.json: grapesjs + grapesjs-preset-newsletter dependencies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>