diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue index 4436449..78efac6 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue @@ -35,6 +35,35 @@ + + + + Cette campagne est une relance de + + ← campagne parent + . Les clics ici sont automatiquement remontés au parent. + + + +
+ {{ campaign.reminders.length }} relance(s) envoyée(s) à partir de cette campagne — + les clics du rappel sont déjà comptabilisés dans + "Cadeau cliqué" ci-dessous (avec un 🔁 sur les rangées concernées). +
+
+ + {{ rem.name || rem.id }} + {{ rem.gift_clicked }}/{{ rem.total }} + +
+
+
{{ campaign.counters?.total || campaign.recipients?.length || 0 }}
@@ -93,7 +122,13 @@ - Cadeau cliqué {{ props.row.gift_clicked_at ? `le ${new Date(props.row.gift_clicked_at).toLocaleString('fr-CA')}` : '' }} + + Cadeau cliqué {{ props.row.gift_clicked_at ? `le ${new Date(props.row.gift_clicked_at).toLocaleString('fr-CA', { timeZone: 'America/Montreal' })}` : '' }} +
↩ via la relance {{ props.row.gift_clicked_via_reminder }}
+
+
+ + Ce clic a été remonté depuis la campagne de relance, pas le 1er courriel
diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 94a892c..3ecfbe6 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -1262,6 +1262,31 @@ function mailjetEventToStatus (event) { } } +// Reminder campaigns are deep-copies of their parent recipients with new +// gift_tokens. A click on a reminder updates the CHILD's gift_link_clicked +// but the parent campaign would still show "non-cliqué" for that recipient. +// This helper mirrors the click flag back to the parent so the parent's +// detail page and counters reflect cumulative engagement across the +// reminder chain. Idempotent — if the parent is already flagged, no-op. +function cascadeClickToParent (childCampaign, childRow) { + if (!childRow.parent_campaign_id || childRow.parent_row_index == null) return false + let parent + try { parent = loadCampaign(childRow.parent_campaign_id) } + catch { return false } + if (!parent?.recipients?.[childRow.parent_row_index]) return false + const parentR = parent.recipients[childRow.parent_row_index] + if (parentR.gift_link_clicked) return false // already counted + parentR.gift_link_clicked = true + parentR.gift_clicked_at = childRow.gift_clicked_at || new Date().toISOString() + // Breadcrumb so the parent UI can show "cliqué via la relance XXX" + parentR.gift_clicked_via_reminder = childCampaign.id + saveCampaign(parent) + sse.broadcast(`campaign:${parent.id}`, 'recipient-update', + { i: childRow.parent_row_index, recipient: parentR }) + log(`cascade: ${childCampaign.id} row ${childRow.parent_row_index} click → parent ${parent.id}`) + return true +} + function applyWebhookEvent (ev) { const newStatus = mailjetEventToStatus(ev.event) if (!newStatus) return false @@ -1310,6 +1335,8 @@ function applyWebhookEvent (ev) { } saveCampaign(c) sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i: idx, recipient: r }) + // Reminder click → mirror to parent so cumulative engagement is visible. + if (r.gift_link_clicked) cascadeClickToParent(c, r) return true } } @@ -1336,6 +1363,7 @@ function applyWebhookEvent (ev) { } saveCampaign(c) sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r }) + if (r.gift_link_clicked) cascadeClickToParent(c, r) return true } } @@ -2090,12 +2118,32 @@ async function handle (req, res, method, path) { return res.end(csv) } - // GET /campaigns/:id — full detail + // GET /campaigns/:id — full detail (+ reminder children if any) const detailMatch = path.match(/^\/campaigns\/([^/]+)$/) if (detailMatch && method === 'GET') { const c = loadCampaign(detailMatch[1]) if (!c) return json(res, 404, { error: 'not found' }) - return json(res, 200, c) + // Attach summaries of reminder children pointing at this campaign so + // the UI can render a "famille de campagnes" banner. Children fully + // load their recipients but we only return aggregate counters here + // to keep the response light. + const reminders = [] + for (const meta of listCampaigns()) { + if (meta.id === c.id) continue + const child = loadCampaign(meta.id) + if (child?.reminder_of === c.id || child?.parent_campaign_id === c.id) { + reminders.push({ + id: child.id, + name: child.name, + status: child.status, + created_at: child.created_at, + counters: child.counters, + total: (child.recipients || []).length, + gift_clicked: child.counters?.gift_clicked || 0, + }) + } + } + return json(res, 200, { ...c, reminders }) } // PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email) @@ -2280,9 +2328,20 @@ async function handleGiftRedirect (req, res, urlPath) { // recipient is already on their way to Giftbit's page). r.gift_redirected_count = (r.gift_redirected_count || 0) + 1 if (!r.gift_first_redirected_at) r.gift_first_redirected_at = new Date().toISOString() + // The wrapper redirect is the most reliable click signal we have (it + // ALWAYS fires on a successful gift click — Mailjet's webhook can lag + // or get dropped). Treat the first redirect as a confirmed gift click + // on the recipient row, even if Mailjet's webhook hasn't arrived yet. + if (!r.gift_link_clicked) { + r.gift_link_clicked = true + r.gift_clicked_at = r.gift_first_redirected_at + } try { saveCampaign(hit.campaign) } catch (e) { log(`gift redirect save failed: ${e.message}`) } // Broadcast so the live campaign detail page updates the click counter sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r }) + // Reminder click → cascade to parent so the originating campaign's + // counters reflect the cumulative engagement at a single glance. + cascadeClickToParent(hit.campaign, r) res.writeHead(302, { Location: r.gift_url,