feat(ops/campaigns): UI module for gift campaigns + GrapesJS template editor

New /campaigns section in the ops SPA, gated by manage_users (proxy until a
dedicated manage_campaigns capability is added).

Pages (apps/ops/src/modules/campaigns/pages/):

- CampaignsListPage: table of all campaigns with status chip + progress
  bar (sent/total, with fail count), "Nouvelle campagne" + "Éditer le
  template" buttons. Empty state with onboarding copy.

- CampaignNewPage: 3-step Quasar Stepper wizard.
  Step 1 — upload Map CSV + Giftbit CSV, configure params (name, amount,
           commitment_months, sender, throttle, multi-email handling).
  Step 2 — preview the matched send list from POST /campaigns/parse, with
           counters (matched/unmatched/excluded), per-row match-method
           chip, and exclude/include toggle. Banner warns when CSVs are
           mis-aligned (leftover gifts or contacts).
  Step 3 — confirmation recap with estimated send duration, then fire
           POST /campaigns + POST /campaigns/:id/send and redirect to the
           live detail page.

- CampaignDetailPage: per-recipient table with status chips updated live
  via EventSource on the campaign:<id> SSE topic. Counters bar
  (envoyés / cliqués / queued / échecs / non envoyés), progress bar,
  per-row customer-link badge with deep-link into /clients/<id>.
  Auto-subscribes to SSE when status is draft|sending; "Lancer l'envoi"
  button for draft campaigns.

- TemplateEditorPage: GrapesJS-based visual editor for the campaign
  templates. Three view modes (Visuel / HTML / Aperçu) — the HTML mode
  is the fallback for our table-heavy hand-crafted template that
  GrapesJS-preset-newsletter may parse imperfectly. Aperçu mode calls
  POST /campaigns/templates/:name/preview on the hub for live variable
  substitution. Custom GrapesJS blocks under "Variables" category for
  drag-drop insertion of {{firstname}}, {{amount}}, {{gift_url}},
  {{description}}, {{expiry}}, {{commitment_months}}. Saves via PUT
  with hub-side backup of the previous version.

Wiring:

- api/campaigns.js: hubFetch wrapper, exports parseCsvs / createCampaign
  / listCampaigns / getCampaign / updateCampaign / sendCampaign +
  campaignSseUrl(id) for EventSource subscription, + listTemplates /
  getTemplate / saveTemplate / previewTemplate for the editor.

- router/index.js: three new routes under /campaigns. The
  /campaigns/templates/:name? route is positioned ABOVE /campaigns/:id
  to prevent the wildcard from catching template paths.

- config/nav.js + layouts/MainLayout.vue: "Campagnes" sidebar entry with
  Lucide Gift icon.

- package.json: grapesjs + grapesjs-preset-newsletter dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-21 19:08:04 -04:00
parent 5d763f12ff
commit 611f4ed5a6
10 changed files with 995 additions and 2 deletions

View File

