gigafibre-fsm/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
louispaulb ea780e61ab fix(ops/campaigns): clarify Step 2 actions + add inline preview + jump-to-editor
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>
2026-05-21 21:01:40 -04:00

562 lines
28 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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-&lt;id&gt;.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>