Auth hardening: - payments: per-customer JWT authorization on dual-use /payments routes (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage) - hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion Leaked-credential cleanup (token already rotated in ERPNext): - de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py, apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects) - ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty) - de-hardcode legacy DB password -> env in import_items.py - gitignore legacy migration PII exports (tsv/json) Conversations/UI: - floating (un-docked) conversation panel; full-width mailbox - per-message real sender from email headers; unified scroll; header spacing - campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
805 lines
39 KiB
Vue
805 lines
39 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?.recipients?.length" flat dense icon="visibility" color="indigo-6"
|
|
label="Aperçu" class="q-mr-sm" @click="openPreview()">
|
|
<q-tooltip>Voir le courriel tel qu'envoyé (rendu du template avec un destinataire réel) — conservé avec la campagne</q-tooltip>
|
|
</q-btn>
|
|
<q-btn v-if="canCreateReminder" flat dense icon="schedule" color="orange-9"
|
|
label="Créer une relance" class="q-mr-sm"
|
|
:loading="creatingReminder" @click="confirmCreateReminder">
|
|
<q-tooltip>Cloner cette campagne pour les destinataires qui n'ont PAS cliqué le cadeau encore</q-tooltip>
|
|
</q-btn>
|
|
<!-- Re-send path: clone a locked (sent) campaign into a fresh draft where
|
|
the sender + subjects are editable again. -->
|
|
<q-btn v-if="campaign && campaign.status !== 'draft'" flat dense icon="content_copy" color="primary"
|
|
label="Dupliquer (brouillon)" class="q-mr-sm"
|
|
:loading="cloning" @click="cloneAndOpen">
|
|
<q-tooltip>Crée une copie en brouillon (tous les destinataires réinitialisés) — pour réenvoyer, ex. avec un autre expéditeur</q-tooltip>
|
|
</q-btn>
|
|
<!-- Draft-only edit affordances: params dialog + jump to template
|
|
editor. Both invisible once status flips off draft to keep
|
|
operators from accidentally mutating a campaign mid-send. -->
|
|
<q-btn v-if="campaign?.status === 'draft'" flat dense icon="palette" color="primary"
|
|
label="Éditer le template" class="q-mr-sm"
|
|
:href="templateEditorHref" target="_blank">
|
|
<q-tooltip>Modifier le contenu HTML du template dans un nouvel onglet (Unlayer)</q-tooltip>
|
|
</q-btn>
|
|
<q-btn v-if="campaign?.status === 'draft'" flat dense icon="tune" color="primary"
|
|
label="Éditer les paramètres" class="q-mr-sm"
|
|
@click="openEditParams">
|
|
<q-tooltip>Sujet, expéditeur, montant affiché, choix de template (FR/EN), etc.</q-tooltip>
|
|
</q-btn>
|
|
<q-btn v-if="campaign?.status === 'draft'" unelevated color="primary" icon="send" label="Lancer l'envoi"
|
|
:loading="resending" @click="openSendConfirm" />
|
|
</div>
|
|
|
|
<!-- Counters bar -->
|
|
<!-- Family banner: this campaign is either a parent (has reminders) or
|
|
a child reminder (linked to a parent). One badge each direction so
|
|
the operator can hop between them quickly without leaving the
|
|
single-glance click view. -->
|
|
<q-banner v-if="campaign?.reminder_of"
|
|
class="bg-blue-1 text-blue-9 q-mb-md" rounded dense>
|
|
<template v-slot:avatar><q-icon name="reply" /></template>
|
|
Cette campagne est une <strong>relance</strong> de
|
|
<a :href="`/ops/#/campaigns/${campaign.reminder_of}`" class="text-primary">
|
|
← campagne parent
|
|
</a>. Les clics ici sont automatiquement remontés au parent.
|
|
</q-banner>
|
|
<q-banner v-if="campaign?.reminders?.length"
|
|
class="bg-deep-purple-1 text-deep-purple-9 q-mb-md" rounded dense>
|
|
<template v-slot:avatar><q-icon name="schedule" /></template>
|
|
<div>
|
|
<strong>{{ campaign.reminders.length }} relance(s)</strong> envoyée(s) à partir de cette campagne —
|
|
les clics du rappel sont déjà comptabilisés dans
|
|
<strong>"Cadeau cliqué"</strong> ci-dessous (avec un 🔁 sur les rangées concernées).
|
|
</div>
|
|
<div class="q-mt-xs">
|
|
<q-chip v-for="rem in campaign.reminders" :key="rem.id" dense outline color="deep-purple-7" clickable
|
|
:to="`/campaigns/${rem.id}`" class="q-mr-xs">
|
|
{{ rem.name || rem.id }}
|
|
<q-badge color="deep-purple-7" class="q-ml-xs">{{ rem.gift_clicked }}/{{ rem.total }}</q-badge>
|
|
</q-chip>
|
|
</div>
|
|
</q-banner>
|
|
|
|
<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', { timeZone: 'America/Montreal' })}` : '' }}
|
|
<span v-if="props.row.gift_clicked_via_reminder"><br>↩ via la relance <strong>{{ props.row.gift_clicked_via_reminder }}</strong></span>
|
|
</q-tooltip>
|
|
</q-icon>
|
|
<q-icon v-if="props.row.gift_clicked_via_reminder" name="autorenew" color="orange-9" size="14px" class="q-ml-xs">
|
|
<q-tooltip>Ce clic a été remonté depuis la campagne de relance, pas le 1er courriel</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">
|
|
<div v-if="props.row.error" class="row items-center q-gutter-xs">
|
|
<span class="text-negative text-caption" style="max-width:240px;word-break:break-word">{{ props.row.error }}</span>
|
|
<q-btn v-if="props.row.status === 'failed'" flat dense size="xs" color="primary"
|
|
icon="refresh" @click="retryFailedRow(props.row)">
|
|
<q-tooltip>Renvoyer ce destinataire (réessaie l'envoi SMTP avec 3 tentatives)</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</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>
|
|
|
|
<!-- Edit-params dialog. Mirrors a subset of the new-campaign wizard
|
|
step 1 fields — everything that's safe to change pre-launch on
|
|
a draft. Saves via PATCH /campaigns/:id which merges into
|
|
campaign.params (the existing values stay if not touched). -->
|
|
<q-dialog v-model="editParamsOpen" persistent>
|
|
<q-card style="min-width: 560px; max-width: 720px">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<div class="text-h6"><q-icon name="tune" class="q-mr-sm" />Éditer les paramètres</div>
|
|
<q-space />
|
|
<q-btn flat dense round icon="close" v-close-popup />
|
|
</q-card-section>
|
|
<q-card-section>
|
|
<q-form @submit="saveEditParams" class="q-gutter-sm">
|
|
<q-input v-model="editParams.name" label="Nom interne (visible uniquement dans l'admin)" outlined dense />
|
|
<div class="row q-col-gutter-sm">
|
|
<q-input v-model="editParams.subject_fr" label="Sujet (FR)" outlined dense class="col-12 col-sm-6"
|
|
:rules="[v => !!v || 'requis']" />
|
|
<q-input v-model="editParams.subject_en" label="Sujet (EN)" outlined dense class="col-12 col-sm-6"
|
|
placeholder="(défaut : sujet FR si vide)" />
|
|
</div>
|
|
<SenderField v-model="editParams.from" />
|
|
<ChannelToggle v-model="editParams.channel" />
|
|
<div class="row q-col-gutter-sm">
|
|
<q-input v-model="editParams.amount" label="Montant affiché" outlined dense
|
|
class="col-12 col-sm-4" placeholder="60 $" />
|
|
<q-input v-model.number="editParams.commitment_months" type="number" min="1"
|
|
label="Engagement (mois)" outlined dense class="col-12 col-sm-4" />
|
|
<q-input v-model="editParams.expiry" label="Expiration (texte affiché)" outlined dense
|
|
class="col-12 col-sm-4" placeholder="31 décembre 2026" />
|
|
</div>
|
|
<!-- Wrapper expiry: date picker (absolute) — empties when cleared
|
|
so the hub falls back to gift_expiry_days. -->
|
|
<q-input v-model="editParams.gift_expires_at_display" outlined dense readonly
|
|
label="Date d'expiration du lien (affichée aux destinataires)"
|
|
placeholder="Choisir une date ou laisser vide pour utiliser le défaut">
|
|
<template v-slot:append>
|
|
<q-icon name="event" class="cursor-pointer">
|
|
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
|
<q-date :model-value="editParams.gift_expires_at_display"
|
|
@update:model-value="v => editParams.gift_expires_at_display = v || ''"
|
|
mask="YYYY-MM-DD" :options="dateAfterToday"
|
|
color="primary" minimal>
|
|
<div class="row items-center justify-end">
|
|
<q-btn v-close-popup label="OK" color="primary" flat />
|
|
</div>
|
|
</q-date>
|
|
</q-popup-proxy>
|
|
</q-icon>
|
|
<q-icon v-if="editParams.gift_expires_at_display" name="close"
|
|
class="cursor-pointer q-ml-xs"
|
|
@click="editParams.gift_expires_at_display = ''">
|
|
<q-tooltip>Effacer la date explicite</q-tooltip>
|
|
</q-icon>
|
|
</template>
|
|
</q-input>
|
|
<div class="row q-col-gutter-sm">
|
|
<q-select v-model="editParams.template_fr" :options="frTemplateOptions" emit-value map-options
|
|
label="🇫🇷 Template français" outlined dense class="col-12 col-sm-6"
|
|
:loading="loadingTemplates" @popup-show="loadTemplateLists" />
|
|
<q-select v-model="editParams.template_en" :options="enTemplateOptions" emit-value map-options
|
|
label="🇺🇸 Template anglais" outlined dense class="col-12 col-sm-6"
|
|
:loading="loadingTemplates" @popup-show="loadTemplateLists" />
|
|
</div>
|
|
<div class="text-caption text-grey-7 q-mt-sm">
|
|
<q-icon name="info" size="14px" /> Pour modifier le <strong>contenu</strong> du courriel
|
|
(texte, mise en page, conditions, etc.), clique <strong>"Éditer le template"</strong>
|
|
à côté — ça ouvre l'éditeur Unlayer dans un nouvel onglet. Les modifications de template
|
|
s'appliquent à <strong>toutes les campagnes</strong> qui utilisent ce template ; pour
|
|
une version unique à cette campagne, crée d'abord un template variant
|
|
(<code>+ Nouveau</code> dans l'éditeur) et sélectionne-le ici.
|
|
</div>
|
|
<div class="row q-mt-md">
|
|
<q-space />
|
|
<q-btn flat label="Annuler" v-close-popup class="q-mr-sm" />
|
|
<q-btn unelevated color="primary" type="submit" icon="save" label="Enregistrer"
|
|
:loading="savingParams" />
|
|
</div>
|
|
</q-form>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Pre-send review. Final summary + last chance to edit the per-language
|
|
subject before the send commits. Replaces the old fire-immediately
|
|
"Lancer l'envoi" behaviour. -->
|
|
<q-dialog v-model="sendConfirmOpen" persistent>
|
|
<q-card style="min-width:420px;max-width:560px">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<q-icon name="send" color="primary" size="sm" class="q-mr-sm" />
|
|
<div class="text-h6">Vérifier avant l'envoi</div>
|
|
<q-space />
|
|
<q-btn flat dense round icon="close" v-close-popup />
|
|
</q-card-section>
|
|
|
|
<q-card-section class="q-gutter-sm">
|
|
<!-- Recipients + From + templates: read-only summary -->
|
|
<q-list dense bordered class="rounded-borders q-mb-sm">
|
|
<q-item>
|
|
<q-item-section avatar><q-icon name="group" color="grey-7" /></q-item-section>
|
|
<q-item-section>
|
|
<q-item-label caption>Destinataires</q-item-label>
|
|
<q-item-label>
|
|
<strong>{{ campaign?.recipients?.length || 0 }}</strong>
|
|
<q-badge color="blue-7" class="q-ml-sm" v-if="langBreakdown.fr">{{ langBreakdown.fr }} FR</q-badge>
|
|
<q-badge color="teal-7" class="q-ml-xs" v-if="langBreakdown.en">{{ langBreakdown.en }} EN</q-badge>
|
|
<q-badge color="grey-6" class="q-ml-xs" v-if="langBreakdown.other">{{ langBreakdown.other }} autre</q-badge>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-separator />
|
|
<q-item>
|
|
<q-item-section avatar><q-icon name="description" color="grey-7" /></q-item-section>
|
|
<q-item-section>
|
|
<q-item-label caption>Templates</q-item-label>
|
|
<q-item-label class="text-body2">
|
|
FR : {{ campaign?.params?.template_fr || '—' }}<span v-if="langBreakdown.en"> · EN : {{ campaign?.params?.template_en || '—' }}</span>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
|
|
<!-- Channel + editable sender + subjects — fix them right before sending -->
|
|
<ChannelToggle v-model="sendForm.channel" />
|
|
<SenderField v-model="sendForm.from" />
|
|
<q-input v-model="sendForm.subject_fr" label="Sujet (FR)" outlined dense
|
|
:rules="[v => !!v || 'requis']" />
|
|
<q-input v-model="sendForm.subject_en" label="Sujet (EN)" outlined dense
|
|
v-if="langBreakdown.en"
|
|
placeholder="(défaut : sujet FR si vide)" />
|
|
<div class="text-caption text-grey-7">
|
|
Chaque client reçoit le sujet de sa langue (FR → sujet FR, EN → sujet EN).
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right" class="q-pa-md">
|
|
<q-btn flat label="Annuler" v-close-popup :disable="resending" />
|
|
<q-btn unelevated color="primary" icon="send"
|
|
:label="`Confirmer l'envoi (${campaign?.recipients?.length || 0})`"
|
|
:loading="resending" :disable="!sendForm.subject_fr"
|
|
@click="confirmSend" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Aperçu de l'envoi : rendu exact du template (FR/EN), conservé avec la campagne. -->
|
|
<q-dialog v-model="previewOpen">
|
|
<q-card style="width:680px;max-width:95vw;height:82vh;display:flex;flex-direction:column">
|
|
<q-card-section class="row items-center q-py-sm">
|
|
<q-icon name="visibility" color="indigo-6" size="sm" class="q-mr-sm" />
|
|
<div class="text-h6">Aperçu de l'envoi</div>
|
|
<q-space />
|
|
<q-btn-toggle :model-value="previewLang" @update:model-value="openPreview" dense no-caps unelevated size="sm"
|
|
toggle-color="primary" color="grey-3" text-color="grey-8" class="q-mr-sm"
|
|
:options="[{ label: 'FR', value: 'fr' }, { label: 'EN', value: 'en' }]" />
|
|
<q-btn flat dense round icon="close" v-close-popup />
|
|
</q-card-section>
|
|
<q-separator />
|
|
<div class="col" style="flex:1;min-height:0;position:relative;background:#f4f6f8">
|
|
<q-inner-loading :showing="previewLoading" />
|
|
<iframe v-if="previewHtml" :srcdoc="previewHtml" title="Aperçu"
|
|
style="width:100%;height:100%;border:0;display:block" sandbox="allow-same-origin"></iframe>
|
|
</div>
|
|
</q-card>
|
|
</q-dialog>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useQuasar } from 'quasar'
|
|
import { getCampaign, sendCampaign, cloneCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates, previewTemplate } from 'src/api/campaigns'
|
|
import SenderField from 'src/modules/campaigns/components/SenderField.vue'
|
|
import ChannelToggle from 'src/modules/campaigns/components/ChannelToggle.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const $q = useQuasar()
|
|
const id = route.params.id
|
|
const campaign = ref(null)
|
|
const loading = ref(true)
|
|
const resending = ref(false)
|
|
const creatingReminder = ref(false)
|
|
const cloning = ref(false)
|
|
|
|
// ── Aperçu de l'envoi ───────────────────────────────────────────────────────
|
|
// Rend le template de la campagne (FR/EN) exactement comme envoyé, avec les
|
|
// données d'un vrai destinataire. Permet de « conserver l'aperçu » sans encombrer
|
|
// l'Inbox (aucun courriel envoyé) — on n'ingère plus nos propres envois.
|
|
const previewOpen = ref(false)
|
|
const previewHtml = ref('')
|
|
const previewLang = ref('fr')
|
|
const previewLoading = ref(false)
|
|
async function openPreview (lang) {
|
|
const p = campaign.value?.params || {}
|
|
previewLang.value = (lang === 'fr' || lang === 'en') ? lang : (previewLang.value || 'fr')
|
|
const tpl = previewLang.value === 'en' ? (p.template_en || p.template_fr) : (p.template_fr || p.template_en)
|
|
if (!tpl) { $q.notify({ type: 'warning', message: 'Aucun template défini sur cette campagne.' }); return }
|
|
// Destinataire représentatif : le 1er de cette langue, sinon le 1er tout court.
|
|
const recips = campaign.value?.recipients || []
|
|
const r = recips.find(x => (x.language || 'fr') === previewLang.value) || recips[0] || {}
|
|
previewOpen.value = true
|
|
previewLoading.value = true
|
|
try {
|
|
const res = await previewTemplate(tpl, { vars: {
|
|
firstname: r.firstname || (previewLang.value === 'en' ? 'dear customer' : 'cher client'),
|
|
lastname: r.lastname || '', email: r.email || '', amount: r.amount || p.amount || '',
|
|
} })
|
|
previewHtml.value = res.rendered || ''
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }); previewHtml.value = '' } finally { previewLoading.value = false }
|
|
}
|
|
|
|
// ── Pre-send review dialog ──────────────────────────────────────────────────
|
|
// "Lancer l'envoi" no longer fires immediately; it opens a summary where the
|
|
// operator confirms the From, the recipient count (split by language), the
|
|
// templates, AND can edit the per-language subject one last time before the
|
|
// send commits. sendForm is a snapshot of the subjects; on confirm we PATCH
|
|
// any change back (params are merged on the hub) then trigger the send.
|
|
const sendConfirmOpen = ref(false)
|
|
const sendForm = ref({ channel: 'mailjet', from: '', subject_fr: '', subject_en: '' })
|
|
|
|
// Recipient count split by language — drives the summary line so the operator
|
|
// sees exactly how many FR vs EN subjects will go out.
|
|
const langBreakdown = computed(() => {
|
|
const out = { fr: 0, en: 0, other: 0 }
|
|
for (const r of (campaign.value?.recipients || [])) {
|
|
const l = (r.language || '').toLowerCase()
|
|
if (l === 'fr') out.fr++
|
|
else if (l === 'en') out.en++
|
|
else out.other++
|
|
}
|
|
return out
|
|
})
|
|
|
|
// Edit-params dialog state. editParams is a snapshot of campaign.params
|
|
// that the form mutates; on save we PATCH only that subset back to the
|
|
// hub, which merges over the existing params on disk.
|
|
const editParamsOpen = ref(false)
|
|
const savingParams = ref(false)
|
|
const editParams = ref({
|
|
name: '', subject: '', subject_fr: '', subject_en: '', from: '', channel: 'mailjet', amount: '', commitment_months: 3,
|
|
expiry: '', template_fr: 'gift-email-fr', template_en: 'gift-email-en',
|
|
// UI-only — derived from / writes back to params.gift_expires_at (full ISO).
|
|
gift_expires_at_display: '',
|
|
})
|
|
|
|
// Date picker validator: future dates only.
|
|
function dateAfterToday (dateStr) {
|
|
const d = new Date(dateStr.replace(/\//g, '-'))
|
|
d.setHours(23, 59, 59, 999)
|
|
return d.getTime() > Date.now()
|
|
}
|
|
|
|
// Convert a YYYY-MM-DD string to the ISO UTC of end-of-day on that date,
|
|
// interpreted as Montreal local. Handles EDT (UTC-4 summer) and EST (UTC-5
|
|
// winter) by probing what hour Intl reports at noon UTC on the same date —
|
|
// noon UTC is always morning in Montreal, so the date never rolls over.
|
|
function endOfDayMontreal (yyyyMmDd) {
|
|
const [y, m, d] = yyyyMmDd.split('-').map(Number)
|
|
const probe = new Date(Date.UTC(y, m - 1, d, 12, 0, 0)) // noon UTC on the target date
|
|
const mtlHour = parseInt(new Intl.DateTimeFormat('en-US', {
|
|
hour: '2-digit', hour12: false, timeZone: 'America/Montreal',
|
|
}).format(probe), 10)
|
|
// Noon UTC reads as 08:00 in EDT or 07:00 in EST → offset = 12 - mtlHour
|
|
const offsetHours = 12 - mtlHour
|
|
// End of day Montreal = 23:59:59 Mtl → (23 + offsetHours):59:59 UTC, rolls
|
|
// over to the next UTC day during summer (EDT).
|
|
return new Date(Date.UTC(y, m - 1, d, 23 + offsetHours, 59, 59, 999)).toISOString()
|
|
}
|
|
|
|
// Template lists for the FR/EN dropdowns in the dialog. Refreshed lazily
|
|
// when the dropdown opens so newly-created templates show up without a
|
|
// page reload.
|
|
const loadingTemplates = ref(false)
|
|
const frTemplateOptions = ref([{ label: 'gift-email-fr', value: 'gift-email-fr' }])
|
|
const enTemplateOptions = ref([{ label: 'gift-email-en', value: 'gift-email-en' }])
|
|
async function loadTemplateLists () {
|
|
loadingTemplates.value = true
|
|
try {
|
|
const tpls = await listTemplates()
|
|
const opt = (t, preferred) => ({
|
|
label: preferred ? t.name : `${t.name} · sans suffixe de langue`,
|
|
value: t.name,
|
|
})
|
|
const fr = [], en = []
|
|
for (const t of tpls) {
|
|
if (!t?.name) continue
|
|
if (t.name.endsWith('-fr')) fr.push(opt(t, true))
|
|
else if (t.name.endsWith('-en')) en.push(opt(t, true))
|
|
else { fr.push(opt(t, false)); en.push(opt(t, false)) }
|
|
}
|
|
const sortFn = (a, b) => {
|
|
const aPref = !a.label.includes('sans suffixe')
|
|
const bPref = !b.label.includes('sans suffixe')
|
|
if (aPref !== bPref) return aPref ? -1 : 1
|
|
return a.value.localeCompare(b.value)
|
|
}
|
|
fr.sort(sortFn); en.sort(sortFn)
|
|
if (fr.length) frTemplateOptions.value = fr
|
|
if (en.length) enTemplateOptions.value = en
|
|
} catch (e) {
|
|
$q.notify({ type: 'warning', message: 'Templates non chargés : ' + e.message })
|
|
} finally {
|
|
loadingTemplates.value = 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))
|
|
|
|
// Deep link to the template editor for the FR template configured on
|
|
// this campaign (most TARGO customers are FR). The editor route also
|
|
// accepts no name and lets the user pick from a dropdown.
|
|
const templateEditorHref = computed(() => {
|
|
const t = campaign.value?.params?.template_fr || 'gift-email-fr'
|
|
return `/ops/#/campaigns/templates/${encodeURIComponent(t)}`
|
|
})
|
|
|
|
// Eligibility for the "Créer une relance" button: campaign sent at least
|
|
// once (not draft), it's not itself a reminder, and at least one recipient
|
|
// is in a non-clicked / non-revoked / non-expired state. The hub re-checks
|
|
// this when the POST hits — UI gating is purely to hide the button when
|
|
// pointless.
|
|
const nonClickedCount = computed(() => {
|
|
const rs = campaign.value?.recipients || []
|
|
const now = Date.now()
|
|
return rs.filter(r =>
|
|
!r.excluded
|
|
&& !r.gift_link_clicked
|
|
&& !r.gift_revoked
|
|
&& (!r.gift_expires_at || new Date(r.gift_expires_at).getTime() > now)
|
|
&& r.gift_url
|
|
&& ['sent', 'opened'].includes(r.status),
|
|
).length
|
|
})
|
|
const canCreateReminder = computed(() =>
|
|
campaign.value
|
|
&& !campaign.value.reminder_of // don't reminder-of-a-reminder
|
|
&& ['sending', 'completed'].includes(campaign.value.status)
|
|
&& nonClickedCount.value > 0,
|
|
)
|
|
|
|
// Pick a representative expiry to show in the confirmation dialog —
|
|
// almost always identical across recipients since they were sent in the
|
|
// same worker pass. Fallback to the first non-empty value.
|
|
const sampleExpiryDate = computed(() => {
|
|
const r = (campaign.value?.recipients || []).find(r => r.gift_expires_at)
|
|
return r ? new Date(r.gift_expires_at).toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' }) : '—'
|
|
})
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
// Open the pre-send review dialog, snapshotting the current per-language
|
|
// subjects so the operator can adjust them in place before committing.
|
|
function openSendConfirm () {
|
|
const p = campaign.value?.params || {}
|
|
sendForm.value = {
|
|
channel: p.channel || 'mailjet',
|
|
from: p.from || '',
|
|
subject_fr: p.subject_fr || p.subject || '',
|
|
subject_en: p.subject_en || '',
|
|
}
|
|
sendConfirmOpen.value = true
|
|
}
|
|
|
|
// Duplicate this (locked, already-sent) campaign into a fresh draft and open
|
|
// it. The clone resets recipients to pending and is a draft, so the From
|
|
// picker, subjects and template are all editable again before re-sending.
|
|
// The page binds `id` at setup, so navigate then reload to bind to the clone.
|
|
async function cloneAndOpen () {
|
|
cloning.value = true
|
|
try {
|
|
const clone = await cloneCampaign(id)
|
|
$q.notify({ type: 'positive', message: `Copie créée en brouillon : ${clone.name}` })
|
|
await router.push(`/campaigns/${clone.id}`)
|
|
window.location.reload()
|
|
} catch (e) {
|
|
$q.notify({ type: 'negative', message: e.message })
|
|
cloning.value = false
|
|
}
|
|
}
|
|
|
|
// Confirm from the review dialog: persist any subject edit (PATCH merges into
|
|
// params on the hub), then fire the actual send.
|
|
async function confirmSend () {
|
|
const p = campaign.value?.params || {}
|
|
const changed = sendForm.value.channel !== (p.channel || 'mailjet')
|
|
|| sendForm.value.from !== (p.from || '')
|
|
|| sendForm.value.subject_fr !== (p.subject_fr || p.subject || '')
|
|
|| sendForm.value.subject_en !== (p.subject_en || '')
|
|
resending.value = true
|
|
try {
|
|
if (changed) {
|
|
await updateCampaign(id, { params: {
|
|
channel: sendForm.value.channel,
|
|
from: sendForm.value.from,
|
|
subject_fr: sendForm.value.subject_fr,
|
|
subject_en: sendForm.value.subject_en,
|
|
subject: sendForm.value.subject_fr, // legacy single-subject fallback
|
|
} })
|
|
}
|
|
await sendCampaign(id)
|
|
sendConfirmOpen.value = false
|
|
await load()
|
|
subscribeSse()
|
|
} catch (e) {
|
|
$q.notify({ type: 'negative', message: e.message })
|
|
} finally {
|
|
resending.value = false
|
|
}
|
|
}
|
|
|
|
// Re-send a single failed recipient. The hub resets status pending and
|
|
// re-fires the worker which retries up to 3 times with backoff. UI live
|
|
// updates via the SSE channel — no manual reload needed.
|
|
async function retryFailedRow (row) {
|
|
const rowIdx = (campaign.value?.recipients || []).indexOf(row)
|
|
if (rowIdx < 0) return
|
|
try {
|
|
await retryRecipient(id, rowIdx)
|
|
$q.notify({ type: 'positive', message: `Renvoi en cours pour ${row.firstname || row.email}…` })
|
|
} catch (e) {
|
|
$q.notify({ type: 'negative', message: 'Renvoi impossible : ' + e.message })
|
|
}
|
|
}
|
|
|
|
// Open the params dialog, snapshotting current campaign.params + name
|
|
// into the editable form. Lazily load the template lists so the
|
|
// dropdowns are populated when the dialog mounts.
|
|
function openEditParams () {
|
|
const p = campaign.value?.params || {}
|
|
// Convert the stored UTC datetime back to YYYY-MM-DD as displayed in
|
|
// America/Montreal — without this, a timestamp stored as 03:59 UTC
|
|
// (= 23:59 EDT the previous day) shows the WRONG day in the picker
|
|
// because .toISOString() outputs UTC. en-CA locale gives YYYY-MM-DD.
|
|
const expiresAtDisplay = p.gift_expires_at
|
|
? new Intl.DateTimeFormat('en-CA', {
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
timeZone: 'America/Montreal',
|
|
}).format(new Date(p.gift_expires_at))
|
|
: ''
|
|
editParams.value = {
|
|
name: campaign.value?.name || '',
|
|
subject: p.subject || '',
|
|
subject_fr: p.subject_fr || p.subject || '',
|
|
subject_en: p.subject_en || '',
|
|
from: p.from || '',
|
|
channel: p.channel || 'mailjet',
|
|
amount: p.amount || '',
|
|
commitment_months: p.commitment_months || 3,
|
|
expiry: p.expiry || '',
|
|
template_fr: p.template_fr || 'gift-email-fr',
|
|
template_en: p.template_en || 'gift-email-en',
|
|
gift_expires_at_display: expiresAtDisplay,
|
|
}
|
|
editParamsOpen.value = true
|
|
loadTemplateLists()
|
|
}
|
|
|
|
// PATCH the campaign with the new name + params. Hub merges params over
|
|
// the existing ones (spread) so unspecified fields stay put. Live UI
|
|
// refresh via load() so the recap + counters reflect the new values.
|
|
async function saveEditParams () {
|
|
savingParams.value = true
|
|
try {
|
|
// Translate the YYYY-MM-DD picker value into the full ISO datetime the
|
|
// hub stores. Anchor at end-of-day America/Montreal so "until June 21"
|
|
// stays valid through all of June 21 Quebec time, regardless of where
|
|
// the operator's browser is set. Empty → fall back to gift_expiry_days
|
|
// on the hub side. PATCH merges into params, so explicitly send null
|
|
// to clear.
|
|
const giftExpiresAt = editParams.value.gift_expires_at_display
|
|
? endOfDayMontreal(editParams.value.gift_expires_at_display)
|
|
: null
|
|
await updateCampaign(id, {
|
|
name: editParams.value.name,
|
|
params: {
|
|
subject: editParams.value.subject_fr || editParams.value.subject, // repli legacy = sujet FR
|
|
subject_fr: editParams.value.subject_fr,
|
|
subject_en: editParams.value.subject_en,
|
|
from: editParams.value.from,
|
|
channel: editParams.value.channel,
|
|
amount: editParams.value.amount,
|
|
commitment_months: editParams.value.commitment_months,
|
|
expiry: editParams.value.expiry,
|
|
template_fr: editParams.value.template_fr,
|
|
template_en: editParams.value.template_en,
|
|
gift_expires_at: giftExpiresAt,
|
|
},
|
|
})
|
|
await load()
|
|
editParamsOpen.value = false
|
|
$q.notify({ type: 'positive', message: 'Paramètres enregistrés' })
|
|
} catch (e) {
|
|
$q.notify({ type: 'negative', message: 'Erreur : ' + e.message })
|
|
} finally {
|
|
savingParams.value = false
|
|
}
|
|
}
|
|
|
|
// Confirmation + creation of the reminder campaign. The hub returns the
|
|
// new draft campaign; we navigate to its detail page so the operator can
|
|
// review the recipients list and click "Lancer l'envoi" when ready.
|
|
function confirmCreateReminder () {
|
|
const n = nonClickedCount.value
|
|
$q.dialog({
|
|
title: 'Créer une campagne de relance ?',
|
|
message: `<div>Une nouvelle campagne <strong>brouillon</strong> sera créée avec les <strong>${n} destinataire(s)</strong> qui n'ont pas encore cliqué le lien-cadeau.<br><br>
|
|
• Mêmes liens Giftbit sous-jacents (le destinataire peut toujours utiliser l'un ou l'autre courriel)<br>
|
|
• Nouveaux liens <code>/g/<token></code> avec leur propre expiration<br>
|
|
• Template <code>gift-email-reminder-fr</code> / <code>-en</code> (framing d'urgence)<br>
|
|
• Sujet par défaut : "⏰ Dernière chance — ton cadeau TARGO expire bientôt"<br>
|
|
• Expiration affichée : <strong>${sampleExpiryDate.value}</strong><br><br>
|
|
La campagne ne part PAS automatiquement — tu pourras réviser le sujet/template et lancer manuellement.</div>`,
|
|
html: true,
|
|
cancel: { label: 'Annuler', flat: true },
|
|
ok: { label: 'Créer la relance', color: 'orange-9', unelevated: true },
|
|
persistent: true,
|
|
}).onOk(async () => {
|
|
creatingReminder.value = true
|
|
try {
|
|
const saved = await createReminderCampaign(id)
|
|
$q.notify({ type: 'positive', message: `Relance créée — ${n} destinataires` })
|
|
router.push(`/campaigns/${saved.id}`)
|
|
} catch (e) {
|
|
$q.notify({ type: 'negative', message: 'Erreur : ' + e.message })
|
|
} finally {
|
|
creatingReminder.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>
|