feat(campaigns): gifts inventory page + expiry presets
Wizard: gift_expiry_days now lives behind a preset toggle (15/30/60/90/180 + Custom) instead of a naked number input. Operator clicks a chip; the value flows back into the existing campaign param. Inventory page (/campaigns/gifts): - Cross-campaign view of every wrapper token with status taxonomy (active / redeemed / expired / revoked / pending). Each card on the counters strip is a click-to-filter shortcut. - "Réassignables" highlighted in amber when > 0 — these are gifts whose wrapper expired or was revoked but the Giftbit URL is still unredeemed, ready for a fresh recipient. - Search across name/email/url/token; per-status and per-campaign filter dropdowns. - One-click copy on the Giftbit URL with a tailored toast that walks the operator through the reassignment workflow (paste into manual- add dialog of a new campaign). - Revoke action with confirmation; explicit about what survives (the Giftbit URL stays valid on their side) vs what changes (our wrapper stops redirecting). Backend: - GET /campaigns/gifts flattens every recipient with a gift across every campaign — single-shot, no pagination yet (we're under 10k gifts total). - POST /campaigns/:id/recipients/:row/revoke sets gift_revoked=true and broadcasts the recipient-update SSE event. 🤖 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
c0ca5feb6f
commit
d529019106
|
|
@ -77,6 +77,23 @@ export function deleteCampaign (id) {
|
|||
})
|
||||
}
|
||||
|
||||
// Inventory of every wrapper token across all campaigns, with status
|
||||
// (active / expired / revoked / redeemed / pending). Used by the
|
||||
// gifts inventory page to surface reassignable Giftbit shortlinks.
|
||||
export function listGifts () {
|
||||
return hubFetch('/campaigns/gifts').then(r => r.gifts || [])
|
||||
}
|
||||
|
||||
// Kill switch — manually expire a single recipient's wrapper token so
|
||||
// the underlying Giftbit URL becomes reassignable before the natural
|
||||
// expiry date.
|
||||
export function revokeGift (campaignId, rowIndex) {
|
||||
return hubFetch(
|
||||
`/campaigns/${encodeURIComponent(campaignId)}/recipients/${rowIndex}/revoke`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
// Build the URL the browser hits to download the per-recipient CSV report
|
||||
// (giftbit shortlink ↔ email ↔ name ↔ status). The hub serves it with the
|
||||
// proper Content-Disposition so an <a download> click triggers a save.
|
||||
|
|
|
|||
|
|
@ -66,11 +66,43 @@
|
|||
<q-input v-model="params.subject" label="Sujet du courriel" outlined dense class="col-12 col-md-6" />
|
||||
<q-input v-model="params.from" label="Expéditeur (From)" outlined dense class="col-12 col-md-6" placeholder="TARGO <support@targointernet.com>" />
|
||||
<q-input v-model="params.expiry" label="Expiration (texte affiché)" outlined dense class="col-12 col-md-6" placeholder="31 décembre 2026" />
|
||||
<q-input v-model.number="params.gift_expiry_days" type="number" min="1" max="365"
|
||||
label="Expiration interne (jours)" outlined dense class="col-6 col-md-3" placeholder="90">
|
||||
<q-tooltip>Délai après lequel le lien intermédiaire /g/<token> expire et ne redirige plus. Le lien Giftbit sous-jacent reste valide chez Giftbit jusqu'à leur propre date — utile pour réassigner un cadeau non utilisé à un autre client.</q-tooltip>
|
||||
</q-input>
|
||||
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-6 col-md-3" />
|
||||
<!-- Internal-only expiry on our /g/<token> wrapper. Preset
|
||||
buttons cover the typical cases; "Custom" reveals a
|
||||
number input for any value between 1-365 days. -->
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="text-caption text-grey-7 q-mb-xs">
|
||||
Expiration interne du lien (avant réassignation possible)
|
||||
<q-icon name="info" size="14px" class="q-ml-xs">
|
||||
<q-tooltip max-width="320px">Délai après lequel notre lien intermédiaire <code>/g/<token></code> cesse de rediriger. Le lien Giftbit sous-jacent reste valide chez Giftbit — utile pour réassigner un cadeau non utilisé à un autre client.</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn-toggle
|
||||
:model-value="expiryPreset"
|
||||
@update:model-value="setExpiryPreset"
|
||||
:options="[
|
||||
{ label: '15j', value: 15 },
|
||||
{ label: '30j', value: 30 },
|
||||
{ label: '60j', value: 60 },
|
||||
{ label: '90j', value: 90 },
|
||||
{ label: '180j', value: 180 },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
]"
|
||||
unelevated dense color="grey-3" text-color="grey-9"
|
||||
toggle-color="primary" toggle-text-color="white"
|
||||
no-caps
|
||||
/>
|
||||
<q-input v-if="expiryPreset === 'custom'"
|
||||
v-model.number="params.gift_expiry_days"
|
||||
type="number" min="1" max="365"
|
||||
dense outlined style="width: 120px"
|
||||
suffix="jours" />
|
||||
<span v-else class="text-caption text-grey-6 q-ml-sm">
|
||||
= {{ params.gift_expiry_days }} jour{{ params.gift_expiry_days > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-12 col-md-6" />
|
||||
<q-select v-model="params.multi" :options="multiOptions" emit-value map-options label="Emails multiples (couples)" outlined dense class="col-12 col-md-6" />
|
||||
<!-- Per-language template override. Defaults to gift-email-fr /
|
||||
gift-email-en. Lets the operator pick seasonal or A/B
|
||||
|
|
@ -567,6 +599,23 @@ const multiOptions = [
|
|||
{ label: 'Ignorer les couples', value: 'skip' },
|
||||
]
|
||||
|
||||
// expiryPreset reflects the current params.gift_expiry_days as one of the
|
||||
// preset buttons, or 'custom' when the value is non-standard. Two-way:
|
||||
// clicking a preset writes back to params.gift_expiry_days.
|
||||
const EXPIRY_PRESETS = [15, 30, 60, 90, 180]
|
||||
const expiryPreset = computed(() => {
|
||||
const v = Number(params.value.gift_expiry_days)
|
||||
return EXPIRY_PRESETS.includes(v) ? v : 'custom'
|
||||
})
|
||||
function setExpiryPreset (val) {
|
||||
if (val === 'custom') {
|
||||
// Don't reset — let the operator continue typing in the custom input.
|
||||
// If they were on a preset and switched to custom, keep that value as starting point.
|
||||
return
|
||||
}
|
||||
params.value.gift_expiry_days = val
|
||||
}
|
||||
|
||||
const parsing = ref(false)
|
||||
const sending = ref(false)
|
||||
const recipients = ref([])
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h5">Campagnes</div>
|
||||
<q-space />
|
||||
<q-btn flat color="primary" icon="redeem" label="Liens cadeaux" :to="'/campaigns/gifts'" class="q-mr-sm">
|
||||
<q-tooltip>Inventaire de tous les liens cadeaux (actifs, expirés, réassignables)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat color="primary" icon="palette" label="Éditer le template" :to="'/campaigns/templates/gift-email-fr'" class="q-mr-sm" />
|
||||
<q-btn unelevated color="primary" icon="add" label="Nouvelle campagne" :to="'/campaigns/new'" />
|
||||
</div>
|
||||
|
|
|
|||
294
apps/ops/src/modules/campaigns/pages/GiftsInventoryPage.vue
Normal file
294
apps/ops/src/modules/campaigns/pages/GiftsInventoryPage.vue
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
<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">
|
||||
<q-tooltip>Retour aux campagnes</q-tooltip>
|
||||
</q-btn>
|
||||
<div class="text-h5">Inventaire des liens cadeaux</div>
|
||||
<q-space />
|
||||
<q-btn flat dense icon="refresh" label="Rafraîchir" @click="load" :loading="loading" />
|
||||
</div>
|
||||
|
||||
<!-- Status counters strip — a glance at the inventory health -->
|
||||
<div class="row q-col-gutter-sm q-mb-md">
|
||||
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = null" style="cursor:pointer">
|
||||
<q-card-section class="text-center q-pa-sm">
|
||||
<div class="text-h5">{{ gifts.length }}</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" @click="filterStatus = 'active'" style="cursor:pointer">
|
||||
<q-card-section class="text-center q-pa-sm">
|
||||
<div class="text-h5 text-positive">{{ counts.active }}</div>
|
||||
<div class="text-caption text-grey-7">Actifs</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'redeemed'" style="cursor:pointer">
|
||||
<q-card-section class="text-center q-pa-sm">
|
||||
<div class="text-h5 text-deep-purple-7">{{ counts.redeemed }}</div>
|
||||
<div class="text-caption text-grey-7">Cliqués</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'expired'" style="cursor:pointer">
|
||||
<q-card-section class="text-center q-pa-sm">
|
||||
<div class="text-h5 text-orange-9">{{ counts.expired }}</div>
|
||||
<div class="text-caption text-grey-7">Expirés</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'revoked'" style="cursor:pointer">
|
||||
<q-card-section class="text-center q-pa-sm">
|
||||
<div class="text-h5 text-grey-7">{{ counts.revoked }}</div>
|
||||
<div class="text-caption text-grey-7">Révoqués</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'reassignable'" style="cursor:pointer"
|
||||
:class="{ 'bg-amber-1': counts.reassignable > 0 }">
|
||||
<q-card-section class="text-center q-pa-sm">
|
||||
<div class="text-h5 text-amber-9">{{ counts.reassignable }}</div>
|
||||
<div class="text-caption text-grey-7">Réassignables</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<q-card flat bordered class="q-mb-md">
|
||||
<q-card-section class="q-py-sm">
|
||||
<div class="row items-center q-col-gutter-sm">
|
||||
<q-input v-model="search" placeholder="Rechercher (nom, courriel, lien…)" outlined dense
|
||||
class="col-12 col-md-4" clearable>
|
||||
<template v-slot:prepend><q-icon name="search" /></template>
|
||||
</q-input>
|
||||
<q-select v-model="filterStatus" :options="statusOptions" emit-value map-options
|
||||
label="Statut" outlined dense clearable class="col-6 col-md-3" />
|
||||
<q-select v-model="filterCampaign" :options="campaignOptions" emit-value map-options
|
||||
label="Campagne" outlined dense clearable class="col-6 col-md-3" />
|
||||
<q-space />
|
||||
<div class="text-caption text-grey-7 q-mr-sm">{{ filtered.length }} résultat(s)</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-table
|
||||
:rows="filtered" :columns="columns" row-key="gift_token"
|
||||
flat bordered dense :pagination="{ rowsPerPage: 50, sortBy: 'expires', descending: false }"
|
||||
:rows-per-page-options="[25, 50, 100, 0]"
|
||||
:loading="loading"
|
||||
>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white"
|
||||
:icon="statusIcon(props.row.status)" :label="statusLabel(props.row.status)" />
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-recipient="props">
|
||||
<q-td :props="props">
|
||||
<div>{{ props.row.firstname }} {{ props.row.lastname }}</div>
|
||||
<div class="text-caption text-grey-6">{{ props.row.email }}</div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-campaign="props">
|
||||
<q-td :props="props">
|
||||
<a :href="`/ops/#/campaigns/${props.row.campaign_id}`" class="text-primary" style="text-decoration:none">
|
||||
{{ props.row.campaign_name || props.row.campaign_id }}
|
||||
<q-icon name="open_in_new" size="14px" />
|
||||
</a>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-gift_url="props">
|
||||
<q-td :props="props">
|
||||
<span style="font-family:monospace; font-size:0.78rem">{{ shorten(props.row.gift_url) }}</span>
|
||||
<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-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-expires="props">
|
||||
<q-td :props="props">
|
||||
<span v-if="props.row.gift_expires_at" :class="{ 'text-orange-9': props.row.status === 'expired' }">
|
||||
{{ formatDate(props.row.gift_expires_at) }}
|
||||
</span>
|
||||
<span v-else class="text-grey-5">—</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-clicks="props">
|
||||
<q-td :props="props" class="text-center">
|
||||
<span v-if="props.row.gift_redirected_count">
|
||||
{{ props.row.gift_redirected_count }}
|
||||
<q-tooltip v-if="props.row.gift_first_redirected_at">
|
||||
1er clic: {{ formatDate(props.row.gift_first_redirected_at) }}
|
||||
</q-tooltip>
|
||||
</span>
|
||||
<span v-else class="text-grey-5">0</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<q-btn v-if="props.row.status === 'active'" flat dense color="negative" icon="block" size="sm"
|
||||
@click="confirmRevoke(props.row)">
|
||||
<q-tooltip>Révoquer ce lien (libère le cadeau Giftbit pour réassignation)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-if="props.row.status === 'expired' || props.row.status === 'revoked'"
|
||||
flat dense color="amber-9" icon="autorenew" size="sm"
|
||||
@click="copyForReassign(props.row.gift_url)">
|
||||
<q-tooltip>Copier l'URL pour réassigner à un autre destinataire</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { listGifts, revokeGift } from 'src/api/campaigns'
|
||||
|
||||
const $q = useQuasar()
|
||||
const gifts = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const filterStatus = ref(null)
|
||||
const filterCampaign = ref(null)
|
||||
|
||||
const columns = [
|
||||
{ name: 'status', label: 'Statut', field: 'status', align: 'left', sortable: true },
|
||||
{ name: 'recipient', label: 'Destinataire', field: r => `${r.firstname} ${r.lastname}`.trim(), align: 'left', sortable: true },
|
||||
{ name: 'campaign', label: 'Campagne', field: 'campaign_name', align: 'left', sortable: true },
|
||||
{ name: 'gift_url', label: 'Lien Giftbit', field: 'gift_url', align: 'left' },
|
||||
{ name: 'expires', label: 'Expire le', field: 'gift_expires_at', align: 'left', sortable: true },
|
||||
{ name: 'clicks', label: 'Clics', field: 'gift_redirected_count', align: 'center', sortable: true },
|
||||
{ name: 'actions', label: '', field: '', align: 'right' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '🟢 Actif', value: 'active' },
|
||||
{ label: '🟣 Cliqué', value: 'redeemed' },
|
||||
{ label: '🟠 Expiré', value: 'expired' },
|
||||
{ label: '⚫ Révoqué', value: 'revoked' },
|
||||
{ label: '🟡 Réassignable (expiré + révoqué)', value: 'reassignable' },
|
||||
{ label: '⏳ Non envoyé', value: 'pending' },
|
||||
]
|
||||
|
||||
const campaignOptions = computed(() => {
|
||||
const seen = new Map()
|
||||
for (const g of gifts.value) {
|
||||
if (!seen.has(g.campaign_id)) seen.set(g.campaign_id, g.campaign_name || g.campaign_id)
|
||||
}
|
||||
return [...seen.entries()].map(([value, label]) => ({ value, label }))
|
||||
})
|
||||
|
||||
const counts = computed(() => {
|
||||
const c = { active: 0, redeemed: 0, expired: 0, revoked: 0, pending: 0, reassignable: 0 }
|
||||
for (const g of gifts.value) {
|
||||
c[g.status] = (c[g.status] || 0) + 1
|
||||
// Reassignable = expired or revoked AND not yet redeemed (i.e. the
|
||||
// underlying Giftbit gift is presumably still unredeemed).
|
||||
if ((g.status === 'expired' || g.status === 'revoked') && !g.gift_link_clicked) c.reassignable++
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
let rows = gifts.value
|
||||
if (filterStatus.value === 'reassignable') {
|
||||
rows = rows.filter(g => (g.status === 'expired' || g.status === 'revoked') && !g.gift_link_clicked)
|
||||
} else if (filterStatus.value) {
|
||||
rows = rows.filter(g => g.status === filterStatus.value)
|
||||
}
|
||||
if (filterCampaign.value) rows = rows.filter(g => g.campaign_id === filterCampaign.value)
|
||||
if (search.value) {
|
||||
const q = search.value.toLowerCase()
|
||||
rows = rows.filter(g =>
|
||||
(g.firstname || '').toLowerCase().includes(q) ||
|
||||
(g.lastname || '').toLowerCase().includes(q) ||
|
||||
(g.email || '').toLowerCase().includes(q) ||
|
||||
(g.gift_url || '').toLowerCase().includes(q) ||
|
||||
(g.gift_token || '').toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function statusColor (s) {
|
||||
return {
|
||||
active: 'positive',
|
||||
redeemed: 'deep-purple-7',
|
||||
expired: 'orange-9',
|
||||
revoked: 'grey-7',
|
||||
pending: 'grey-5',
|
||||
}[s] || 'grey-5'
|
||||
}
|
||||
function statusIcon (s) {
|
||||
return {
|
||||
active: 'check_circle',
|
||||
redeemed: 'redeem',
|
||||
expired: 'schedule',
|
||||
revoked: 'block',
|
||||
pending: 'hourglass_empty',
|
||||
}[s] || ''
|
||||
}
|
||||
function statusLabel (s) {
|
||||
return {
|
||||
active: 'Actif',
|
||||
redeemed: 'Cliqué',
|
||||
expired: 'Expiré',
|
||||
revoked: 'Révoqué',
|
||||
pending: 'En attente',
|
||||
}[s] || s
|
||||
}
|
||||
|
||||
function shorten (u) {
|
||||
if (!u) return ''
|
||||
return u.replace(/^https?:\/\//, '').slice(0, 36) + (u.length > 40 ? '…' : '')
|
||||
}
|
||||
function formatDate (iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
async function copy (text, customMsg) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
$q.notify({ type: 'positive', message: customMsg || 'Copié dans le presse-papier', timeout: 2500 })
|
||||
} catch {
|
||||
$q.notify({ type: 'negative', message: 'Copie impossible — sélectionne et copie manuellement' })
|
||||
}
|
||||
}
|
||||
function copyForReassign (url) {
|
||||
// Separate helper so the toast message can live in JS where quote-escaping
|
||||
// is trivial (the Vue attribute parser doesn't accept embedded quotes).
|
||||
copy(url, `Lien Giftbit copié — colle-le dans "Ajouter manuellement" d'une nouvelle campagne pour réassigner ce cadeau`)
|
||||
}
|
||||
|
||||
function confirmRevoke (row) {
|
||||
$q.dialog({
|
||||
title: 'Révoquer ce lien ?',
|
||||
message: `Le destinataire <strong>${row.firstname} ${row.lastname}</strong> ne pourra plus utiliser son lien après cette action. L'URL Giftbit (<code>${shorten(row.gift_url)}</code>) restera valide chez Giftbit et pourra être réassignée à un autre destinataire via une nouvelle campagne.`,
|
||||
html: true,
|
||||
cancel: { label: 'Annuler', flat: true },
|
||||
ok: { label: 'Révoquer', color: 'negative', unelevated: true },
|
||||
persistent: true,
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await revokeGift(row.campaign_id, row.row_index)
|
||||
row.status = 'revoked'
|
||||
row.gift_revoked = true
|
||||
$q.notify({ type: 'positive', message: 'Lien révoqué' })
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try {
|
||||
gifts.value = await listGifts()
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Chargement échoué: ' + e.message })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
|
@ -41,9 +41,11 @@ const routes = [
|
|||
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
|
||||
{ path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') },
|
||||
{ path: 'campaigns/new', component: () => import('src/modules/campaigns/pages/CampaignNewPage.vue') },
|
||||
// Template editor route must be ABOVE /campaigns/:id otherwise the
|
||||
// ':id' wildcard captures 'templates/...' and shows the detail page.
|
||||
// Template editor + gifts inventory routes must be ABOVE /campaigns/:id
|
||||
// otherwise the ':id' wildcard captures the literal paths and shows
|
||||
// the detail page instead.
|
||||
{ path: 'campaigns/templates/:name?', component: () => import('src/modules/campaigns/pages/TemplateEditorPage.vue'), props: true },
|
||||
{ path: 'campaigns/gifts', component: () => import('src/modules/campaigns/pages/GiftsInventoryPage.vue') },
|
||||
{ path: 'campaigns/:id', component: () => import('src/modules/campaigns/pages/CampaignDetailPage.vue'), props: true },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1215,6 +1215,77 @@ async function handle (req, res, method, path) {
|
|||
return json(res, 200, saved)
|
||||
}
|
||||
|
||||
// GET /campaigns/gifts — flattened inventory of every gift across every
|
||||
// campaign so operators can see which Giftbit shortlinks are still
|
||||
// unredeemed and reassignable. Cross-campaign view; lighter than the
|
||||
// full campaign JSONs (no template params, no recipient PII beyond name+email).
|
||||
// ORDER MATTERS: this route must come BEFORE the /campaigns/:id wildcard.
|
||||
if (path === '/campaigns/gifts' && method === 'GET') {
|
||||
const now = Date.now()
|
||||
const rows = []
|
||||
for (const meta of listCampaigns()) {
|
||||
const c = loadCampaign(meta.id)
|
||||
if (!c?.recipients) continue
|
||||
for (let i = 0; i < c.recipients.length; i++) {
|
||||
const r = c.recipients[i]
|
||||
if (!r.gift_url) continue
|
||||
const expired = r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now
|
||||
// Status taxonomy for the inventory UI:
|
||||
// redeemed — recipient already clicked through to Giftbit (we
|
||||
// don't know if they actually redeemed; would need
|
||||
// the Giftbit /gifts/{uuid} poll, task #25)
|
||||
// revoked — manually killed by an operator
|
||||
// expired — our own gift_expires_at has passed; reassignable
|
||||
// active — still live, may be clicked any moment
|
||||
// pending — not yet sent (no gift_token generated yet)
|
||||
let status = 'pending'
|
||||
if (r.gift_token) {
|
||||
status = 'active'
|
||||
if (r.gift_revoked) status = 'revoked'
|
||||
else if (expired) status = 'expired'
|
||||
if (r.gift_link_clicked) status = 'redeemed'
|
||||
}
|
||||
rows.push({
|
||||
campaign_id: c.id,
|
||||
campaign_name: c.name,
|
||||
row_index: i,
|
||||
firstname: r.firstname, lastname: r.lastname, email: r.email,
|
||||
gift_token: r.gift_token || null,
|
||||
gift_url: r.gift_url,
|
||||
giftbit_uuid: r.giftbit_uuid,
|
||||
gift_expires_at: r.gift_expires_at || null,
|
||||
gift_revoked: !!r.gift_revoked,
|
||||
gift_redirected_count: r.gift_redirected_count || 0,
|
||||
gift_first_redirected_at: r.gift_first_redirected_at || null,
|
||||
gift_link_clicked: !!r.gift_link_clicked,
|
||||
gift_clicked_at: r.gift_clicked_at || null,
|
||||
status,
|
||||
})
|
||||
}
|
||||
}
|
||||
return json(res, 200, { gifts: rows })
|
||||
}
|
||||
|
||||
// POST /campaigns/:id/recipients/:row/revoke — kill switch for a single
|
||||
// wrapper token. Sets gift_revoked=true so /g/<token> returns the
|
||||
// "désactivé" page. Used when an operator wants to free a Giftbit URL
|
||||
// for reassignment before its natural expiry.
|
||||
const revokeMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/revoke$/)
|
||||
if (revokeMatch && method === 'POST') {
|
||||
const c = loadCampaign(revokeMatch[1])
|
||||
if (!c) return json(res, 404, { error: 'not found' })
|
||||
const i = parseInt(revokeMatch[2], 10)
|
||||
const r = (c.recipients || [])[i]
|
||||
if (!r) return json(res, 404, { error: 'recipient not found' })
|
||||
if (!r.gift_token) return json(res, 400, { error: 'no token to revoke' })
|
||||
r.gift_revoked = true
|
||||
r.gift_revoked_at = new Date().toISOString()
|
||||
saveCampaign(c)
|
||||
log(`gift token ${r.gift_token} revoked (campaign ${c.id} row ${i})`)
|
||||
sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r })
|
||||
return json(res, 200, { revoked: true, gift_token: r.gift_token })
|
||||
}
|
||||
|
||||
// GET /campaigns — list summaries
|
||||
if (path === '/campaigns' && method === 'GET') {
|
||||
return json(res, 200, { campaigns: listCampaigns() })
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user