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>
This commit is contained in:
louispaulb 2026-05-21 21:01:40 -04:00
parent d6096fe1f8
commit 2b85735006

View File

@ -3,6 +3,14 @@
<div class="row items-center q-mb-md"> <div class="row items-center q-mb-md">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" /> <q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<div class="text-h5">Nouvelle campagne</div> <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> </div>
<q-stepper v-model="step" header-nav ref="stepper" color="primary" animated flat bordered class="bg-white"> <q-stepper v-model="step" header-nav ref="stepper" color="primary" animated flat bordered class="bg-white">
@ -212,10 +220,27 @@
</q-list> </q-list>
</q-expansion-item> </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-stepper-navigation>
<q-btn flat label="Retour" @click="step = 1" /> <q-btn flat label="Retour" @click="step = 1" />
<q-btn unelevated color="primary" :label="`Approuver — ${sendableCount} à envoyer`" icon-right="send" <q-space />
class="q-ml-sm" @click="step = 3" :disable="sendableCount === 0" /> <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-stepper-navigation>
</q-step> </q-step>
@ -236,19 +261,52 @@
<q-item><q-item-section side>Durée estimée</q-item-section><q-item-section> {{ estimatedMinutes }} 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-list>
</q-card-section> </q-card-section>
<q-card-section class="bg-orange-1 text-orange-9"> <q-card-section class="bg-red-1 text-red-9">
<q-icon name="info" /> L'envoi démarre dès que vous cliquez ci-dessous. <q-icon name="warning" /> <strong>Confirmation finale.</strong>
Vous serez redirigé vers la page de progression en temps réel. 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-section>
</q-card> </q-card>
<q-stepper-navigation> <q-stepper-navigation>
<q-btn flat label="Retour" @click="step = 2" /> <q-btn flat label="Retour modifier" icon="arrow_back" @click="step = 2" />
<q-btn unelevated color="primary" label="Lancer l'envoi" icon-right="send" <q-space />
class="q-ml-sm" :loading="sending" @click="launchSend" /> <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-stepper-navigation>
</q-step> </q-step>
</q-stepper> </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> </q-page>
</template> </template>
@ -256,7 +314,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { parseCsvs, createCampaign, sendCampaign } from 'src/api/campaigns' import { parseCsvs, createCampaign, sendCampaign, previewTemplate } from 'src/api/campaigns'
const $q = useQuasar() const $q = useQuasar()
const router = useRouter() const router = useRouter()
@ -344,6 +402,81 @@ function shortenUrl (u) {
return u.replace(/^https?:\/\//, '').slice(0, 28) + (u.length > 35 ? '…' : '') 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) { function readFile (file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const r = new FileReader() const r = new FileReader()