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/4kpZMApLK4B
→ https://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>
236 lines
10 KiB
Vue
236 lines
10 KiB
Vue
<template>
|
|
<q-page padding>
|
|
<div class="row items-center q-mb-md">
|
|
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
|
|
<div class="text-h5">{{ campaign?.name || id }}</div>
|
|
<q-chip dense class="q-ml-md" :color="statusColor(campaign?.status)" text-color="white" :label="statusLabel(campaign?.status)" />
|
|
<q-space />
|
|
<q-btn
|
|
v-if="campaign?.recipients?.length"
|
|
flat dense icon="file_download" label="CSV" class="q-mr-sm"
|
|
:href="reportCsvUrl" download
|
|
>
|
|
<q-tooltip>Télécharger le rapport (shortlinks Giftbit, emails, statuts d'envoi)</q-tooltip>
|
|
</q-btn>
|
|
<q-btn v-if="campaign?.status === 'draft'" unelevated color="primary" icon="send" label="Lancer l'envoi"
|
|
:loading="resending" @click="relaunch" />
|
|
</div>
|
|
|
|
<!-- Counters bar -->
|
|
<div class="row q-col-gutter-sm q-mb-md" v-if="campaign">
|
|
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
|
<div class="text-h5">{{ campaign.counters?.total || campaign.recipients?.length || 0 }}</div>
|
|
<div class="text-caption text-grey-7">Total</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-positive">{{ counterFor('sent') + counterFor('opened') + counterFor('clicked') }}</div>
|
|
<div class="text-caption text-grey-7">Envoyés</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-blue">{{ counterFor('clicked') }}</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>
|
|
<div class="text-caption text-grey-7">En attente</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-negative">{{ counterFor('failed') + counterFor('bounced') }}</div>
|
|
<div class="text-caption text-grey-7">Échecs</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-grey-7">{{ counterFor('pending') }}</div>
|
|
<div class="text-caption text-grey-7">Non envoyés</div>
|
|
</q-card-section></q-card>
|
|
</div>
|
|
|
|
<q-linear-progress
|
|
v-if="campaign && (campaign.status === 'sending' || campaign.status === 'completed')"
|
|
:value="sentRatio" :color="campaign.counters?.failed ? 'orange' : 'positive'" size="8px" class="q-mb-md"
|
|
/>
|
|
|
|
<q-table
|
|
v-if="campaign"
|
|
:rows="campaign.recipients || []"
|
|
:columns="columns" row-key="email"
|
|
flat bordered dense
|
|
:pagination="{ rowsPerPage: 50 }"
|
|
:rows-per-page-options="[25, 50, 100, 0]"
|
|
>
|
|
<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">
|
|
<q-td :props="props">
|
|
<span v-if="props.row.customer_id">
|
|
<q-icon name="person" size="14px" class="q-mr-xs" />
|
|
<a :href="`/#/clients/${props.row.customer_id}`" target="_blank" style="color:var(--q-primary)">
|
|
{{ props.row.customer_name || props.row.customer_id }}
|
|
</a>
|
|
<q-chip dense size="xs" outline class="q-ml-xs">{{ props.row.match_method }}</q-chip>
|
|
</span>
|
|
<span v-else class="text-grey-6">—</span>
|
|
</q-td>
|
|
</template>
|
|
<template v-slot:body-cell-gift_url="props">
|
|
<q-td :props="props">
|
|
<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">
|
|
<q-td :props="props">
|
|
<span v-if="props.row.error" class="text-negative text-caption">{{ props.row.error }}</span>
|
|
</q-td>
|
|
</template>
|
|
</q-table>
|
|
|
|
<div v-if="!campaign && !loading" class="text-center q-pa-xl text-grey-7">
|
|
<q-icon name="error_outline" size="48px" />
|
|
<div class="text-h6 q-mt-md">Campagne introuvable</div>
|
|
</div>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { useQuasar } from 'quasar'
|
|
import { getCampaign, sendCampaign, campaignSseUrl, campaignReportCsvUrl } from 'src/api/campaigns'
|
|
|
|
const route = useRoute()
|
|
const $q = useQuasar()
|
|
const id = route.params.id
|
|
const campaign = ref(null)
|
|
const loading = ref(true)
|
|
const resending = ref(false)
|
|
|
|
let es = null
|
|
|
|
const columns = [
|
|
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
|
|
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
|
|
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
|
|
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
|
|
{ name: 'customer', label: 'Client lié', field: 'customer_name', align: 'left' },
|
|
{ name: 'gift_url', label: 'Shortlink', field: 'gift_url', align: 'left' },
|
|
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
|
|
{ name: 'error', label: 'Erreur', field: 'error', align: 'left' },
|
|
]
|
|
|
|
function counterFor (s) { return campaign.value?.counters?.[s] || 0 }
|
|
function statusColor (s) {
|
|
return {
|
|
pending: 'grey-5', queued: 'orange', sent: 'positive', opened: 'positive',
|
|
clicked: 'blue', failed: 'negative', bounced: 'negative',
|
|
draft: 'grey', sending: 'orange', completed: 'positive',
|
|
}[s] || 'grey-5'
|
|
}
|
|
function statusLabel (s) {
|
|
return {
|
|
pending: 'En attente', queued: 'En file', sent: 'Envoyé', opened: 'Ouvert',
|
|
clicked: 'Cliqué', failed: 'Échec', bounced: 'Rejeté',
|
|
draft: 'Brouillon', sending: 'En cours', completed: 'Terminée',
|
|
}[s] || 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(() => {
|
|
const total = campaign.value?.counters?.total || 1
|
|
const done = counterFor('sent') + counterFor('opened') + counterFor('clicked')
|
|
+ counterFor('failed') + counterFor('bounced')
|
|
return Math.min(1, done / total)
|
|
})
|
|
|
|
async function load () {
|
|
loading.value = true
|
|
try { campaign.value = await getCampaign(id) }
|
|
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
|
finally { loading.value = false }
|
|
}
|
|
|
|
function subscribeSse () {
|
|
if (es) es.close()
|
|
es = new EventSource(campaignSseUrl(id))
|
|
es.addEventListener('recipient-update', (ev) => {
|
|
const data = JSON.parse(ev.data)
|
|
if (!campaign.value?.recipients) return
|
|
// Apply patch by index; counters will be re-rendered from recipients next refresh
|
|
if (campaign.value.recipients[data.i]) {
|
|
Object.assign(campaign.value.recipients[data.i], data.recipient)
|
|
// Recompute counters in-place for live update
|
|
const counters = { total: campaign.value.recipients.length }
|
|
for (const r of campaign.value.recipients) counters[r.status] = (counters[r.status] || 0) + 1
|
|
campaign.value.counters = counters
|
|
}
|
|
})
|
|
es.addEventListener('campaign-done', () => {
|
|
$q.notify({ type: 'positive', message: 'Campagne terminée' })
|
|
load()
|
|
})
|
|
es.addEventListener('campaign-status', (ev) => {
|
|
const data = JSON.parse(ev.data)
|
|
if (campaign.value) campaign.value.status = data.status
|
|
})
|
|
}
|
|
|
|
async function relaunch () {
|
|
resending.value = true
|
|
try {
|
|
await sendCampaign(id)
|
|
await load()
|
|
subscribeSse()
|
|
} catch (e) {
|
|
$q.notify({ type: 'negative', message: e.message })
|
|
} finally {
|
|
resending.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await load()
|
|
// Auto-subscribe to SSE if still running (or about to run)
|
|
if (campaign.value && ['draft','sending'].includes(campaign.value.status)) {
|
|
subscribeSse()
|
|
}
|
|
})
|
|
onBeforeUnmount(() => { if (es) es.close() })
|
|
</script>
|