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:
parent
10d3745b31
commit
9fb6fab88e
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(','))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user