feat(campaigns): one-click Giftbit admin lookup per recipient

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/4kpZMApLK4Bhttps://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>
This commit is contained in:
louispaulb 2026-05-22 10:58:33 -04:00
parent feeae6eb40
commit 85ad66f103
3 changed files with 48 additions and 2 deletions

View File

@ -96,6 +96,11 @@
<a :href="props.row.gift_url" target="_blank" class="text-grey-7" style="font-family:monospace; font-size:0.78rem">
{{ shortLink(props.row.gift_url) }}
</a>
<q-btn v-if="giftbitAdminUrl(props.row.gift_url)"
flat dense round size="xs" icon="open_in_new" color="primary"
:href="giftbitAdminUrl(props.row.gift_url)" target="_blank" class="q-ml-xs">
<q-tooltip>Voir sur Giftbit admin (vérifier statut de redemption)</q-tooltip>
</q-btn>
</q-td>
</template>
<template v-slot:body-cell-error="props">
@ -155,6 +160,16 @@ function statusLabel (s) {
}
function shortLink (u) { return (u || '').replace(/^https?:\/\//, '').slice(0, 28) + ((u || '').length > 35 ? '…' : '') }
// Deep link into Giftbit's admin so the operator can verify redemption
// status manually until the /gifts/{uuid} API polling (task #25) lands.
// The trailing segment of the shortlink URL becomes the search query.
function giftbitAdminUrl (giftUrl) {
if (!giftUrl) return null
const code = giftUrl.replace(/\/+$/, '').split('/').pop()
if (!code || code.length < 4) return null
return `https://app.giftbit.com/app/rewards?search=${encodeURIComponent(code)}`
}
const reportCsvUrl = computed(() => campaignReportCsvUrl(id))
const sentRatio = computed(() => {

View File

@ -100,6 +100,11 @@
<q-btn flat dense round size="xs" icon="content_copy" @click="copy(props.row.gift_url)" class="q-ml-xs">
<q-tooltip>Copier l'URL Giftbit (à coller dans une nouvelle campagne pour réassigner)</q-tooltip>
</q-btn>
<q-btn v-if="giftbitAdminUrl(props.row.gift_url)"
flat dense round size="xs" icon="open_in_new" color="primary"
:href="giftbitAdminUrl(props.row.gift_url)" target="_blank" class="q-ml-xs">
<q-tooltip>Voir l'historique sur Giftbit (statut de redemption)</q-tooltip>
</q-btn>
</q-td>
</template>
<template v-slot:body-cell-expires="props">
@ -241,6 +246,21 @@ function shorten (u) {
if (!u) return ''
return u.replace(/^https?:\/\//, '').slice(0, 36) + (u.length > 40 ? '…' : '')
}
// Build a deep link into Giftbit's admin panel for a given shortlink so
// operators can confirm redemption status manually until task #25 wires
// the /gifts/{uuid} API polling. The trailing path segment of the
// shortlink URL is what Giftbit's reward search uses as the lookup key.
// "http://gft.link/4kpZMApLK4B"
// "https://app.giftbit.com/app/rewards?search=4kpZMApLK4B"
function giftbitAdminUrl (giftUrl) {
if (!giftUrl) return null
// Last non-empty path segment (strip trailing slashes if any)
const segments = giftUrl.replace(/\/+$/, '').split('/')
const code = segments[segments.length - 1]
if (!code || code.length < 4) return null
return `https://app.giftbit.com/app/rewards?search=${encodeURIComponent(code)}`
}
function formatDate (iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' })

View File

@ -1838,7 +1838,8 @@ async function handle (req, res, method, path) {
if (!c) return json(res, 404, { error: 'not found' })
const headers = [
'row', 'firstname', 'lastname', 'email', 'phone', 'language', 'customer_id',
'civic_address', 'city', 'postal_code', 'gift_value_cents', 'gift_url', 'giftbit_uuid',
'civic_address', 'city', 'postal_code', 'gift_value_cents',
'gift_url', 'giftbit_uuid', 'giftbit_admin_url',
'gift_token', 'gift_expires_at', 'gift_revoked', 'gift_redirected_count', 'gift_first_redirected_at',
'status', 'excluded', 'sent_at', 'opened_at', 'clicked_at',
'gift_link_clicked', 'gift_clicked_at',
@ -1851,9 +1852,19 @@ async function handle (req, res, method, path) {
}
const lines = [headers.join(',')]
for (const r of (c.recipients || [])) {
// Build Giftbit admin search URL for manual redemption check
// (until /gifts/{uuid} API polling lands — see task #25).
let giftbitAdminUrl = ''
if (r.gift_url) {
const code = String(r.gift_url).replace(/\/+$/, '').split('/').pop()
if (code && code.length >= 4) {
giftbitAdminUrl = `https://app.giftbit.com/app/rewards?search=${encodeURIComponent(code)}`
}
}
lines.push([
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.civic_address, r.city, r.postal_code, r.gift_value_cents,
r.gift_url, r.giftbit_uuid, giftbitAdminUrl,
r.gift_token, r.gift_expires_at, r.gift_revoked ? 'true' : 'false',
r.gift_redirected_count || 0, r.gift_first_redirected_at,
r.status, r.excluded ? 'true' : 'false', r.sent_at, r.opened_at, r.clicked_at,