Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré / juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts, sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle week-end + placeholder « en attente du quart », clustering + lasso + filtre-date sur la carte, accès rapide « À assigner » (badge). Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive + timeout fetch global + authFetch durci → fin des rechargements manuels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
9.2 KiB
JavaScript
251 lines
9.2 KiB
JavaScript
/**
|
|
* 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',
|
|
})
|
|
}
|
|
|
|
// Duplicate a campaign as a fresh draft (id "_2", recipients reset to pending).
|
|
// Returns the new draft campaign so the caller can open it for review/send.
|
|
export function cloneCampaign (id) {
|
|
return hubFetch(`/campaigns/${encodeURIComponent(id)}/clone`, {
|
|
method: 'POST',
|
|
})
|
|
}
|
|
|
|
// Permanent deletion — removes the JSON on the hub. Used for clearing
|
|
// test campaigns from the list. Giftbit shortlinks are unaffected.
|
|
export function deleteCampaign (id) {
|
|
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
|
|
method: 'DELETE',
|
|
})
|
|
}
|
|
|
|
// Inventory of every wrapper token across all campaigns, with status
|
|
// (active / expired / revoked / redeemed / pending). Used by the
|
|
// gifts inventory page to surface reassignable Giftbit shortlinks.
|
|
export function listGifts () {
|
|
return hubFetch('/campaigns/gifts').then(r => r.gifts || [])
|
|
}
|
|
|
|
// Pool de liens-cadeaux récupérés (jamais cliqués) prêts à réallouer à d'autres clients.
|
|
export function getGiftPool () {
|
|
return hubFetch('/campaigns/gifts/pool')
|
|
}
|
|
|
|
// Récupère les liens non cliqués d'une campagne vers le pool (dry_run par défaut = aperçu).
|
|
export function reclaimGifts ({ campaign_id, dry_run = true, revoke_live = true }) {
|
|
return hubFetch('/campaigns/gifts/reclaim', { method: 'POST', body: { campaign_id, dry_run, revoke_live } })
|
|
}
|
|
|
|
// Envoi unitaire d'une récompense depuis le pool vers un client (dry_run pour aperçu).
|
|
export function rewardSend (body) {
|
|
return hubFetch('/campaigns/reward-send', { method: 'POST', body })
|
|
}
|
|
|
|
// Récompenses déjà préparées/envoyées à un client (pour la fiche : voir / copier le lien).
|
|
export function rewardByCustomer (customer_id, email) {
|
|
const q = customer_id ? 'customer_id=' + encodeURIComponent(customer_id) : 'email=' + encodeURIComponent(email || '')
|
|
return hubFetch('/campaigns/reward/by-customer?' + q)
|
|
}
|
|
|
|
// Supprime un brouillon / révoque une récompense non cliquée (remet le lien en réserve ; refusé si déjà ouverte).
|
|
export function revokeRewardByToken (gift_token) {
|
|
return hubFetch('/campaigns/reward/revoke', { method: 'POST', body: { gift_token } })
|
|
}
|
|
|
|
// Révoque une récompense déjà envoyée + remet le lien Giftbit au pool (refusé si déjà cliqué/redirigé).
|
|
export function revokeReward (gift_url) {
|
|
return hubFetch('/campaigns/gifts/revoke-reward', { method: 'POST', body: { gift_url } })
|
|
}
|
|
|
|
// Kill switch — manually expire a single recipient's wrapper token so
|
|
// the underlying Giftbit URL becomes reassignable before the natural
|
|
// expiry date.
|
|
export function revokeGift (campaignId, rowIndex) {
|
|
return hubFetch(
|
|
`/campaigns/${encodeURIComponent(campaignId)}/recipients/${rowIndex}/revoke`,
|
|
{ method: 'POST' },
|
|
)
|
|
}
|
|
|
|
// Re-attempt a single failed recipient — resets status pending and
|
|
// fires the worker. Used for one-off failures the auto-retry didn't
|
|
// recover (rare transient Mailjet socket closes, etc.).
|
|
export function retryRecipient (campaignId, rowIndex) {
|
|
return hubFetch(
|
|
`/campaigns/${encodeURIComponent(campaignId)}/recipients/${rowIndex}/retry`,
|
|
{ method: 'POST' },
|
|
)
|
|
}
|
|
|
|
// Create a brand-new draft campaign targeted at the parent's non-clicked
|
|
// recipients. Uses gift-email-reminder-* templates + an urgency subject.
|
|
// The new campaign is returned but NOT auto-sent — the operator reviews
|
|
// and clicks Lancer l'envoi when ready.
|
|
export function createReminderCampaign (parentCampaignId) {
|
|
return hubFetch(
|
|
`/campaigns/${encodeURIComponent(parentCampaignId)}/reminder`,
|
|
{ method: 'POST' },
|
|
)
|
|
}
|
|
|
|
// Build the URL the browser hits to download the per-recipient CSV report
|
|
// (giftbit shortlink ↔ email ↔ name ↔ status). The hub serves it with the
|
|
// proper Content-Disposition so an <a download> click triggers a save.
|
|
export function campaignReportCsvUrl (id) {
|
|
return `${HUB_URL}/campaigns/${encodeURIComponent(id)}/report.csv`
|
|
}
|
|
|
|
// ── Image assets (self-hosted on the hub, for GrapesJS asset manager) ───────
|
|
|
|
export function listAssets () {
|
|
return hubFetch('/campaigns/assets').then(r => r.assets || [])
|
|
}
|
|
|
|
// Upload a File / Blob from the browser via base64-encoded JSON. Bypasses
|
|
// multipart parsing on the hub side (zero new deps) at the cost of ~33%
|
|
// payload overhead. Acceptable for the ≤5 MB images we permit.
|
|
export async function uploadAsset (file) {
|
|
const dataUrl = await new Promise((resolve, reject) => {
|
|
const r = new FileReader()
|
|
r.onload = () => resolve(r.result)
|
|
r.onerror = () => reject(new Error('FileReader failed'))
|
|
r.readAsDataURL(file)
|
|
})
|
|
return hubFetch('/campaigns/assets/upload', {
|
|
method: 'POST',
|
|
body: { name: file.name, data: dataUrl },
|
|
})
|
|
}
|
|
|
|
export function deleteAsset (filename) {
|
|
return hubFetch(`/campaigns/assets/${encodeURIComponent(filename)}`, {
|
|
method: 'DELETE',
|
|
})
|
|
}
|
|
|
|
// ── 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)}`)
|
|
}
|
|
|
|
// saveTemplate(name, content, opts) — content is HTML by default.
|
|
// Optional opts.design = Unlayer design JSON (persisted alongside HTML so the
|
|
// editor can re-load the visual state on next open).
|
|
// Legacy opts.format = 'mjml' still supported for older callers (sends mjml).
|
|
export function saveTemplate (name, content, { format = 'html', design = null } = {}) {
|
|
const body = format === 'mjml' ? { mjml: content } : { html: content }
|
|
if (design) body.design = design
|
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, {
|
|
method: 'PUT',
|
|
body,
|
|
})
|
|
}
|
|
|
|
export function previewTemplate (name, { html, vars } = {}) {
|
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, {
|
|
method: 'POST',
|
|
body: { html, vars },
|
|
})
|
|
}
|
|
|
|
// Translate the source template to a target language via Gemini.
|
|
// targetName must match the source's prefix (e.g. gift-email-fr → gift-email-en).
|
|
// override=true required if the target already exists.
|
|
export function translateTemplate (srcName, targetName, { override = false } = {}) {
|
|
return hubFetch(
|
|
`/campaigns/templates/${encodeURIComponent(srcName)}/translate-to/${encodeURIComponent(targetName)}`,
|
|
{ method: 'POST', body: { override } },
|
|
)
|
|
}
|
|
|
|
// Send ONE rendered email to a specific address for visual QA.
|
|
// Pass { to, vars, from?, subject? } — defaults filled in server-side.
|
|
export function testSendTemplate (name, { to, vars, from, subject, via } = {}) {
|
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
|
|
method: 'POST',
|
|
body: { to, vars, from, subject, via },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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)}`
|
|
}
|