diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue
index b66e81a..d196ccf 100644
--- a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue
+++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue
@@ -165,6 +165,31 @@
+
+
+
+
+
+ editParams.gift_expires_at_display = v || ''"
+ mask="YYYY-MM-DD" :options="dateAfterToday"
+ color="primary" minimal>
+
+
+
+
+
+
+
+ Effacer la date explicite
+
+
+
Date.now()
+}
+
// Template lists for the FR/EN dropdowns in the dialog. Refreshed lazily
// when the dropdown opens so newly-created templates show up without a
// page reload.
@@ -409,6 +443,10 @@ async function retryFailedRow (row) {
// dropdowns are populated when the dialog mounts.
function openEditParams () {
const p = campaign.value?.params || {}
+ // Convert the stored full ISO datetime back to YYYY-MM-DD for the picker.
+ const expiresAtDisplay = p.gift_expires_at
+ ? new Date(p.gift_expires_at).toISOString().slice(0, 10)
+ : ''
editParams.value = {
name: campaign.value?.name || '',
subject: p.subject || '',
@@ -418,6 +456,7 @@ function openEditParams () {
expiry: p.expiry || '',
template_fr: p.template_fr || 'gift-email-fr',
template_en: p.template_en || 'gift-email-en',
+ gift_expires_at_display: expiresAtDisplay,
}
editParamsOpen.value = true
loadTemplateLists()
@@ -429,6 +468,13 @@ function openEditParams () {
async function saveEditParams () {
savingParams.value = true
try {
+ // Translate the YYYY-MM-DD picker value into the full ISO datetime the
+ // hub stores. Anchor at end-of-day local so "until June 15" stays valid
+ // through all of June 15. Empty → fall back to gift_expiry_days on the
+ // hub side. PATCH merges into params, so explicitly send null to clear.
+ const giftExpiresAt = editParams.value.gift_expires_at_display
+ ? new Date(editParams.value.gift_expires_at_display + 'T23:59:59').toISOString()
+ : null
await updateCampaign(id, {
name: editParams.value.name,
params: {
@@ -439,6 +485,7 @@ async function saveEditParams () {
expiry: editParams.value.expiry,
template_fr: editParams.value.template_fr,
template_en: editParams.value.template_en,
+ gift_expires_at: giftExpiresAt,
},
})
await load()
diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
index 71710e0..63e5d78 100644
--- a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
+++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
@@ -73,12 +73,38 @@
Expiration interne du lien (avant réassignation possible)
- Délai après lequel notre lien intermédiaire /g/<token> cesse de rediriger. Le lien Giftbit sous-jacent reste valide chez Giftbit — utile pour réassigner un cadeau non utilisé à un autre client.
+ Date après laquelle notre lien intermédiaire /g/<token> cesse de rediriger. Le lien Giftbit sous-jacent reste valide chez Giftbit — utile pour réassigner un cadeau non utilisé. Tu peux choisir une date précise (tous les destinataires auront cette date affichée) ou un nombre de jours après l'envoi (relatif, glisse si l'envoi est différé).
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Effacer la date explicite (retour aux presets)
+
+
+
-
-
+
= {{ params.gift_expiry_days }} jour{{ params.gift_expiry_days > 1 ? 's' : '' }}
+
+ ≈ {{ daysUntilExplicitDate }} jour{{ daysUntilExplicitDate > 1 ? 's' : '' }} à partir d'aujourd'hui
+
@@ -594,12 +623,47 @@ const params = ref({
// freeing the underlying Giftbit gift_url for reassignment to another
// customer in a new campaign.
gift_expiry_days: 90,
+ // Optional explicit cutoff (ISO date). When set, overrides the days
+ // calculation — every recipient gets THIS exact date in their email,
+ // regardless of when the worker actually fires the send. Useful when
+ // you want "valid until June 15" rather than "30 days from now".
+ // The hub stores .gift_expires_at on each row at send time from this value.
+ gift_expires_at_display: '', // YYYY-MM-DD picked in the date picker
// Per-language template selection. Defaults match the canonical templates;
// operator can switch to a variant (e.g. seasonal) per campaign.
template_fr: 'gift-email-fr',
template_en: 'gift-email-en',
})
+// Date picker constraints + computed helpers for the expiry picker.
+function dateAfterToday (date) {
+ // q-date passes ISO string YYYY/MM/DD — must be > today
+ const d = new Date(date.replace(/\//g, '-'))
+ d.setHours(23, 59, 59, 999)
+ return d.getTime() > Date.now()
+}
+const daysUntilExplicitDate = computed(() => {
+ if (!params.value.gift_expires_at_display) return 0
+ const target = new Date(params.value.gift_expires_at_display + 'T23:59:59')
+ return Math.max(1, Math.ceil((target - new Date()) / 86400000))
+})
+function onExpiryDatePicked (dateStr) {
+ // dateStr from q-date with mask "YYYY-MM-DD"
+ params.value.gift_expires_at_display = dateStr || ''
+ // Sync gift_expiry_days for legacy callers, and so the recap shows ~N days
+ if (dateStr) {
+ const target = new Date(dateStr + 'T23:59:59')
+ params.value.gift_expiry_days = Math.max(1, Math.ceil((target - new Date()) / 86400000))
+ }
+}
+function clearExpiryDate () {
+ params.value.gift_expires_at_display = ''
+ // Restore default 90 days if user cleared
+ if (!EXPIRY_PRESETS.includes(params.value.gift_expiry_days)) {
+ params.value.gift_expiry_days = 90
+ }
+}
+
// Template dropdowns. Lists EVERY editable gift-email-* template in both
// dropdowns, but sorts so the language-matching ones (-fr / -en suffix)
// appear at the top — operator instinct will pick those first while still
@@ -1012,9 +1076,20 @@ function submitManualRow () {
async function launchSend () {
sending.value = true
try {
+ // The hub expects params.gift_expires_at as a full ISO datetime when the
+ // user picked an explicit cutoff. The UI carries the YYYY-MM-DD shape
+ // in gift_expires_at_display — translate here, anchored at end-of-day
+ // local time so "until June 15" really means through all of June 15.
+ const apiParams = { ...params.value }
+ if (apiParams.gift_expires_at_display) {
+ apiParams.gift_expires_at = new Date(apiParams.gift_expires_at_display + 'T23:59:59').toISOString()
+ } else {
+ delete apiParams.gift_expires_at
+ }
+ delete apiParams.gift_expires_at_display // UI-only field, drop before save
const saved = await createCampaign({
name: params.value.name,
- params: { ...params.value },
+ params: apiParams,
recipients: recipients.value,
})
await sendCampaign(saved.id)
diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js
index 63902df..503c7de 100644
--- a/services/targo-hub/lib/campaigns.js
+++ b/services/targo-hub/lib/campaigns.js
@@ -1106,8 +1106,18 @@ async function sendCampaignAsync (id) {
// Computed BEFORE the gift_url var so the email gets the wrapped URL.
if (!r.gift_token && r.gift_url) {
r.gift_token = generateGiftToken()
- const expiryDays = parseInt(p.gift_expiry_days || 90, 10)
- r.gift_expires_at = new Date(Date.now() + expiryDays * 86400 * 1000).toISOString()
+ // Expiry resolution order:
+ // 1. params.gift_expires_at (explicit ISO date set in the wizard
+ // via the date picker) — all recipients of THIS campaign get
+ // the same hard cutoff, regardless of when the worker fires.
+ // 2. Fallback: now() + gift_expiry_days (relative deadline,
+ // shifts forward by the queue lag).
+ if (p.gift_expires_at) {
+ r.gift_expires_at = new Date(p.gift_expires_at).toISOString()
+ } else {
+ const expiryDays = parseInt(p.gift_expiry_days || 90, 10)
+ r.gift_expires_at = new Date(Date.now() + expiryDays * 86400 * 1000).toISOString()
+ }
r.gift_revoked = false
r.gift_redirected_count = 0
tokenIndex.set(r.gift_token, { campaign_id: id, row: i })