feat(campaigns/expiry): date picker for explicit cutoff

Operator can now choose an exact date for the wrapper expiry (e.g.
"valid until June 15") instead of computing days from today. Useful
when communicating a specific deadline to recipients.

Worker resolution order:
  1. params.gift_expires_at (full ISO datetime, set by the date picker)
     — all recipients of this campaign get THIS exact date, regardless
     of when the worker fires the send.
  2. Fallback: now() + gift_expiry_days (relative deadline, shifts
     forward by queue lag).

UI in both wizard (new campaign) and edit-params dialog (draft):
- Date picker at the top with cursor-pointer event icon + clear (x)
- Preset toggle (15/30/60/90/180/Custom days) below — auto-disabled
  when explicit date is set so the operator picks ONE mode
- Indicator "≈ N jours à partir d'aujourd'hui" when explicit date is
  active so the operator sees both representations

UI carries the picker value as YYYY-MM-DD (gift_expires_at_display);
launchSend / saveEditParams translate to ISO YYYY-MM-DDT23:59:59Z
before PATCH/POST. Anchoring at end-of-day local means "until June 15"
stays valid through all of June 15, not just the start.

dateAfterToday validator blocks past dates in the picker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-01 14:45:14 -04:00
parent 31562f62bf
commit 89057d0166
3 changed files with 139 additions and 7 deletions

View File

@ -165,6 +165,31 @@
<q-input v-model="editParams.expiry" label="Expiration (texte affiché)" outlined dense
class="col-12 col-sm-4" placeholder="31 décembre 2026" />
</div>
<!-- Wrapper expiry: date picker (absolute) empties when cleared
so the hub falls back to gift_expiry_days. -->
<q-input v-model="editParams.gift_expires_at_display" outlined dense readonly
label="Date d'expiration du lien (affichée aux destinataires)"
placeholder="Choisir une date ou laisser vide pour utiliser le défaut">
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date :model-value="editParams.gift_expires_at_display"
@update:model-value="v => editParams.gift_expires_at_display = v || ''"
mask="YYYY-MM-DD" :options="dateAfterToday"
color="primary" minimal>
<div class="row items-center justify-end">
<q-btn v-close-popup label="OK" color="primary" flat />
</div>
</q-date>
</q-popup-proxy>
</q-icon>
<q-icon v-if="editParams.gift_expires_at_display" name="close"
class="cursor-pointer q-ml-xs"
@click="editParams.gift_expires_at_display = ''">
<q-tooltip>Effacer la date explicite</q-tooltip>
</q-icon>
</template>
</q-input>
<div class="row q-col-gutter-sm">
<q-select v-model="editParams.template_fr" :options="frTemplateOptions" emit-value map-options
label="🇫🇷 Template français" outlined dense class="col-12 col-sm-6"
@ -217,8 +242,17 @@ const savingParams = ref(false)
const editParams = ref({
name: '', subject: '', from: '', amount: '', commitment_months: 3,
expiry: '', template_fr: 'gift-email-fr', template_en: 'gift-email-en',
// UI-only derived from / writes back to params.gift_expires_at (full ISO).
gift_expires_at_display: '',
})
// Date picker validator: future dates only.
function dateAfterToday (dateStr) {
const d = new Date(dateStr.replace(/\//g, '-'))
d.setHours(23, 59, 59, 999)
return d.getTime() > 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()

View File

@ -73,12 +73,38 @@
<div class="text-caption text-grey-7 q-mb-xs">
Expiration interne du lien (avant réassignation possible)
<q-icon name="info" size="14px" class="q-ml-xs">
<q-tooltip max-width="320px">Délai après lequel notre lien intermédiaire <code>/g/&lt;token&gt;</code> cesse de rediriger. Le lien Giftbit sous-jacent reste valide chez Giftbit utile pour réassigner un cadeau non utilisé à un autre client.</q-tooltip>
<q-tooltip max-width="380px">Date après laquelle notre lien intermédiaire <code>/g/&lt;token&gt;</code> cesse de rediriger. Le lien Giftbit sous-jacent reste valide chez Giftbit utile pour réassigner un cadeau non utilisé. Tu peux choisir une <strong>date précise</strong> (tous les destinataires auront cette date affichée) ou un <strong>nombre de jours après l'envoi</strong> (relatif, glisse si l'envoi est différé).</q-tooltip>
</q-icon>
</div>
<!-- Primary: explicit date picker (writes params.gift_expires_at).
Secondary: preset toggle in days (writes gift_expiry_days,
clears the explicit date). One or the other never both. -->
<q-input v-model="params.gift_expires_at_display" outlined dense readonly
placeholder="Choisir une date explicite (sinon, presets ci-dessous)"
class="q-mb-xs">
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date :model-value="params.gift_expires_at_display"
@update:model-value="onExpiryDatePicked"
mask="YYYY-MM-DD" :options="dateAfterToday"
color="primary" minimal>
<div class="row items-center justify-end">
<q-btn v-close-popup label="OK" color="primary" flat />
</div>
</q-date>
</q-popup-proxy>
</q-icon>
<q-icon v-if="params.gift_expires_at_display" name="close" class="cursor-pointer q-ml-xs"
@click="clearExpiryDate">
<q-tooltip>Effacer la date explicite (retour aux presets)</q-tooltip>
</q-icon>
</template>
</q-input>
<div class="row items-center q-gutter-xs">
<q-btn-toggle
:model-value="expiryPreset"
:model-value="params.gift_expires_at_display ? null : expiryPreset"
:disable="!!params.gift_expires_at_display"
@update:model-value="setExpiryPreset"
:options="[
{ label: '15j', value: 15 },
@ -92,14 +118,17 @@
toggle-color="primary" toggle-text-color="white"
no-caps
/>
<q-input v-if="expiryPreset === 'custom'"
<q-input v-if="!params.gift_expires_at_display && expiryPreset === 'custom'"
v-model.number="params.gift_expiry_days"
type="number" min="1" max="365"
dense outlined style="width: 120px"
suffix="jours" />
<span v-else class="text-caption text-grey-6 q-ml-sm">
<span v-else-if="!params.gift_expires_at_display" class="text-caption text-grey-6 q-ml-sm">
= {{ params.gift_expiry_days }} jour{{ params.gift_expiry_days > 1 ? 's' : '' }}
</span>
<span v-else class="text-caption text-grey-6 q-ml-sm">
{{ daysUntilExplicitDate }} jour{{ daysUntilExplicitDate > 1 ? 's' : '' }} à partir d'aujourd'hui
</span>
</div>
</div>
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-12 col-md-6" />
@ -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)

View File

@ -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 })