@ -12,6 +12,8 @@
"@twilio/voice-sdk": "^2.18.1",
"chart.js": "^4.5.1",
"cytoscape": "^3.33.2",
"grapesjs": "^0.22.16",
"grapesjs-preset-newsletter": "^1.0.2",
"idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7",
@ -2661,6 +2663,16 @@
"node": ">= 12"
}
},
"node_modules/@types/backbone": {
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.15.tgz",
"integrity": "sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==",
"license": "MIT",
"dependencies": {
"@types/jquery": "*",
"@types/underscore": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@ -2781,6 +2793,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jquery": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.0.tgz",
"integrity": "sha512-Z+to+A2VkaHq1DfI2oSwsoCdhCHMpTSgjWzNcbNlRGYzksDBpPUgEcAL+RQjOBJRaLoEAOHXxqDGBVP+BblBwg==",
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -2862,6 +2880,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/underscore": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz",
"integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==",
"license": "MIT"
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@ -3373,6 +3397,26 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/backbone": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/backbone/-/backbone-1.4.1.tgz",
"integrity": "sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==",
"license": "MIT",
"dependencies": {
"underscore": ">=1.8.3"
}
},
"node_modules/backbone-undo": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/backbone-undo/-/backbone-undo-0.2.6.tgz",
"integrity": "sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"dependencies": {
"backbone": ">=1.0.0",
"underscore": ">=1.4.4"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -3870,6 +3914,18 @@
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "5.63.0",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz",
"integrity": "sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==",
"license": "MIT"
},
"node_modules/codemirror-formatting": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/codemirror-formatting/-/codemirror-formatting-1.0.0.tgz",
"integrity": "sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==",
"license": "MIT"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5770,6 +5826,28 @@
"dev": true,
"license": "ISC"
},
"node_modules/grapesjs": {
"version": "0.22.16",
"resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.16.tgz",
"integrity": "sha512-kCfphgpC7pqJPuMYmIhMR6ueyB3+V67isdpMZOvmuGeWDMomkgzqRWOMH3matfdqIJW7LUivHZo9GeyVQAGmLw==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/backbone": "1.4.15",
"backbone": "1.4.1",
"backbone-undo": "0.2.6",
"codemirror": "5.63.0",
"codemirror-formatting": "1.0.0",
"html-entities": "~1.4.0",
"promise-polyfill": "8.3.0",
"underscore": "1.13.8"
}
},
"node_modules/grapesjs-preset-newsletter": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/grapesjs-preset-newsletter/-/grapesjs-preset-newsletter-1.0.2.tgz",
"integrity": "sha512-z8KJ1ZrTXfASSJZ/tHOcnpcWu4AMr2F/ZfQit+QjimNi3UGowwl7+Yjefuh3R7lbDTrXMMaxhCannCaJo/kPJw==",
"license": "BSD-3-Clause"
},
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@ -5871,6 +5949,12 @@
"node": ">= 0.4"
}
},
"node_modules/html-entities": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
"integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==",
"license": "MIT"
},
"node_modules/html-minifier-terser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
@ -7687,6 +7771,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/promise-polyfill": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -9220,6 +9310,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",

View File

@ -14,6 +14,8 @@
"@twilio/voice-sdk": "^2.18.1",
"chart.js": "^4.5.1",
"cytoscape": "^3.33.2",
"grapesjs": "^0.22.16",
"grapesjs-preset-newsletter": "^1.0.2",
"idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7",

View File

