Adds a "Créer une relance" button on the campaign detail page that
clones the parent campaign into a new draft, targeting only the
recipients who haven't clicked the Giftbit gift link yet.
Backend (POST /campaigns/:id/reminder):
- Filters parent recipients: status sent/opened, not excluded, not
revoked, wrapper not yet expired, has a gift_url.
- Builds a fresh recipients array — same gift_url (Giftbit shortlink),
same name/email/language/amount, but cleared gift_token so the worker
generates a brand-new wrapper at send time. Each campaign owns its
own click metrics.
- New campaign starts as 'draft' so the operator can review, tweak
subject/template, and click "Lancer l'envoi" when ready.
- Tracks parent_campaign_id + parent_row_index on each reminder row
for traceability in CSV reports and debugging.
Templates (gift-email-reminder-fr / gift-email-reminder-en):
- Header swap: "Petit rappel pour {firstname}" / "Quick reminder, X"
- Bold orange urgency line: "⏰ Hâte-toi! Ton cadeau de X expire le Y"
using the existing {{expires_at_date}} and {{amount}} merge vars
- Body shortened — drops the manifesto, focuses on "you have a gift,
redeem before it's gone"
- Same CTA button + prorata disclaimer + signature + footer as the
main templates so brand stays consistent.
UI:
- Button visible when campaign is sending/completed AND it's not
itself a reminder AND there's ≥ 1 eligible non-clicker.
- Confirmation dialog spells out the mechanics: same Giftbit URLs,
new wrapper tokens, reminder template, sample expiry date pulled
from the campaign's first recipient with a gift_expires_at.
- On OK, redirects to the new campaign's detail page.
Click stats on the existing campaign (cmp-20260522-2d4605) verified
intact before+after deploy (109 opens, 15 generic clicks, 27 gift CTA
clicks) — saveCampaign persists per-event so the hub restart was a
no-op for accumulated data.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
212 lines
7.4 KiB
JavaScript
212 lines
7.4 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',
|
|
})
|
|
}
|
|
|
|
// 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 || [])
|
|
}
|
|
|
|
// 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 } = {}) {
|
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
|
|
method: 'POST',
|
|
body: { to, vars, from, subject },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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)}`
|
|
}
|