diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue index cfa1836..c49ee03 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue @@ -28,7 +28,21 @@ {{ counterFor('clicked') }} - Cliqués + + Cliqués + + Tous liens confondus (CTA, support, footer…) + + + + + 🎁 {{ campaign.counters?.gift_clicked || 0 }} + + Cadeau cliqué + + Le destinataire a cliqué le bouton CTA / lien Giftbit — signal d'engagement réel avec l'offre + + {{ counterFor('queued') }} @@ -60,6 +74,9 @@ + + Cadeau cliqué {{ props.row.gift_clicked_at ? `le ${new Date(props.row.gift_clicked_at).toLocaleString('fr-CA')}` : '' }} + diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 07b1d3a..0135857 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -625,12 +625,16 @@ function loadCampaign (id) { } function saveCampaign (campaign) { - // Recompute counters from recipients every save — single source of truth + // Recompute counters from recipients every save — single source of truth. + // gift_clicked is the high-value engagement signal (recipient clicked the + // CTA button leading to the Giftbit shortlink), tracked separately from + // the generic 'clicked' status which fires on any link in the email. const c = { ...campaign } c.counters = (c.recipients || []).reduce((acc, r) => { acc[r.status] = (acc[r.status] || 0) + 1 + if (r.gift_link_clicked) acc.gift_clicked++ return acc - }, { total: (c.recipients || []).length }) + }, { total: (c.recipients || []).length, gift_clicked: 0 }) fs.writeFileSync(campaignPath(c.id), JSON.stringify(c, null, 2)) return c } @@ -932,6 +936,18 @@ function applyWebhookEvent (ev) { const customId = String(ev.CustomID || ev.custom_id || '') const msgId = String(ev.MessageID || ev.message_id || '') + // Mailjet click events include the original URL the recipient clicked. We + // distinguish "clicked the gift CTA" (the actionable engagement signal) + // from "clicked the support email / footer link" (low-signal). Match the + // recipient's stored gift_url first (exact), with a host-level fallback + // for shortlink redirects (gft.link / giftbit.com). + function isGiftClick (r, ev) { + const clickedUrl = String(ev.url || ev.URL || '') + if (!clickedUrl) return false + if (r.gift_url && clickedUrl.startsWith(r.gift_url)) return true + return /(?:^|\/\/)(?:[\w-]+\.)?(?:gft\.link|giftbit\.com)/i.test(clickedUrl) + } + // Fast path: parse CustomID to skip the campaign scan entirely if (customId && customId.includes(':')) { const [campId, idxStr] = customId.split(':') @@ -941,7 +957,15 @@ function applyWebhookEvent (ev) { const r = c.recipients[idx] r.status = newStatus if (newStatus === 'opened') r.opened_at = new Date((ev.time || 0) * 1000).toISOString() - if (newStatus === 'clicked') r.clicked_at = new Date((ev.time || 0) * 1000).toISOString() + if (newStatus === 'clicked') { + r.clicked_at = new Date((ev.time || 0) * 1000).toISOString() + // Flag the high-value signal — recipient engaged with the offer. + // Once true, stays true (later non-gift clicks don't unset it). + if (isGiftClick(r, ev) && !r.gift_link_clicked) { + r.gift_link_clicked = true + r.gift_clicked_at = r.clicked_at + } + } if (newStatus === 'bounced' || newStatus === 'failed') { r.error = ev.error || ev.error_related_to || ev.event } @@ -961,7 +985,13 @@ function applyWebhookEvent (ev) { if (String(r.mailjet_uuid) !== msgId) continue r.status = newStatus if (newStatus === 'opened') r.opened_at = new Date((ev.time || 0) * 1000).toISOString() - if (newStatus === 'clicked') r.clicked_at = new Date((ev.time || 0) * 1000).toISOString() + if (newStatus === 'clicked') { + r.clicked_at = new Date((ev.time || 0) * 1000).toISOString() + if (isGiftClick(r, ev) && !r.gift_link_clicked) { + r.gift_link_clicked = true + r.gift_clicked_at = r.clicked_at + } + } if (newStatus === 'bounced' || newStatus === 'failed') { r.error = ev.error || ev.error_related_to || ev.event } @@ -1520,7 +1550,9 @@ async function handle (req, res, method, path) { const headers = [ 'row', 'firstname', 'lastname', 'email', 'phone', 'language', 'customer_id', 'civic_address', 'city', 'postal_code', 'gift_value_cents', 'gift_url', 'giftbit_uuid', - 'status', 'excluded', 'sent_at', 'opened_at', 'clicked_at', 'mailjet_uuid', 'error', + 'status', 'excluded', 'sent_at', 'opened_at', 'clicked_at', + 'gift_link_clicked', 'gift_clicked_at', + 'mailjet_uuid', 'error', ] const esc = (v) => { if (v == null) return '' @@ -1533,6 +1565,7 @@ async function handle (req, res, method, path) { r.row_index, r.firstname, r.lastname, r.email, r.phone, r.language, r.customer_id, r.civic_address, r.city, r.postal_code, r.gift_value_cents, r.gift_url, r.giftbit_uuid, r.status, r.excluded ? 'true' : 'false', r.sent_at, r.opened_at, r.clicked_at, + r.gift_link_clicked ? 'true' : 'false', r.gift_clicked_at, r.mailjet_uuid, r.error, ].map(esc).join(',')) }