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>
GET /campaigns/:id/recipients/:i/view re-renders the campaign with the
same vars the worker used at send time — same template, same per-row
amount override, same language pick. Useful when the recipient's mail
client butchers the layout: image-blocking, antique Outlook, niche
third-party apps, accessibility tools.
Templates: Mustache section {{#view_url}}…{{/view_url}} guards a tiny
gray link above the header logo (11px, #94a3b8). The section collapses
to nothing when view_url is empty, so:
- the /view page itself doesn't show the link (you're already there)
- wizard previews / test-sends don't show it (no real campaign id)
worker passes view_url = HUB_PUBLIC_URL + /campaigns/<id>/recipients/<i>/view
using the existing cfg.HUB_PUBLIC_URL setting (defaults msg.gigafibre.ca).
Security: campaign-id is a 21-char nanoid (≈10²¹ space). Same level of
exposure as the Giftbit shortlink itself. X-Robots-Tag: noindex on the
response so the URLs don't end up on search engines.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ops UI
- CampaignDetailPage: "CSV" button — downloads per-recipient report
(shortlinks, status, opened/clicked timestamps, mailjet UUID)
- CampaignNewPage: "Saisie manuelle (sans CSV)" on Step 1 and
"Ajouter manuellement" on Step 2 — both open the same dialog with
firstname / email / gift_url / city / postal_code / language /
amount override. Indigo "manuel" chip in the recipients table.
- New "Ville" column shows city OR postal_code as fallback.
Hub
- GET /campaigns/:id/report.csv — RFC 4180 CSV with UTF-8 BOM so
Excel auto-detects encoding. 20 columns including new "city".
- Worker honours per-recipient amount override:
r.amount > derive from r.gift_value_cents > params.amount > "50 $".
Fixes manual-add showing campaign default instead of typed value.
- Default subject "Un cadeau pour toi" (tutoyer).
Templates
- Order: Intro → ✅ Option 1 → 🎁 marques → CTA → prorata → ⏭️ Option 2.
- New EN intro (manifesto): "Thank you for choosing local. Your
support helps keep our community connected. / Because great
connections aren't just about fiber — they're about people too."
- Amazon logo removed (incongruent with "achat local" framing).
- Body paragraphs: text-align justify (greeting/labels stay left).
- Support line: "N'hésite pas à nous écrire / Feel free to email us"
+ dash format 514-448-0773, drop "Support 7j/7" overpromise.
- Logo style fix: inline width:32px to beat Unlayer canvas CSS that
was rendering brand pills full-width.
Ignore template converter .bak-*.json backups.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 'sans engagement ni carte-cadeau' wording in Option 2 was confusing
for customers with an existing multi-month commitment — implies their
subscription is commitment-free, which contradicts their actual contract.
Reworded to make zero claims about the customer's commitment status,
just describes what happens if they ignore the email:
FR:
before — 'Ne rien faire. Ton abonnement mensuel se poursuit normalement,
sans engagement ni carte-cadeau.'
after — 'Ne rien faire. Aucun changement à ton abonnement actuel.'
EN (Gemini copywriter version was different from earlier templates):
before — 'Just kick back! Your monthly subscription will continue as
usual, with no commitment and no gift card.'
after — 'Do nothing. No changes to your current subscription.'
Benefits:
• No false claim about engagement status
• Shorter, more direct
• Still preserves the explicit consent UX (customer knows ignoring
is a valid choice without consequence)
• No mention of 'no gift card' — that's implicit from not clicking
the CTA, doesn't need to be stated
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous logo injection commit (d76a922) only matched the FR anchor —
EN was missed because Gemini's copywriter-mode translation rewrote
'at hundreds of brands' as 'to spend at hundreds of your favorite stores'.
Patched EN with the correct anchor + 'and more' caption.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two refinements per user feedback:
1. Objective/factual prorata disclaimer (shorter, conditions-of-service tone)
FR:
before — "Si tu annules avant {{commitment_months}} mois, tu rembourses
seulement au prorata des mois restants."
after — "Annulation avant {{commitment_months}} mois : seulement à
rembourser au prorata des mois restants."
EN:
before — "If you cancel before {{commitment_months}} months, you only
refund the prorated amount for the remaining months."
after — "Cancellation before {{commitment_months}} months: only the
prorated amount for the remaining months is refundable."
The colon-prefixed structure ("X : Y") reads like a T&C bullet rather
than a marketing sentence — clearer, less wordy, no subject pronoun.
2. Inline row of 6 merchant logos in the offer info pill
Inserted between the "60 $ chez des centaines de marques" line and the
"Instant activation" line. 6 most recognizable QC brands at 32px wide:
Amazon · Tim Hortons · Walmart · Home Depot · IGA · Home Hardware
Followed by "et plus" / "and more" caption.
Uses the existing Mailjet-hosted brand logos (same URLs as the 4×3 grid
in the older rich variant). 32px width fits comfortably on one line
(~280px total in a 484px-wide pill). Email-safe single-row table layout
with vertical-align middle, padding-right 8px for spacing.
Visual effect: instant recognition for the reader — they see the brands
they'd actually redeem at, without dropping the full 12-logo grid that
bloated the previous design.
Applied to .html + .json (both FR + EN) via anchor-based injection:
finds the "🎁 {{amount}} chez/at hundreds of marques/brands" paragraph,
inserts the logo table immediately after its closing </p>. Both files
remain valid + the Unlayer editor will pick up the new table next load.
Verified live via test-send (35-37 KB output, recipient queue ok).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Iterating on the prorata disclaimer per user feedback. The previous
version ("tu rembourses le prorata non utilisé (20 $/mois)") still
read ambiguously — "non utilisé" could mean "the portion you haven't
spent" which is conceptually confusing for a one-time gift card, and
the hardcoded "$20/month" tied the template to the specific
$60/3-month campaign.
New phrasing makes the math explicit: refund only for the months
you're NOT staying.
FR:
before — "Si tu résilies avant {{commitment_months}} mois,
tu rembourses le prorata non utilisé (20 $/mois)."
after — "Si tu annules avant {{commitment_months}} mois,
tu rembourses seulement au prorata des mois restants."
EN:
before — "If you cancel before {{commitment_months}} months,
you refund the unused pro-rated amount ($20/month)."
after — "If you cancel before {{commitment_months}} months,
you only refund the prorated amount for the remaining months."
Wins:
• Subject ("tu" / "you") explicit — no ambiguity on who refunds
• Logic clarified — refund == months NOT STAYED, not "unused
portion of money" (which doesn't quite map to a one-time gift)
• Generic over campaign params — no hardcoded "$20/month" so the
template works at any gift amount + commitment combination
• "annules" (more common in QC consumer-facing) instead of
"résilies" (slightly more formal/legal-sounding)
Applied via direct find/replace on .html + .json (FR + EN). Live
test-send queued to confirm rendering.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User feedback: the prorata disclaimer was semantically wrong — both FR
("le prorata du montant est remboursable") and EN ("we'll refund the
pro-rated amount") read as if TARGO would refund the customer, when
actually the customer needs to refund the unused portion of the gift
they received if they cancel within the commitment period.
Plus: add the explicit per-month rate ($20/month at $60 / 3 months) so
the customer knows exactly what they'd owe at any cancellation date.
FR:
before — "🪂 En cas de départ avant {{commitment_months}} mois,
le prorata du montant est remboursable."
after — "🪂 Si tu résilies avant {{commitment_months}} mois,
tu rembourses le prorata non utilisé (20 $/mois)."
EN:
before — "🪂 If you decide to leave before {{commitment_months}}
months, we'll refund the pro-rated amount."
after — "🪂 If you cancel before {{commitment_months}} months,
you refund the unused pro-rated amount ($20/month)."
Both changes:
• Subject clarified: customer refunds, not TARGO
• Added explicit per-month value for transparency
• Kept warm tone (informal "tu" / "you")
• Mustache {{commitment_months}} preserved
Applied directly to .html + .json via string substitution (preserves
the Unlayer design tree intact except for that one phrase). The
"$20/month" figure is hardcoded for the current $60/3-month campaign;
a future {{monthly_prorata}} computed variable would generalize but
isn't needed yet.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User feedback: previous prompt produced robotic word-for-word output that
lost the marketing impact. The system prompt was too restrictive on
preservation, suppressing Gemini's natural rephrasing ability.
Rewrote the system prompt with three structural changes:
1. FRAME shifted from "translator" to "senior marketing copywriter"
The opening line now says "You are NOT translating words — you are
rewriting marketing copy that lands the same way for a different
audience." This unlocks idiomatic rephrasing, sentence reorganization,
active/passive switching, and cultural metaphor adaptation.
2. FEW-SHOT EXAMPLES showing the desired style
4 FR→EN pairs in the prompt itself, with explicit "NOT: <literal>"
anti-examples to show what to avoid:
• "loyauté envers l'achat local" → "keeping it local"
(not "loyalty to local shopping")
• "connexions stables et relations durables" →
"steady connections — both the fiber kind and the human kind"
(not "stable connections and lasting relationships")
• "On est juste à côté" → "We're right next door"
• "Avec l'arrivée de l'été" → "Summer's here"
These ground Gemini in the brand voice with concrete examples.
3. TONE constraint explicit
"Warm, conversational, slightly playful. Like a neighbor explaining
something — never corporate, never stiff." Use of contractions
("we're", "you'll") encouraged.
Plus: temperature bumped from 0.2 → 0.7 so Gemini actually exercises
creative rephrasing instead of staying glued to source word order.
Structural preservation rules (HTML, Mustache vars, brand names, emojis,
URLs, technical values like "3.5 Gbit/s") kept as HARD CONSTRAINTS but
clearly separated from the creative freedom on text content.
Live re-translation of gift-email-fr → gift-email-en applied:
• 51s response time (similar to literal version)
• 35,934 → 36,067 bytes (slight expansion, normal for EN)
• Output markers confirm idiomatic phrasing landed:
"Thanks for keeping it local", "steady connections — both kinds",
"right next door", "lending a hand", "Summer's here"
• Mustache vars + brand names + HTML preserved (verified)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User edited gift-email-fr in the Unlayer editor with richer marketing
copy (loyalty thanks, brand manifesto, 3.5 Gbit/s upsell, helpful CTA).
Mirror those edits to the EN template via a one-shot translation script
so the bilingual pair stays in sync for the next campaign send.
Translation strategy: plain-string find/replace mapping with FR
phrases in longest-first order to avoid partial matches. Applied to
BOTH the rendered .html (what the recipient sees) AND the .json
(Unlayer design tree — so re-opening the EN editor preserves the
matching structure).
Mapping coverage:
• Intro paragraphs (greeting, gift announcement, loyalty thanks,
brand manifesto, speed upsell, "we're around the corner")
• Offer info pill (amount, instant activation, commitment)
• CTA button labels (Activer → Redeem, Choisir → Pick)
• Prorata refund disclaimer
• Option 2 "do nothing" text
• Signature ("Merci de faire rouler" → "Thanks for helping...thrive")
• Footer contact info + "Tous droits réservés" → "All rights reserved"
• <html lang="fr"> → <html lang="en">
23/28 translation rules matched; the 5 unused ones were for legacy
phrasing not present in the user's latest save (e.g. the old "Tu
choisis local" line that was replaced by the current intro).
Also: drop the obsolete .mjml source files. Now that Unlayer is the
canonical editor, the MJML→HTML compile pipeline is no longer used
on save (Unlayer outputs HTML directly). The .mjml files were stale
copies from the previous MJML-based editor. Removed from disk on
prod and from git history; rollback via git revert if needed.
Verified live: GET /campaigns/templates/gift-email-en returns the
translated content (9 EN markers detected in HTML). Test-send to
louis@targo.ca queued via Mailjet for visual QA.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Expanded the email intro from 3 short paragraphs into 4 semantic blocks,
restoring the marketing-friendly "Tu choisis local..." line that earlier
edits had dropped, plus adding new content about the 3.5 Gbit/s plans
and a "we're right around the corner" CTA framing.
FR intro structure now:
1. "Bonjour {{firstname}},"
2. "Tu choisis local, on veut te remercier. / Comme toi, on aime les
connexions stables et les relations durables." (paired manifesto)
3. "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible
pour un temps limité."
4. "Nous offrons maintenant de nouveaux forfaits, jusqu'à 3.5 Gbit/s.
Que tu souhaites plus de vitesse, battre une autre offre ou juste
nous jaser, on est juste à côté."
EN translation mirrors the same 4-block structure.
Editorial rationale for block grouping in MJML:
- Each block is its own <mj-text> for independent drag-drop in GrapesJS
- Lines that always travel together (manifesto pair, upsell + CTA pair)
share one <mj-text> joined with <br/> to reduce component clutter
- Different styles per block (greeting smaller/secondary, manifesto
larger/bolder, body paragraphs normal) require separate <mj-text>
components anyway since MJML inherits styling per-block
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two big moves:
1. Promote MJML to the canonical template format
- Move gift-email-fr-mjml.{mjml,html} → gift-email-fr.{mjml,html}
- Create gift-email-en.mjml (English translation of FR MJML)
- Compile EN MJML → gift-email-en.html
- Remove obsolete variants:
• gift-email-fr-simple.html (now replaced by MJML)
• gift-email-en-simple.html (same)
• gift-email-fr-mjml.* (renamed to canonical)
- The old gift-email-fr.html (rich-with-merchant-grid version) is
backed up as gift-email-fr.legacy-rich.html.bak — kept on disk
for reference but not in the editable list.
- EDITABLE_TEMPLATES is now just ['gift-email-fr', 'gift-email-en'],
both backed by .mjml source + .html auto-compiled output.
2. Add "Envoyer un test" feature
Backend:
- POST /campaigns/templates/:name/test-send accepts { to, vars,
from?, subject? }. Reads compiled .html, renders Mustache vars,
sends via Mailjet through email.sendEmail with X-MJ-CustomID
"test-send:<name>:<timestamp>" so webhook events for tests are
identifiable. Returns { sent, to, from, message_id, bytes }.
- Default vars are sensible: firstname="Louis", amount="60 $",
gift_url="https://gft.link/TEST123", etc. User overrides any
via the request body.
Frontend (TemplateEditorPage):
- Toolbar button "Envoyer un test" (orange) — opens a dialog.
- Dialog has email input + subject + 7 variable inputs
(firstname, lastname, amount, commitment_months, gift_url,
description, expiry) with sensible defaults.
- "Dirty" banner warning: if the user has unsaved changes, the
test will use the LAST SAVED version (so save first to test the
latest). Mentions explicitly in card footer.
- On send: live notification with the message_id + byte count.
Errors surface clearly.
Verified live in prod:
POST /campaigns/templates/gift-email-fr/test-send → 200, message_id
returned, ~32 KB rendered MJML→HTML output, sent from
TARGO <support@targointernet.com> (Mailjet-validated sender).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per user feedback after seeing the rendered preview:
1. Opening line replaced:
FR: "Tu choisis local, on veut te remercier." →
"Comme toi, on aime les connexions stables et les relations durables."
EN: "You went local — we want to say thanks." →
"Just like you, we love stable connections and lasting relationships."
The new line ties the Internet service (stable connections) to the
relationship framing (lasting), which reads more naturally than the
previous "we want to thank you" phrasing.
2. Dark footer band cleanup:
• Removed the CSS-styled TARGO. wordmark (with green dot)
• Removed the official slogan line "Services de confiance, ..."
• Replaced with the actual TARGO logo image (img tag at 120px wide)
The wordmark is now ALWAYS the logo image, never a text styling —
keeps the brand mark consistent across header and footer.
TODO marker left in the HTML pointing to the white-variant logo: the
brand guide §1 specifies targo-logo-white.svg for dark backgrounds, but
we only have the green variant uploaded on Mailjet (UUID eed4d18c-...).
The green logo on the #1C1E26 Targo Dark bg is readable but not
pixel-perfect with the brand. To fix, upload the white variant via the
new /campaigns/assets/upload endpoint and swap the src in both
templates.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User pasted the full HTML block from their Mailjet Passport editor —
extracted the 8 missing CDN URLs for the merchant grid bottom rows and
swapped them into both FR and EN templates.
Final 12-logo grid is now 100% real Mailjet-hosted assets matching the
user's brand-approved visuals (no more placehold.co rectangles):
Row 1: Amazon, IGA, Tim Hortons, $1 Plus (already real)
Row 2: Pizza Pizza, Home Depot, Best Buy, Walmart (NEW)
Row 3: Petro-Canada, Esso, Home Hardware, Sobeys (NEW)
URL pattern: https://xqy3m.mjt.lu/img2/xqy3m/<UUID>/content
Width normalized to 95px (consistent with row 1) instead of the source
template's 300px since our 600px-wide email card means each 25% column
is ~140px effective — 95px image fits with proper margins.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>