feat(campaigns): distinguish gift-CTA click from generic email click

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>
This commit is contained in:
louispaulb 2026-05-22 09:22:12 -04:00
parent 10d3745b31
commit 9fb6fab88e
2 changed files with 56 additions and 6 deletions

View File

@ -28,7 +28,21 @@
</q-card-section></q-card>
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5 text-blue">{{ counterFor('clicked') }}</div>
<div class="text-caption text-grey-7">Cliqués</div>
<div class="text-caption text-grey-7">
Cliqués
<q-icon name="info" size="12px" class="q-ml-xs">
<q-tooltip>Tous liens confondus (CTA, support, footer)</q-tooltip>
</q-icon>
</div>
</q-card-section></q-card>
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5 text-deep-purple-7">🎁 {{ campaign.counters?.gift_clicked || 0 }}</div>
<div class="text-caption text-grey-7">
Cadeau cliqué
<q-icon name="info" size="12px" class="q-ml-xs">
<q-tooltip>Le destinataire a cliqué le bouton CTA / lien Giftbit signal d'engagement réel avec l'offre</q-tooltip>
</q-icon>
</div>
</q-card-section></q-card>
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5 text-orange">{{ counterFor('queued') }}</div>
@ -60,6 +74,9 @@
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
<q-icon v-if="props.row.gift_link_clicked" name="redeem" color="deep-purple-7" size="18px" class="q-ml-xs">
<q-tooltip>Cadeau cliqué {{ props.row.gift_clicked_at ? `le ${new Date(props.row.gift_clicked_at).toLocaleString('fr-CA')}` : '' }}</q-tooltip>
</q-icon>
</q-td>
</template>
<template v-slot:body-cell-customer="props">

View File

@ -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(','))
}