feat(campaigns/templates): visible wrapper-expiry date in the email

Two new template variables are auto-derived from r.gift_expires_at at
render time (separately by the worker and the /view fallback to keep
them consistent):

  {{expires_at_date}}  locale-formatted FR/EN long date — "21 août 2026"
                       / "August 21, 2026". Empty when no wrapper token.
  {{expires_in_days}}  remaining days as string (rounded up). Useful
                       for tight deadlines where a date is too distant
                       to convey urgency.

Templates: a small centered badge appears between the CTA button and
the prorata disclaimer, wrapped in a Mustache section so it disappears
cleanly on campaigns that pre-date the wrapper feature.

   Cadeau valide jusqu'au <strong>21 août 2026</strong>
   Gift valid until <strong>August 21, 2026</strong>

Editor merge-tag panel updated so authors can drop these into custom
copy without remembering the exact variable names. The legacy
{{expiry}} field stays — it's still the right tool for promotion-end
dates that don't track the gift link's own deadline.

🤖 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-05-22 10:47:58 -04:00
parent d529019106
commit feeae6eb40
10 changed files with 114 additions and 8 deletions

View File

@ -278,10 +278,12 @@ const editorOptions = {
{ {
name: 'Offre', name: 'Offre',
mergeTags: [ mergeTags: [
{ name: 'Montant', value: '{{amount}}', sample: '60 $' }, { name: 'Montant', value: '{{amount}}', sample: '60 $' },
{ name: 'Lien cadeau (URL)', value: '{{gift_url}}', sample: 'https://gft.link/abc' }, { name: 'Lien cadeau (URL)', value: '{{gift_url}}', sample: 'https://gft.link/abc' },
{ name: "Date d'expiration", value: '{{expiry}}', sample: '31 décembre 2026' }, { name: "Date d'expiration (texte)", value: '{{expiry}}', sample: '31 décembre 2026' },
{ name: 'Engagement (mois)', value: '{{commitment_months}}', sample: '3' }, { name: 'Expiration auto (date)', value: '{{expires_at_date}}', sample: '21 août 2026' },
{ name: 'Expiration auto (jours)', value: '{{expires_in_days}}', sample: '90' },
{ name: 'Engagement (mois)', value: '{{commitment_months}}', sample: '3' },
], ],
}, },
{ {

View File

@ -493,6 +493,21 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
<!--[if mso | IE]></td></tr></table><![endif]--> <!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ WRAPPER EXPIRY BADGE (auto-derived) ════════ -->
{{#expires_at_date}}
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody><tr><td style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:0 36px 4px;text-align:center;">
<div style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.4;text-align:center;color:#64748B;">
⏰ Gift valid until <strong style="color:#1B2E24;">{{expires_at_date}}</strong>
</div>
</td></tr></tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
{{/expires_at_date}}
<!-- ════════ PRORATA REFUND DISCLAIMER ════════ --> <!-- ════════ PRORATA REFUND DISCLAIMER ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->

File diff suppressed because one or more lines are too long

View File

@ -497,6 +497,27 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
<!--[if mso | IE]></td></tr></table><![endif]--> <!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ WRAPPER EXPIRY BADGE (auto-derived) ════════
Visible juste sous le bouton CTA pour que le destinataire sache
qu'il a un délai limité. {{expires_at_date}} est calculé à
partir de gift_expires_at (sent_at + gift_expiry_days) — il
tombe à vide si la campagne n'a pas de wrapper, auquel cas
la section Mustache {{#expires_at_date}}…{{/expires_at_date}}
collapse silencieusement. -->
{{#expires_at_date}}
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody><tr><td style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:0 36px 4px;text-align:center;">
<div style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.4;text-align:center;color:#64748B;">
⏰ Cadeau valide jusqu'au <strong style="color:#1B2E24;">{{expires_at_date}}</strong>
</div>
</td></tr></tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
{{/expires_at_date}}
<!-- ════════ PRORATA REFUND DISCLAIMER ════════ --> <!-- ════════ PRORATA REFUND DISCLAIMER ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->

File diff suppressed because one or more lines are too long

View File

@ -948,6 +948,20 @@ async function sendCampaignAsync (id) {
r.gift_redirected_count = 0 r.gift_redirected_count = 0
tokenIndex.set(r.gift_token, { campaign_id: id, row: i }) tokenIndex.set(r.gift_token, { campaign_id: id, row: i })
} }
// Format the wrapper's own expiry date for display in the email body.
// Locale matches the recipient's language so "May 31, 2026" / "31 mai 2026"
// appear naturally. expires_in_days is a friendlier shorter format for
// tight deadlines (≤ 60 days). The two are intentionally redundant
// so the template author can pick the one that fits their layout.
const expiryLocale = lang === 'en' ? 'en-CA' : 'fr-CA'
let expiresAtDate = ''
let expiresInDays = ''
if (r.gift_expires_at) {
const exp = new Date(r.gift_expires_at)
expiresAtDate = exp.toLocaleDateString(expiryLocale, { day: 'numeric', month: 'long', year: 'numeric' })
const days = Math.max(0, Math.ceil((exp - new Date()) / 86400000))
expiresInDays = String(days)
}
const vars = { const vars = {
firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'), firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'),
lastname: r.lastname || '', lastname: r.lastname || '',
@ -958,6 +972,11 @@ async function sendCampaignAsync (id) {
gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url, gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url,
amount: displayAmount, amount: displayAmount,
expiry: p.expiry || '', expiry: p.expiry || '',
// Auto-derived from the wrapper's gift_expires_at — distinct from
// the manual {{expiry}} field above which is for promotion-end dates
// unrelated to the gift link wrapper.
expires_at_date: expiresAtDate,
expires_in_days: expiresInDays,
commitment_months: p.commitment_months || '3', commitment_months: p.commitment_months || '3',
year: new Date().getFullYear(), year: new Date().getFullYear(),
view_url: viewUrl, view_url: viewUrl,
@ -1764,6 +1783,17 @@ async function handle (req, res, method, path) {
? `${cents / 100} $` ? `${cents / 100} $`
: `${(cents / 100).toFixed(2)} $` : `${(cents / 100).toFixed(2)} $`
} }
// Locale-formatted wrapper expiry — same logic as the worker so the
// /view fallback renders the exact same date the recipient saw at first.
const expiryLocale = lang === 'en' ? 'en-CA' : 'fr-CA'
let expiresAtDate = ''
let expiresInDays = ''
if (r.gift_expires_at) {
const exp = new Date(r.gift_expires_at)
expiresAtDate = exp.toLocaleDateString(expiryLocale, { day: 'numeric', month: 'long', year: 'numeric' })
const days = Math.max(0, Math.ceil((exp - new Date()) / 86400000))
expiresInDays = String(days)
}
const vars = { const vars = {
firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'), firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'),
lastname: r.lastname || '', lastname: r.lastname || '',
@ -1775,6 +1805,8 @@ async function handle (req, res, method, path) {
gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url, gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url,
amount: displayAmount, amount: displayAmount,
expiry: p.expiry || '', expiry: p.expiry || '',
expires_at_date: expiresAtDate,
expires_in_days: expiresInDays,
commitment_months: p.commitment_months || '3', commitment_months: p.commitment_months || '3',
year: new Date().getFullYear(), year: new Date().getFullYear(),
// Empty so the {{#view_url}} section block in the template collapses // Empty so the {{#view_url}} section block in the template collapses

View File

@ -493,6 +493,21 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
<!--[if mso | IE]></td></tr></table><![endif]--> <!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ WRAPPER EXPIRY BADGE (auto-derived) ════════ -->
{{#expires_at_date}}
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody><tr><td style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:0 36px 4px;text-align:center;">
<div style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.4;text-align:center;color:#64748B;">
⏰ Gift valid until <strong style="color:#1B2E24;">{{expires_at_date}}</strong>
</div>
</td></tr></tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
{{/expires_at_date}}
<!-- ════════ PRORATA REFUND DISCLAIMER ════════ --> <!-- ════════ PRORATA REFUND DISCLAIMER ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->

File diff suppressed because one or more lines are too long

View File

@ -497,6 +497,27 @@ table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: under
<!--[if mso | IE]></td></tr></table><![endif]--> <!--[if mso | IE]></td></tr></table><![endif]-->
<!-- ════════ WRAPPER EXPIRY BADGE (auto-derived) ════════
Visible juste sous le bouton CTA pour que le destinataire sache
qu'il a un délai limité. {{expires_at_date}} est calculé à
partir de gift_expires_at (sent_at + gift_expiry_days) — il
tombe à vide si la campagne n'a pas de wrapper, auquel cas
la section Mustache {{#expires_at_date}}…{{/expires_at_date}}
collapse silencieusement. -->
{{#expires_at_date}}
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody><tr><td style="border-left:1px solid #e5e7eb;border-right:1px solid #e5e7eb;direction:ltr;font-size:0px;padding:0 36px 4px;text-align:center;">
<div style="font-family:Plus Jakarta Sans, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.4;text-align:center;color:#64748B;">
⏰ Cadeau valide jusqu'au <strong style="color:#1B2E24;">{{expires_at_date}}</strong>
</div>
</td></tr></tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
{{/expires_at_date}}
<!-- ════════ PRORATA REFUND DISCLAIMER ════════ --> <!-- ════════ PRORATA REFUND DISCLAIMER ════════ -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->

File diff suppressed because one or more lines are too long