User confusion: the "Approuver — 3 à envoyer" button at the end of Step 2
had a send icon, suggesting it fired emails immediately. It actually
just navigated to Step 3 (the confirmation step). The current flow has
two consent moments (Step 2 approve → Step 3 launch) but the UI made
them look like one.
Three changes to address this:
1. Step 2 navigation button:
- Icon changed from 'send' to 'arrow_forward' — clearly "next step"
- Label changed from "Approuver — N à envoyer" to "Continuer — N prêts"
- Added tooltip explaining the send only happens at Step 3
2. Inline preview dialog:
- New "Aperçu du courriel" button in Step 2 (and Step 3)
- Opens a maximized dialog with an iframe rendering the actual template
via POST /campaigns/templates/:name/preview, using the first sendable
recipient's real data + the campaign params (amount, expiry, etc.)
- FR/EN toggle inside the dialog so the user can verify both templates
before launching a mixed-language campaign
- Defaults to the recipient's own language for first view
- Non-destructive — fires zero emails
3. Always-accessible "Éditer le template" link:
- Persistent button in the page header (visible all 3 steps)
- Plus secondary buttons in Step 2 + Step 3 action rows
- Opens the template editor in a NEW TAB so the wizard's state
(uploaded CSVs, parsed recipients) stays intact in the original
tab — the user can tweak the template, save, switch back, click
"Aperçu" to see the change, then continue with the send
4. Step 3 confirmation hardening:
- Banner color escalated from amber to red (this IS the point of no
return for actual delivery)
- Wrap the launch button click in a Quasar confirm dialog ("Envoyer
N courriel(s) maintenant ? Pas annulable.") — adds a third friction
point against accidental clicks
- Launch button is red (negative) — visually distinct from the green
navigation primaries to signal "destructive action ahead"
- Back-to-Step 2 button renamed "Retour modifier" with arrow_back
icon for clarity
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
562 lines
28 KiB
Vue
562 lines
28 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">Nouvelle campagne</div>
|
||
<q-space />
|
||
<!-- Always-accessible jump-to-editor link. Opens in new tab so the
|
||
wizard's uploaded files + parsed recipients stay intact. Useful
|
||
when the user wants to tweak the template mid-import. -->
|
||
<q-btn flat color="primary" icon="palette" label="Éditer le template (nouvel onglet)"
|
||
type="a" href="/ops/#/campaigns/templates/gift-email-fr" target="_blank">
|
||
<q-tooltip>S'ouvre dans un nouvel onglet — tes fichiers uploadés restent intacts ici</q-tooltip>
|
||
</q-btn>
|
||
</div>
|
||
|
||
<q-stepper v-model="step" header-nav ref="stepper" color="primary" animated flat bordered class="bg-white">
|
||
|
||
<!-- Step 1 — Upload + parameters ─────────────────────────────────── -->
|
||
<q-step :name="1" title="Fichiers + paramètres" icon="upload_file" :done="step > 1" :header-nav="step > 1">
|
||
<div class="row q-col-gutter-md">
|
||
<div class="col-12 col-md-6">
|
||
<q-card flat bordered>
|
||
<q-card-section>
|
||
<div class="text-subtitle1 q-mb-sm">1. Export Map CSV (brut)</div>
|
||
<div class="text-caption text-grey-7 q-mb-md">
|
||
Le fichier <code>selectionAdressesMap*.csv</code> tel qu'exporté de la sélection
|
||
d'adresses (pipe-delimited, préambule de 1 ligne accepté).
|
||
</div>
|
||
<q-file v-model="mapFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readMapFile">
|
||
<template v-slot:prepend><q-icon name="attach_file" /></template>
|
||
</q-file>
|
||
<div v-if="mapPreview" class="text-caption q-mt-sm text-grey-7">
|
||
✓ {{ countMapRows(mapPreview) }} lignes de contacts (préambule + header retirés)
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</div>
|
||
<div class="col-12 col-md-6">
|
||
<q-card flat bordered>
|
||
<q-card-section>
|
||
<div class="text-subtitle1 q-mb-sm">2. Shortlinks Giftbit CSV</div>
|
||
<div class="text-caption text-grey-7 q-mb-md">
|
||
Le fichier <code>giftbit-gifts-<id>.csv</code> retourné par
|
||
<code>create_giftbit_campaign.js</code> (colonnes: firstname, lastname, email,
|
||
gift_url, giftbit_uuid, gift_value_cents).
|
||
</div>
|
||
<q-file v-model="giftFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readGiftFile">
|
||
<template v-slot:prepend><q-icon name="attach_file" /></template>
|
||
</q-file>
|
||
<div v-if="giftPreview" class="text-caption q-mt-sm text-grey-7">
|
||
✓ {{ countGiftRows(giftPreview) }} cartes-cadeaux
|
||
<span v-if="giftFormatHint" class="text-grey-6">(format: {{ giftFormatHint }})</span>
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</div>
|
||
</div>
|
||
|
||
<q-card flat bordered class="q-mt-md">
|
||
<q-card-section>
|
||
<div class="text-subtitle1 q-mb-sm">Paramètres de la campagne</div>
|
||
<div class="row q-col-gutter-md">
|
||
<q-input v-model="params.name" label="Nom interne" outlined dense class="col-12 col-md-6" />
|
||
<q-input v-model="params.amount" label="Montant (affiché)" outlined dense class="col-6 col-md-3" placeholder="60 $" />
|
||
<q-input v-model.number="params.commitment_months" type="number" label="Engagement (mois)" outlined dense class="col-6 col-md-3" />
|
||
<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.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" />
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
|
||
<q-stepper-navigation>
|
||
<q-btn unelevated color="primary" label="Suivant — Aperçu" icon-right="arrow_forward"
|
||
:disable="!mapPreview || !giftPreview || parsing"
|
||
:loading="parsing" @click="goPreview" />
|
||
</q-stepper-navigation>
|
||
</q-step>
|
||
|
||
<!-- Step 2 — Preview matched send list ─────────────────────────── -->
|
||
<q-step :name="2" title="Aperçu et matching" icon="preview" :done="step > 2" :header-nav="step > 2">
|
||
<!-- Counter strip — at a glance: total / paired / sendable / unmatched -->
|
||
<div class="row q-col-gutter-sm q-mb-md">
|
||
<q-card flat bordered class="col-6 col-md">
|
||
<q-card-section class="text-center q-pa-sm">
|
||
<div class="text-h5">{{ recipients.length }}</div>
|
||
<div class="text-caption text-grey-7">Paires contact ↔ lien</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
<q-card flat bordered class="col-6 col-md" :class="{ 'bg-positive-1': sendableCount > 0 }">
|
||
<q-card-section class="text-center q-pa-sm">
|
||
<div class="text-h5 text-positive">{{ sendableCount }}</div>
|
||
<div class="text-caption text-grey-7">À envoyer</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
<q-card flat bordered class="col-6 col-md">
|
||
<q-card-section class="text-center q-pa-sm">
|
||
<div class="text-h5 text-positive">{{ matchedCount }}</div>
|
||
<div class="text-caption text-grey-7">Client lié</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
<q-card flat bordered class="col-6 col-md">
|
||
<q-card-section class="text-center q-pa-sm">
|
||
<div class="text-h5 text-warning">{{ unmatchedCount }}</div>
|
||
<div class="text-caption text-grey-7">Sans client</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
<q-card flat bordered class="col-6 col-md" v-if="unpairedContacts.length">
|
||
<q-card-section class="text-center q-pa-sm">
|
||
<div class="text-h5 text-negative">{{ unpairedContacts.length }}</div>
|
||
<div class="text-caption text-grey-7">Sans lien</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
<q-card flat bordered class="col-6 col-md" v-if="unusedGifts.length">
|
||
<q-card-section class="text-center q-pa-sm">
|
||
<div class="text-h5 text-orange">{{ unusedGifts.length }}</div>
|
||
<div class="text-caption text-grey-7">Liens en surplus</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</div>
|
||
|
||
<!-- Imbalance banner: explicit explanation of what the imbalance means -->
|
||
<q-banner v-if="unpairedContacts.length || unusedGifts.length" class="bg-orange-1 text-orange-9 q-mb-md" rounded>
|
||
<template v-slot:avatar><q-icon name="warning" /></template>
|
||
<div v-if="unpairedContacts.length">
|
||
<strong>{{ unpairedContacts.length }} contact(s) sans lien-cadeau</strong> —
|
||
ils n'apparaissent PAS dans la liste d'envoi ci-dessous et ne recevront rien.
|
||
Pour les inclure, acquérir {{ unpairedContacts.length }} liens supplémentaires
|
||
chez Giftbit et re-uploader le fichier.
|
||
</div>
|
||
<div v-if="unusedGifts.length" :class="unpairedContacts.length ? 'q-mt-xs' : ''">
|
||
<strong>{{ unusedGifts.length }} lien(s) Giftbit non utilisés</strong> —
|
||
il y a plus de cartes-cadeaux que de contacts. Le surplus sera perdu si
|
||
la campagne est envoyée tel quel.
|
||
</div>
|
||
</q-banner>
|
||
|
||
<!-- ✓ Paired recipients (will be sent) ────────────────────────── -->
|
||
<div class="text-subtitle1 q-mb-xs">
|
||
<q-icon name="link" /> Association contact ↔ lien-cadeau
|
||
<span class="text-caption text-grey-7">— vérifier avant d'approuver</span>
|
||
</div>
|
||
<q-table
|
||
:rows="recipients" :columns="recipientColumns" row-key="row_index"
|
||
flat bordered dense :pagination="{ rowsPerPage: 25 }"
|
||
:rows-per-page-options="[10, 25, 50, 100, 0]"
|
||
class="q-mb-md"
|
||
>
|
||
<template v-slot:body-cell-row_index="props">
|
||
<q-td :props="props" class="text-grey-6">#{{ props.row.row_index }}</q-td>
|
||
</template>
|
||
<template v-slot:body-cell-gift_url="props">
|
||
<q-td :props="props">
|
||
<a :href="props.row.gift_url" target="_blank" style="color:var(--q-primary); font-family:monospace; font-size:0.78rem">
|
||
{{ shortenUrl(props.row.gift_url) }}
|
||
</a>
|
||
</q-td>
|
||
</template>
|
||
<template v-slot:body-cell-language="props">
|
||
<q-td :props="props">
|
||
<!-- Clickable chip — toggles FR ↔ EN inline. Default value comes
|
||
from the matched Customer.language (or 'fr' for unmatched).
|
||
The send-worker picks the template by this value at send
|
||
time, so flipping here changes which template gets used. -->
|
||
<q-chip dense clickable size="sm"
|
||
:color="props.row.language === 'en' ? 'blue-grey-6' : 'primary'"
|
||
text-color="white"
|
||
:label="(props.row.language || 'fr').toUpperCase()"
|
||
@click="props.row.language = props.row.language === 'en' ? 'fr' : 'en'">
|
||
<q-tooltip>Cliquer pour basculer FR ↔ EN</q-tooltip>
|
||
</q-chip>
|
||
</q-td>
|
||
</template>
|
||
<template v-slot:body-cell-match="props">
|
||
<q-td :props="props">
|
||
<q-chip v-if="props.row.customer_id" dense color="positive" text-color="white" size="sm" :label="props.row.match_method" />
|
||
<q-chip v-else dense color="warning" text-color="white" size="sm" label="non lié" />
|
||
</q-td>
|
||
</template>
|
||
<template v-slot:body-cell-actions="props">
|
||
<q-td :props="props" class="text-right">
|
||
<q-btn flat dense size="sm" :icon="props.row.excluded ? 'add_circle' : 'block'"
|
||
:color="props.row.excluded ? 'positive' : 'negative'"
|
||
@click="props.row.excluded = !props.row.excluded">
|
||
<q-tooltip>{{ props.row.excluded ? 'Inclure' : 'Exclure' }}</q-tooltip>
|
||
</q-btn>
|
||
</q-td>
|
||
</template>
|
||
</q-table>
|
||
|
||
<!-- ⚠ Contacts that have NO gift-url (won't be sent) ─────────── -->
|
||
<q-expansion-item v-if="unpairedContacts.length" expand-separator
|
||
icon="person_off" :label="`${unpairedContacts.length} contact(s) sans lien-cadeau (ne recevront pas)`"
|
||
header-class="bg-red-1 text-red-9">
|
||
<q-table
|
||
:rows="unpairedContacts" :columns="unpairedColumns" row-key="row_index"
|
||
flat dense hide-bottom :pagination="{ rowsPerPage: 0 }"
|
||
>
|
||
<template v-slot:body-cell-row_index="props">
|
||
<q-td :props="props" class="text-grey-6">#{{ props.row.row_index }}</q-td>
|
||
</template>
|
||
</q-table>
|
||
</q-expansion-item>
|
||
|
||
<!-- ⓘ Unused gift URLs (extra capacity) ────────────────────────── -->
|
||
<q-expansion-item v-if="unusedGifts.length" expand-separator
|
||
icon="card_giftcard"
|
||
:label="`${unusedGifts.length} lien(s) Giftbit non utilisés`"
|
||
header-class="bg-orange-1 text-orange-9"
|
||
class="q-mt-sm">
|
||
<q-list dense>
|
||
<q-item v-for="g in unusedGifts" :key="g.gift_url">
|
||
<q-item-section side class="text-grey-6">#{{ g.row_index }}</q-item-section>
|
||
<q-item-section>
|
||
<a :href="g.gift_url" target="_blank" style="color:var(--q-primary); font-family:monospace; font-size:0.85rem">{{ g.gift_url }}</a>
|
||
</q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
</q-expansion-item>
|
||
|
||
<!-- Action row: preview + edit template are quick-access utilities,
|
||
both non-destructive. The primary action is "Continuer" which
|
||
moves to Step 3 (still NOT the send — Step 3 has its own
|
||
explicit launch button). Icon changed from 'send' (confusing,
|
||
looked like it fired) to 'arrow_forward'. -->
|
||
<q-stepper-navigation>
|
||
<q-btn flat label="Retour" @click="step = 1" />
|
||
<q-space />
|
||
<q-btn flat color="primary" icon="visibility" label="Aperçu du courriel"
|
||
:disable="!firstPreviewable" @click="openPreview" class="q-mr-sm">
|
||
<q-tooltip>Voir le rendu du courriel avec les vraies données du destinataire #1 (n'envoie rien)</q-tooltip>
|
||
</q-btn>
|
||
<q-btn flat color="primary" icon="palette" label="Éditer le template"
|
||
type="a" :href="editorHref" target="_blank" class="q-mr-sm">
|
||
<q-tooltip>Ouvre l'éditeur dans un nouvel onglet — ton import reste ici intact</q-tooltip>
|
||
</q-btn>
|
||
<q-btn unelevated color="primary" :label="`Continuer — ${sendableCount} prêts`"
|
||
icon-right="arrow_forward"
|
||
@click="step = 3" :disable="sendableCount === 0">
|
||
<q-tooltip>Va à l'étape de confirmation finale. L'envoi ne démarre qu'au clic sur "Lancer l'envoi" de l'étape 3.</q-tooltip>
|
||
</q-btn>
|
||
</q-stepper-navigation>
|
||
</q-step>
|
||
|
||
<!-- Step 3 — Confirm + send ──────────────────────────────────────── -->
|
||
<q-step :name="3" title="Confirmation" icon="send">
|
||
<q-card flat bordered>
|
||
<q-card-section>
|
||
<div class="text-subtitle1 q-mb-sm">Récapitulatif</div>
|
||
<q-list dense>
|
||
<q-item><q-item-section side>Nom</q-item-section><q-item-section>{{ params.name }}</q-item-section></q-item>
|
||
<q-item><q-item-section side>Destinataires actifs</q-item-section><q-item-section>{{ sendableCount }} (sur {{ recipients.length }} paires ; {{ excludedCount }} exclus, {{ unpairedContacts.length }} sans lien)</q-item-section></q-item>
|
||
<q-item><q-item-section side>Répartition par langue</q-item-section><q-item-section>{{ langBreakdown }}</q-item-section></q-item>
|
||
<q-item><q-item-section side>Montant affiché</q-item-section><q-item-section>{{ params.amount }}</q-item-section></q-item>
|
||
<q-item><q-item-section side>Engagement</q-item-section><q-item-section>{{ params.commitment_months }} mois</q-item-section></q-item>
|
||
<q-item><q-item-section side>Sujet</q-item-section><q-item-section>{{ params.subject }}</q-item-section></q-item>
|
||
<q-item><q-item-section side>Expéditeur</q-item-section><q-item-section>{{ params.from }}</q-item-section></q-item>
|
||
<q-item><q-item-section side>Throttle</q-item-section><q-item-section>{{ params.throttle_ms }} ms entre envois (≈ {{ Math.round((60 / (params.throttle_ms / 1000)) || 0) }} emails/min)</q-item-section></q-item>
|
||
<q-item><q-item-section side>Durée estimée</q-item-section><q-item-section>≈ {{ estimatedMinutes }} min</q-item-section></q-item>
|
||
</q-list>
|
||
</q-card-section>
|
||
<q-card-section class="bg-red-1 text-red-9">
|
||
<q-icon name="warning" /> <strong>Confirmation finale.</strong>
|
||
L'envoi démarre dès le clic sur <em>"Lancer l'envoi maintenant"</em>.
|
||
Tu seras redirigé vers la page de progression temps réel.
|
||
</q-card-section>
|
||
</q-card>
|
||
<q-stepper-navigation>
|
||
<q-btn flat label="Retour modifier" icon="arrow_back" @click="step = 2" />
|
||
<q-space />
|
||
<q-btn flat color="primary" icon="visibility" label="Aperçu courriel" :disable="!firstPreviewable" @click="openPreview" class="q-mr-sm" />
|
||
<q-btn unelevated color="negative" label="Lancer l'envoi maintenant" icon-right="send"
|
||
:loading="sending" @click="confirmAndLaunch" />
|
||
</q-stepper-navigation>
|
||
</q-step>
|
||
|
||
</q-stepper>
|
||
|
||
<!-- Preview dialog — renders the actual template with the first sendable
|
||
recipient's data + the campaign params. Lets the user verify the
|
||
visual + content WITHOUT firing any emails. Toggleable FR/EN since
|
||
a mixed-language campaign would send both templates. -->
|
||
<q-dialog v-model="previewOpen" maximized persistent>
|
||
<q-card class="bg-grey-2">
|
||
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
|
||
<q-icon name="visibility" class="q-mr-sm" />
|
||
<q-toolbar-title>
|
||
Aperçu du courriel
|
||
<span v-if="previewRecipient" class="text-caption text-grey-7">
|
||
· destinataire #{{ previewRecipient.row_index }} {{ previewRecipient.firstname }} {{ previewRecipient.lastname }}
|
||
</span>
|
||
</q-toolbar-title>
|
||
<q-btn-toggle v-model="previewLang" :options="[{label:'🇫🇷 FR', value:'fr'},{label:'🇺🇸 EN', value:'en'}]"
|
||
dense unelevated toggle-color="primary" @update:model-value="renderPreview" />
|
||
<q-btn flat icon="open_in_new" :href="editorHref" target="_blank" class="q-mx-sm">
|
||
<q-tooltip>Éditer dans un nouvel onglet</q-tooltip>
|
||
</q-btn>
|
||
<q-btn flat dense round icon="close" @click="previewOpen = false" />
|
||
</q-toolbar>
|
||
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9">
|
||
<q-spinner size="sm" /> Rendu en cours…
|
||
</q-banner>
|
||
<q-card-section class="q-pa-md" style="height: calc(100vh - 60px); overflow:hidden">
|
||
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;"></iframe>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
</q-page>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { useQuasar } from 'quasar'
|
||
import { parseCsvs, createCampaign, sendCampaign, previewTemplate } from 'src/api/campaigns'
|
||
|
||
const $q = useQuasar()
|
||
const router = useRouter()
|
||
const step = ref(1)
|
||
|
||
const mapFile = ref(null)
|
||
const giftFile = ref(null)
|
||
const mapPreview = ref('')
|
||
const giftPreview = ref('')
|
||
|
||
const params = ref({
|
||
name: `Campagne ${new Date().toISOString().slice(0,10)}`,
|
||
amount: '60 $',
|
||
commitment_months: 3,
|
||
subject: '🎁 Un cadeau pour vous, de la part de TARGO',
|
||
from: 'TARGO <support@targointernet.com>',
|
||
expiry: '',
|
||
throttle_ms: 600,
|
||
multi: 'first',
|
||
})
|
||
const multiOptions = [
|
||
{ label: '1er email seulement (1 cadeau/foyer)', value: 'first' },
|
||
{ label: 'Séparer en 2 rangées (1 cadeau/personne)', value: 'split' },
|
||
{ label: 'Ignorer les couples', value: 'skip' },
|
||
]
|
||
|
||
const parsing = ref(false)
|
||
const sending = ref(false)
|
||
const recipients = ref([])
|
||
const unpairedContacts = ref([])
|
||
const unusedGifts = ref([])
|
||
|
||
// row_index (#1, #2, ...) is the source-CSV position — invaluable for the
|
||
// user to cross-reference what they see here against the file they uploaded.
|
||
// gift_url is rendered as a clickable short label so contact↔link pairing
|
||
// can be eyeballed at a glance and the user can click through to verify
|
||
// the shortlink works.
|
||
const recipientColumns = [
|
||
{ name: 'row_index', label: '#', field: 'row_index', align: 'left' },
|
||
{ 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: 'gift_url', label: 'Lien-cadeau', field: 'gift_url', align: 'left' },
|
||
{ name: 'language', label: 'Langue', field: 'language', align: 'left' },
|
||
{ name: 'match', label: 'Match', field: 'match_method', align: 'left' },
|
||
{ name: 'customer_name', label: 'Client ERPNext', field: 'customer_name', align: 'left' },
|
||
{ name: 'actions', label: '', field: '', align: 'right' },
|
||
]
|
||
|
||
const unpairedColumns = [
|
||
{ name: 'row_index', label: '#', field: 'row_index', align: 'left' },
|
||
{ 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: 'postal_code', label: 'Code postal', field: 'postal_code', align: 'left' },
|
||
]
|
||
|
||
const matchedCount = computed(() => recipients.value.filter(r => r.customer_id).length)
|
||
const unmatchedCount = computed(() => recipients.value.filter(r => !r.customer_id).length)
|
||
const excludedCount = computed(() => recipients.value.filter(r => r.excluded).length)
|
||
// Net number of emails that will actually be fired off (paired AND not excluded)
|
||
const sendableCount = computed(() => recipients.value.filter(r => !r.excluded && r.gift_url).length)
|
||
|
||
// FR / EN breakdown of the sendable recipients — useful preview before launch
|
||
// so the user knows which template will actually be used and how many.
|
||
const langBreakdown = computed(() => {
|
||
const counts = {}
|
||
for (const r of recipients.value) {
|
||
if (r.excluded || !r.gift_url) continue
|
||
const lang = (r.language || 'fr').toLowerCase()
|
||
counts[lang] = (counts[lang] || 0) + 1
|
||
}
|
||
const parts = Object.entries(counts).map(([l, n]) => `${n} × ${l.toUpperCase()}`)
|
||
return parts.length ? parts.join(', ') : '—'
|
||
})
|
||
const estimatedMinutes = computed(() => {
|
||
const per = (params.value.throttle_ms || 600) / 1000
|
||
return Math.max(1, Math.round((sendableCount.value * per) / 60))
|
||
})
|
||
|
||
function shortenUrl (u) {
|
||
if (!u) return ''
|
||
return u.replace(/^https?:\/\//, '').slice(0, 28) + (u.length > 35 ? '…' : '')
|
||
}
|
||
|
||
// ── Preview dialog ──────────────────────────────────────────────────────────
|
||
// Renders the email through the hub's /campaigns/templates/:name/preview
|
||
// endpoint, using the first sendable recipient's data + the current campaign
|
||
// params. Non-destructive — no emails are fired by this action.
|
||
const previewOpen = ref(false)
|
||
const previewLoading = ref(false)
|
||
const previewHtmlContent = ref('')
|
||
const previewLang = ref('fr')
|
||
const previewRecipient = ref(null)
|
||
|
||
// First recipient that's actually going to be sent — used as the preview
|
||
// sample so the user sees real data, not synthetic placeholders.
|
||
const firstPreviewable = computed(() =>
|
||
recipients.value.find(r => !r.excluded && r.gift_url) || null
|
||
)
|
||
|
||
// Link to the template editor for the relevant language. Always opens
|
||
// in a new tab so the user's in-progress wizard state is preserved.
|
||
const editorHref = computed(() =>
|
||
`/ops/#/campaigns/templates/gift-email-${previewLang.value}`
|
||
)
|
||
|
||
async function openPreview () {
|
||
const r = firstPreviewable.value
|
||
if (!r) return
|
||
previewRecipient.value = r
|
||
// Default preview language to the recipient's actual language so the user
|
||
// first sees what THIS recipient will receive
|
||
previewLang.value = (r.language || 'fr').toLowerCase().split('-')[0]
|
||
previewOpen.value = true
|
||
await renderPreview()
|
||
}
|
||
|
||
async function renderPreview () {
|
||
if (!previewRecipient.value) return
|
||
previewLoading.value = true
|
||
try {
|
||
const r = previewRecipient.value
|
||
const vars = {
|
||
firstname: r.firstname || (previewLang.value === 'en' ? 'dear customer' : 'cher client'),
|
||
lastname: r.lastname || '',
|
||
email: r.email,
|
||
description: r.civic_address || '',
|
||
gift_url: r.gift_url,
|
||
amount: params.value.amount,
|
||
expiry: params.value.expiry,
|
||
commitment_months: params.value.commitment_months,
|
||
year: new Date().getFullYear(),
|
||
}
|
||
const res = await previewTemplate(`gift-email-${previewLang.value}`, { vars })
|
||
previewHtmlContent.value = res.rendered || ''
|
||
} catch (e) {
|
||
$q.notify({ type: 'negative', message: 'Aperçu impossible: ' + e.message })
|
||
} finally {
|
||
previewLoading.value = false
|
||
}
|
||
}
|
||
|
||
// Wrapper around launchSend that confirms one last time before firing. The
|
||
// Step 3 page is already a "confirmation step", but this dialog adds one
|
||
// final friction so accidental clicks don't fire 200 emails.
|
||
function confirmAndLaunch () {
|
||
$q.dialog({
|
||
title: 'Envoyer maintenant ?',
|
||
message: `Cette action enverra <strong>${sendableCount.value} courriel(s)</strong>
|
||
via Mailjet immédiatement. Pas annulable une fois démarré.`,
|
||
html: true,
|
||
persistent: true,
|
||
ok: { label: 'Oui, envoyer', color: 'negative', icon: 'send', unelevated: true },
|
||
cancel: { label: 'Annuler', flat: true },
|
||
}).onOk(() => {
|
||
launchSend()
|
||
})
|
||
}
|
||
|
||
function readFile (file) {
|
||
return new Promise((resolve, reject) => {
|
||
const r = new FileReader()
|
||
r.onload = () => resolve(r.result)
|
||
r.onerror = reject
|
||
r.readAsText(file, 'utf-8')
|
||
})
|
||
}
|
||
async function readMapFile (file) { if (file) mapPreview.value = await readFile(file) }
|
||
async function readGiftFile (file) { if (file) giftPreview.value = await readFile(file) }
|
||
|
||
// Counts must mirror the backend parser exactly so the user sees the same
|
||
// numbers in the preview as what Step 2 will receive.
|
||
|
||
// Map CSV format: 1-line title preamble + header row + N data rows.
|
||
// Returns N (the # of contact lines, excluding the preamble and header).
|
||
function countMapRows (text) {
|
||
if (!text) return 0
|
||
const lines = text.split(/\r?\n/).filter(l => l.trim())
|
||
// -1 for preamble, -1 for header
|
||
return Math.max(0, lines.length - 2)
|
||
}
|
||
|
||
// Giftbit CSV: TWO formats
|
||
// 1. "Link Order" — headerless, one URL per line (each URL = 1 gift)
|
||
// 2. "Campaign export" — header row + N data rows (-1 for header)
|
||
// Detect like the backend: first non-empty line is a bare URL with no
|
||
// separator → no header.
|
||
function countGiftRows (text) {
|
||
if (!text) return 0
|
||
const cleaned = text.replace(/^/, '').trim()
|
||
if (!cleaned) return 0
|
||
const firstLine = cleaned.split(/\r?\n/, 1)[0].trim()
|
||
const isLinkOrder = /^https?:\/\/\S+$/.test(firstLine) &&
|
||
!firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')
|
||
const lines = cleaned.split(/\r?\n/).filter(l => l.trim())
|
||
return isLinkOrder ? lines.length : Math.max(0, lines.length - 1)
|
||
}
|
||
|
||
// Show "Link Order" or "Campaign export" hint next to the gift count
|
||
const giftFormatHint = computed(() => {
|
||
if (!giftPreview.value) return ''
|
||
const firstLine = giftPreview.value.replace(/^/, '').trim().split(/\r?\n/, 1)[0].trim()
|
||
if (/^https?:\/\/\S+$/.test(firstLine) && !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')) {
|
||
return 'Link Order'
|
||
}
|
||
return 'Campaign export'
|
||
})
|
||
|
||
async function goPreview () {
|
||
if (!mapPreview.value || !giftPreview.value) return
|
||
parsing.value = true
|
||
try {
|
||
const r = await parseCsvs({ map_csv: mapPreview.value, giftbit_csv: giftPreview.value, multi: params.value.multi })
|
||
recipients.value = r.recipients || []
|
||
unpairedContacts.value = r.unpaired_contacts || []
|
||
unusedGifts.value = r.unused_gifts || []
|
||
step.value = 2
|
||
} catch (e) {
|
||
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
|
||
} finally {
|
||
parsing.value = false
|
||
}
|
||
}
|
||
|
||
async function launchSend () {
|
||
sending.value = true
|
||
try {
|
||
const saved = await createCampaign({
|
||
name: params.value.name,
|
||
params: { ...params.value },
|
||
recipients: recipients.value,
|
||
})
|
||
await sendCampaign(saved.id)
|
||
router.push(`/campaigns/${saved.id}`)
|
||
} catch (e) {
|
||
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
||
} finally {
|
||
sending.value = false
|
||
}
|
||
}
|
||
</script>
|