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:
parent
31562f62bf
commit
89057d0166
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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/<token></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/<token></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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user