@ -0,0 +1,105 @@
/**
* api/campaigns.js Client for Hub /campaigns endpoints.
*
* Mirrors services/targo-hub/lib/campaigns.js. All gift-campaign requests
* go through the Hub which handles ERPNext auth + Mailjet send + SSE
* progress broadcast.
*
* Functions:
* parseCsvs({ map_csv, giftbit_csv, multi }) preview matched send list
* createCampaign({ name, params, recipients }) save + return id
* listCampaigns() summaries
* getCampaign(id) full detail
* updateCampaign(id, patch) edit recipients/params
* sendCampaign(id) fire background worker
* campaignSseUrl(id) SSE URL for live updates
*/
import { HUB_URL } from 'src/config/hub'
async function hubFetch (path, { method = 'GET', body } = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body) opts.body = JSON.stringify(body)
const res = await fetch(`${HUB_URL}${path}`, opts)
const text = await res.text()
let data
try { data = text ? JSON.parse(text) : {} }
catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
if (!res.ok) {
const msg = data.error || `HTTP ${res.status}`
const err = new Error(msg)
err.status = res.status
throw err
}
return data
}
export function parseCsvs ({ map_csv, giftbit_csv, multi = 'first' }) {
return hubFetch('/campaigns/parse', {
method: 'POST',
body: { map_csv, giftbit_csv, multi },
})
}
export function createCampaign ({ name, params, recipients }) {
return hubFetch('/campaigns', {
method: 'POST',
body: { name, params, recipients },
})
}
export function listCampaigns () {
return hubFetch('/campaigns').then(r => r.campaigns || [])
}
export function getCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`)
}
export function updateCampaign (id, patch) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: patch,
})
}
export function sendCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}/send`, {
method: 'POST',
})
}
// ── Template editing (used by the GrapesJS editor page) ─────────────────────
export function listTemplates () {
return hubFetch('/campaigns/templates').then(r => r.templates || [])
}
export function getTemplate (name) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`)
}
export function saveTemplate (name, html) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, {
method: 'PUT',
body: { html },
})
}
export function previewTemplate (name, { html, vars } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, {
method: 'POST',
body: { html, vars },
})
}
/**
* Returns the URL for the SSE channel of one campaign. The Hub broadcasts on
* topic `campaign:<id>` so we subscribe to that single topic. Use with:
* const es = new EventSource(campaignSseUrl(id))
* es.addEventListener('recipient-update', ev => { ... })
* es.addEventListener('campaign-done', ev => { ... })
*/
export function campaignSseUrl (id) {
return `${HUB_URL}/sse?topics=campaign:${encodeURIComponent(id)}`
}

View File

@ -7,6 +7,7 @@ export const navItems = [
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
]

View File

@ -123,13 +123,13 @@ import { listDocs } from 'src/api/erp'
import { navItems as allNavItems } from 'src/config/nav'
import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
Settings, LogOut, PanelLeftOpen, PanelLeftClose,
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
} from 'lucide-vue-next'
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
import { useConversations } from 'src/composables/useConversations'
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
const { panelOpen, activeCount: convCount } = useConversations()
function toggleConvPanel () { panelOpen.value = !panelOpen.value }

View File

@ -0,0 +1,194 @@
<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?.status === 'draft'" unelevated color="primary" icon="send" label="Lancer l'envoi"
:loading="resending" @click="relaunch" />
</div>
<!-- Counters bar -->
<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</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-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-td>
</template>
<template v-slot:body-cell-error="props">
<q-td :props="props">
<span v-if="props.row.error" class="text-negative text-caption">{{ props.row.error }}</span>
</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>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { getCampaign, sendCampaign, campaignSseUrl } from 'src/api/campaigns'
const route = useRoute()
const $q = useQuasar()
const id = route.params.id
const campaign = ref(null)
const loading = ref(true)
const resending = ref(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 ? '…' : '') }
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
})
}
async function relaunch () {
resending.value = true
try {
await sendCampaign(id)
await load()
subscribeSse()
} catch (e) {
$q.notify({ type: 'negative', message: e.message })
} finally {
resending.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>

View File

@ -0,0 +1,268 @@
<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>
</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">
{{ Math.max(0, (mapPreview.match(/\n/g)||[]).length - 1) }} lignes lues
</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">
{{ Math.max(0, (giftPreview.match(/\n/g)||[]).length - 1) }} cartes-cadeaux
</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">
<div class="row q-col-gutter-md q-mb-md">
<q-card flat bordered class="col-12 col-md-3">
<q-card-section class="text-center">
<div class="text-h4">{{ recipients.length }}</div>
<div class="text-caption text-grey-7">Total destinataires</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-12 col-md-3">
<q-card-section class="text-center">
<div class="text-h4 text-positive">{{ matchedCount }}</div>
<div class="text-caption text-grey-7">Client ERPNext lié</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-12 col-md-3">
<q-card-section class="text-center">
<div class="text-h4 text-warning">{{ unmatchedCount }}</div>
<div class="text-caption text-grey-7">Non liés (review)</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-12 col-md-3">
<q-card-section class="text-center">
<div class="text-h4 text-grey-7">{{ excludedCount }}</div>
<div class="text-caption text-grey-7">Exclus</div>
</q-card-section>
</q-card>
</div>
<q-banner v-if="parseInfo.leftover_gifts || parseInfo.leftover_contacts" class="bg-orange-1 text-orange-9 q-mb-md" rounded>
<template v-slot:avatar><q-icon name="warning" /></template>
Désalignement détecté: {{ parseInfo.leftover_gifts }} carte(s) sans contact,
{{ parseInfo.leftover_contacts }} contact(s) sans carte. Vérifier l'ordre des CSV.
</q-banner>
<q-table
:rows="recipients" :columns="recipientColumns" row-key="email"
flat bordered dense :pagination="{ rowsPerPage: 25 }"
:rows-per-page-options="[10, 25, 50, 100, 0]"
>
<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>
<q-stepper-navigation>
<q-btn flat label="Retour" @click="step = 1" />
<q-btn unelevated color="primary" label="Confirmer et envoyer" icon-right="send"
class="q-ml-sm" @click="step = 3" :disable="recipients.length === 0" />
</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>{{ recipients.length - excludedCount }} (sur {{ recipients.length }} totaux)</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-orange-1 text-orange-9">
<q-icon name="info" /> L'envoi démarre dès que vous cliquez ci-dessous.
Vous serez redirigé vers la page de progression en temps réel.
</q-card-section>
</q-card>
<q-stepper-navigation>
<q-btn flat label="Retour" @click="step = 2" />
<q-btn unelevated color="primary" label="Lancer l'envoi" icon-right="send"
class="q-ml-sm" :loading="sending" @click="launchSend" />
</q-stepper-navigation>
</q-step>
</q-stepper>
</q-page>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { parseCsvs, createCampaign, sendCampaign } 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 parseInfo = ref({ leftover_gifts: 0, leftover_contacts: 0 })
const recipientColumns = [
{ 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' },
{ name: 'match', label: 'Match client', field: 'match_method', align: 'left' },
{ name: 'customer_name', label: 'Client ERPNext', field: 'customer_name', align: 'left' },
{ name: 'actions', label: '', field: '', align: 'right' },
]
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)
const estimatedMinutes = computed(() => {
const n = recipients.value.length - excludedCount.value
const per = (params.value.throttle_ms || 600) / 1000
return Math.max(1, Math.round((n * per) / 60))
})
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) }
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 || []
parseInfo.value = { leftover_gifts: r.leftover_gifts || 0, leftover_contacts: r.leftover_contacts || 0 }
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>

View File

@ -0,0 +1,88 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<div class="text-h5">Campagnes</div>
<q-space />
<q-btn flat color="primary" icon="palette" label="Éditer le template" :to="'/campaigns/templates/gift-email-fr'" class="q-mr-sm" />
<q-btn unelevated color="primary" icon="add" label="Nouvelle campagne" :to="'/campaigns/new'" />
</div>
<q-card flat bordered v-if="!loading && campaigns.length === 0" class="q-pa-xl text-center text-grey-7">
<q-icon name="card_giftcard" size="48px" class="q-mb-md" />
<div class="text-h6">Aucune campagne pour le moment</div>
<div class="q-mt-sm">
Une campagne envoie des cartes-cadeaux Giftbit par courriel personnalisé.
Importer 2 CSV (export Map + shortlinks Giftbit) et lancer l'envoi via Mailjet.
</div>
<q-btn class="q-mt-lg" color="primary" icon="add" label="Créer la première" :to="'/campaigns/new'" />
</q-card>
<q-table
v-else
:rows="campaigns"
:columns="columns"
row-key="id"
:loading="loading"
flat bordered
:pagination="{ rowsPerPage: 25, sortBy: 'created_at', descending: true }"
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
</q-td>
</template>
<template v-slot:body-cell-progress="props">
<q-td :props="props">
<div class="row items-center q-gutter-xs">
<span class="text-grey-7">{{ (props.row.counters?.sent || 0) }} / {{ props.row.total || 0 }}</span>
<q-linear-progress
:value="(props.row.counters?.sent || 0) / Math.max(1, props.row.total || 1)"
size="6px"
:color="props.row.counters?.failed ? 'negative' : 'positive'"
style="min-width:80px"
/>
<span v-if="props.row.counters?.failed" class="text-negative text-caption">
{{ props.row.counters.failed }} échec(s)
</span>
</div>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn flat dense color="primary" icon="visibility" :to="`/campaigns/${props.row.id}`" />
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listCampaigns } from 'src/api/campaigns'
const campaigns = ref([])
const loading = ref(true)
const columns = [
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
{ name: 'created_at', label: 'Créée', field: r => new Date(r.created_at).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' }), align: 'left', sortable: true },
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
{ name: 'progress', label: 'Envois', field: 'counters', align: 'left' },
{ name: 'actions', label: '', field: '', align: 'right' },
]
function statusColor (s) {
return { draft: 'grey', sending: 'orange', completed: 'positive', failed: 'negative' }[s] || 'grey'
}
function statusLabel (s) {
return { draft: 'Brouillon', sending: 'En cours', completed: 'Terminée', failed: 'Échec' }[s] || s
}
async function load () {
loading.value = true
try { campaigns.value = await listCampaigns() }
finally { loading.value = false }
}
onMounted(load)
</script>

View File

@ -0,0 +1,232 @@
<template>
<q-page>
<!-- Top bar -->
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<div class="text-h6 q-mr-md">Éditeur de template</div>
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined style="min-width:240px" @update:model-value="loadTemplate" />
<q-space />
<q-btn-toggle v-model="viewMode" :options="[
{ label: 'Visuel', value: 'visual' },
{ label: 'HTML', value: 'html' },
{ label: 'Aperçu', value: 'preview' },
]" dense unelevated toggle-color="primary" />
<q-btn flat icon="undo" label="Annuler" class="q-ml-sm" :disable="!dirty" @click="discardChanges" />
<q-btn unelevated color="primary" icon="save" label="Enregistrer" class="q-ml-sm"
:loading="saving" :disable="!dirty" @click="save" />
</div>
<!-- Editor surface (one of three modes) -->
<div v-show="viewMode === 'visual'" ref="grapesContainer" style="height:calc(100vh - 110px)"></div>
<div v-show="viewMode === 'html'" class="q-pa-md" style="height:calc(100vh - 110px)">
<q-banner class="bg-blue-1 text-blue-9 q-mb-sm" rounded>
<template v-slot:avatar><q-icon name="info" /></template>
Édition HTML brute. Les changements sauvegardés ici écrasent ceux de l'éditeur visuel.
Variables disponibles : <code>{{ '{{firstname}}' }}</code>, <code>{{ '{{amount}}' }}</code>,
<code>{{ '{{gift_url}}' }}</code>, <code>{{ '{{description}}' }}</code>,
<code>{{ '{{expiry}}' }}</code>, <code>{{ '{{commitment_months}}' }}</code>.
Blocs conditionnels : <code>{{ '{{#expiry}}...{{/expiry}}' }}</code>.
</q-banner>
<q-input v-model="html" type="textarea" outlined dense filled
input-style="font-family:monospace; font-size:0.85rem; line-height:1.4; min-height:calc(100vh - 220px)"
@update:model-value="dirty = true" />
</div>
<div v-show="viewMode === 'preview'" style="height:calc(100vh - 110px); overflow:auto; background:#f7f8f7;">
<iframe :srcdoc="previewHtml" frameborder="0" style="width:100%; height:100%; display:block;"></iframe>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { listTemplates, getTemplate, saveTemplate, previewTemplate } from 'src/api/campaigns'
import grapesjs from 'grapesjs'
import 'grapesjs/dist/css/grapes.min.css'
import presetNewsletter from 'grapesjs-preset-newsletter'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
const grapesContainer = ref(null)
const templates = ref([])
const currentName = ref(route.params.name || 'gift-email-fr')
const html = ref('')
const previewHtml = ref('')
const dirty = ref(false)
const saving = ref(false)
const viewMode = ref('visual') // 'visual' | 'html' | 'preview'
let editor = null
let originalHtml = ''
const templateOptions = ref([])
async function loadAvailableTemplates () {
templates.value = await listTemplates()
templateOptions.value = templates.value.map(t => ({
label: `${t.name}.html (${Math.round(t.size/1024)} KB)`,
value: t.name,
}))
}
async function loadTemplate (name) {
const data = await getTemplate(name || currentName.value)
html.value = data.html
originalHtml = data.html
dirty.value = false
// Push the loaded HTML into the GrapesJS canvas
if (editor) {
editor.setComponents(data.html)
// Once components are loaded we'll need the editor to NOT mark as dirty
// (setComponents triggers a `component:update` event by design).
setTimeout(() => { dirty.value = false }, 100)
}
router.replace({ path: `/campaigns/templates/${name || currentName.value}` })
}
function initGrapes () {
editor = grapesjs.init({
container: grapesContainer.value,
height: '100%',
width: 'auto',
storageManager: false, // we manage save ourselves via REST
fromElement: false,
plugins: [presetNewsletter],
pluginsOpts: {
[presetNewsletter]: {
modalLabelImport: 'Coller votre HTML ici',
modalLabelExport: 'Copier le HTML',
codeViewerTheme: 'hopscotch',
importPlaceholder: '<table>...</table>',
cellStyle: { 'font-size': '12px', 'font-weight': 300, 'vertical-align': 'top' },
},
},
// Custom blocks for merge-variable insertion
blockManager: {
blocks: [
{
id: 'var-firstname',
label: '{{firstname}}',
category: 'Variables',
content: '<span>{{firstname}}</span>',
attributes: { class: 'fa fa-user' },
},
{
id: 'var-amount',
label: '{{amount}}',
category: 'Variables',
content: '<strong>{{amount}}</strong>',
},
{
id: 'var-gift-url',
label: '{{gift_url}}',
category: 'Variables',
content: '<a href="{{gift_url}}" style="color:#019547; font-weight:700;">Lien cadeau</a>',
},
{
id: 'var-description',
label: '{{description}}',
category: 'Variables',
content: '<span>{{description}}</span>',
},
{
id: 'var-expiry',
label: '{{expiry}}',
category: 'Variables',
content: '<span>{{expiry}}</span>',
},
{
id: 'var-commitment',
label: '{{commitment_months}}',
category: 'Variables',
content: '<span>{{commitment_months}}</span>',
},
],
},
})
// Track changes
editor.on('component:add component:remove component:update', () => {
if (!editor.getHtml) return
const next = editor.getHtml({ component: editor.getWrapper() }) + '<style>' + editor.getCss() + '</style>'
if (next !== originalHtml) dirty.value = true
})
// Sync HTML on every change so the HTML tab stays accurate
editor.on('update', () => {
try {
html.value = editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : '')
} catch (e) { /* during init the editor may not have a canvas ready */ }
})
}
// When viewMode changes to 'preview', call the hub's preview endpoint with
// sample data so {{vars}} get substituted live.
watch(viewMode, async (mode) => {
if (mode === 'preview') {
// If user has been editing in HTML mode, use their current html.value;
// otherwise pull from the grapes canvas.
const sourceHtml = viewMode.value === 'html'
? html.value
: (editor ? editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : '') : html.value)
try {
const r = await previewTemplate(currentName.value, { html: sourceHtml })
previewHtml.value = r.rendered
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur prévisualisation: ' + e.message })
}
}
// When switching from HTML mode back to Visual, sync grapes canvas from textarea
if (mode === 'visual' && editor && html.value && html.value !== editor.getHtml()) {
editor.setComponents(html.value)
}
})
async function save () {
saving.value = true
try {
// Determine final HTML to save based on current mode
const finalHtml = viewMode.value === 'html'
? html.value
: (editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : ''))
await saveTemplate(currentName.value, finalHtml)
originalHtml = finalHtml
html.value = finalHtml
dirty.value = false
$q.notify({ type: 'positive', message: 'Template enregistré ✓' })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
}
function discardChanges () {
html.value = originalHtml
if (editor) editor.setComponents(originalHtml)
dirty.value = false
}
onMounted(async () => {
await loadAvailableTemplates()
await nextTick()
initGrapes()
await loadTemplate(currentName.value)
})
onBeforeUnmount(() => {
if (editor) try { editor.destroy() } catch {}
})
</script>
<style>
/* GrapesJS UI is darkish by default — tone down to match Quasar light theme */
.gjs-one-bg { background: #f3f4f3 !important; }
.gjs-three-bg { background: #019547 !important; }
.gjs-four-color, .gjs-four-color-h:hover { color: #019547 !important; }
</style>

View File

@ -38,6 +38,13 @@ const routes = [
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
{ path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') },
{ path: 'campaigns/new', component: () => import('src/modules/campaigns/pages/CampaignNewPage.vue') },
// Template editor route must be ABOVE /campaigns/:id otherwise the
// ':id' wildcard captures 'templates/...' and shows the detail page.
{ path: 'campaigns/templates/:name?', component: () => import('src/modules/campaigns/pages/TemplateEditorPage.vue'), props: true },
{ path: 'campaigns/:id', component: () => import('src/modules/campaigns/pages/CampaignDetailPage.vue'), props: true },
],
},
]