fix(marketing): pricing — honest ROI payback + capped sliders + URL hygiene

- ROI payback now returns raw months; template branches to 'moins d'un mois'
  for sub-month paybacks and 'Payable dès la première année' when savings≤0
  (was rounding up to 'Payback : 1 mois' for ~95% of slider combos)
- Cap sliders: users 1..25 (was 50), hours 0.5..4 (was 8) to keep displayed
  savings in a defensible band (~8.8 M$/yr max instead of 35 M$)
- pricing_card href uses cta_url.rstrip('/') to avoid double-slash if caller
  passes a trailing slash (preempts A-2.8 / B-2.7 regression)
- aria-live polite + aria-atomic on the savings paragraph so screen readers
  announce slider updates
- Cleaner JS module pattern: single window.roiCalculator = function() {...}
- Tests updated for payback ternary; new tests for slider caps, aria-live,
  and double-slash guard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-27 19:05:36 -04:00
parent 0ae4053faa
commit 7d67b64ddc
4 changed files with 48 additions and 11 deletions

View File

@@ -3,7 +3,7 @@
// - 80% du temps de transcription manuelle est économisé // - 80% du temps de transcription manuelle est économisé
// - 220 jours ouvrables/an // - 220 jours ouvrables/an
// - Coût annuel comparé = DictIA 16 = 5 750 $ + (201 $ × 12) = 8 162 $ // - Coût annuel comparé = DictIA 16 = 5 750 $ + (201 $ × 12) = 8 162 $
function roiCalculator() { window.roiCalculator = function roiCalculator() {
return { return {
users: 5, users: 5,
hours: 2, hours: 2,
@@ -14,9 +14,8 @@ function roiCalculator() {
}, },
get payback() { get payback() {
const annualCost = 5750 + (201 * 12); const annualCost = 5750 + (201 * 12);
if (this.savings <= 0) return 999; if (this.savings <= 0) return null;
return Math.max(1, Math.round((annualCost / this.savings) * 12)); return (annualCost / this.savings) * 12;
} }
}; };
} };
window.roiCalculator = roiCalculator;

View File

@@ -35,7 +35,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% from 'macros/button.html' import button %} {% from 'macros/button.html' import button %}
{{ button('Réserver ' + name, href=cta_url + '/' + slug, variant='primary' if recommended else 'secondary', size='lg') }} {{ button('Réserver ' + name, href=cta_url.rstrip('/') + '/' + slug, variant='primary' if recommended else 'secondary', size='lg') }}
</div> </div>
</div> </div>
{%- endmacro -%} {%- endmacro -%}

View File

@@ -256,12 +256,12 @@
<div class="grid sm:grid-cols-3 gap-4 mb-6"> <div class="grid sm:grid-cols-3 gap-4 mb-6">
<label class="flex flex-col"> <label class="flex flex-col">
<span class="text-xs font-semibold mb-1 text-brand-navy">Utilisateurs</span> <span class="text-xs font-semibold mb-1 text-brand-navy">Utilisateurs</span>
<input type="range" x-model.number="users" min="1" max="50" step="1" class="accent-brand-b1" aria-label="Nombre d'utilisateurs"> <input type="range" x-model.number="users" min="1" max="25" step="1" class="accent-brand-b1" aria-label="Nombre d'utilisateurs">
<span class="text-sm text-brand-navy/70" x-text="users + ' utilisateur' + (users > 1 ? 's' : '')"></span> <span class="text-sm text-brand-navy/70" x-text="users + ' utilisateur' + (users > 1 ? 's' : '')"></span>
</label> </label>
<label class="flex flex-col"> <label class="flex flex-col">
<span class="text-xs font-semibold mb-1 text-brand-navy">Heures audio / jour</span> <span class="text-xs font-semibold mb-1 text-brand-navy">Heures audio / jour</span>
<input type="range" x-model.number="hours" min="0.5" max="8" step="0.5" class="accent-brand-b1" aria-label="Heures d'audio par jour"> <input type="range" x-model.number="hours" min="0.5" max="4" step="0.5" class="accent-brand-b1" aria-label="Heures d'audio par jour">
<span class="text-sm text-brand-navy/70" x-text="hours + ' h/jour'"></span> <span class="text-sm text-brand-navy/70" x-text="hours + ' h/jour'"></span>
</label> </label>
<label class="flex flex-col"> <label class="flex flex-col">
@@ -272,8 +272,10 @@
</div> </div>
<div class="text-center pt-6 border-t border-brand-border"> <div class="text-center pt-6 border-t border-brand-border">
<p class="text-sm text-brand-navy/70 mb-2">Économies estimées par an</p> <p class="text-sm text-brand-navy/70 mb-2">Économies estimées par an</p>
<p class="text-5xl font-black grad-text" x-text="savings.toLocaleString('fr-CA') + ' $'"></p> <p class="text-5xl font-black grad-text" aria-live="polite" aria-atomic="true"
<p class="text-sm text-brand-navy/70 mt-2" x-text="'Payback : ' + payback + ' mois'"></p> x-text="savings.toLocaleString('fr-CA') + ' $'"></p>
<p class="text-sm text-brand-navy/70 mt-2"
x-text="payback === null ? 'Payable dès la première année' : (payback < 1 ? 'Payback : moins d\'un mois' : 'Payback : ' + Math.round(payback) + ' mois')"></p>
</div> </div>
<p class="text-xs text-brand-navy/70 mt-6 text-center"> <p class="text-xs text-brand-navy/70 mt-6 text-center">
Hypothèses&nbsp;: 80&nbsp;% du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à DictIA 16 (5&nbsp;750&nbsp;$ + 201&nbsp;$/mois). Estimation à titre indicatif. Hypothèses&nbsp;: 80&nbsp;% du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à DictIA 16 (5&nbsp;750&nbsp;$ + 201&nbsp;$/mois). Estimation à titre indicatif.

View File

@@ -386,7 +386,9 @@ def test_roi_calculator_present_with_alpine_bindings():
assert 'x-model.number="rate"' in body assert 'x-model.number="rate"' in body
# Live output bindings # Live output bindings
assert 'x-text="savings' in body assert 'x-text="savings' in body
assert 'x-text="\'Payback' in body assert 'payback === null' in body, "Payback display must use null sentinel branch"
assert "moins d\\'un mois" in body or 'moins d&#39;un mois' in body or "moins d'un mois" in body, \
"Payback display must offer 'moins d'un mois' branch"
# Transparent hypothesis footnote — LPC art. 219 hygiene # Transparent hypothesis footnote — LPC art. 219 hygiene
assert '80&nbsp;%' in body and 'jours ouvrables' in body, "Missing transparent hypothesis footnote" assert '80&nbsp;%' in body and 'jours ouvrables' in body, "Missing transparent hypothesis footnote"
# Sliders accessible (aria-label on each input) # Sliders accessible (aria-label on each input)
@@ -403,3 +405,37 @@ def test_roi_calculator_script_loaded():
roi_pos = body.find('roi_calculator.js') roi_pos = body.find('roi_calculator.js')
assert alpine_pos != -1 and roi_pos != -1 assert alpine_pos != -1 and roi_pos != -1
assert alpine_pos < roi_pos, "Alpine.js must load before roi_calculator.js" assert alpine_pos < roi_pos, "Alpine.js must load before roi_calculator.js"
def test_roi_calculator_sliders_capped_defensibly():
"""Sliders capped: users<=25, hours<=4 (LPC art. 219 hygiene — no $35M screenshots)."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
# Users slider max must be 25, not 50
assert 'x-model.number="users"' in body
assert 'max="25"' in body, "Users slider must cap at 25 (was 50 — too aggressive for marketing claim)"
# Hours slider max must be 4, not 8
assert 'x-model.number="hours"' in body
assert 'max="4"' in body, "Hours slider must cap at 4 (was 8 — too aggressive)"
def test_roi_savings_paragraph_has_aria_live():
"""Savings <p> must announce updates to screen readers on slider change (aria-live polite)."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
# The savings paragraph (the headline number) must be a polite live region
assert 'aria-live="polite"' in body
assert 'aria-atomic="true"' in body
# Verify it's on the savings line, not somewhere unrelated
# (The savings p is the only element with text-5xl in the section)
assert 'text-5xl font-black grad-text' in body
def test_pricing_cta_url_no_double_slash():
"""pricing_card uses cta_url.rstrip('/') so href never has '//' (regression guard)."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
# All 3 CTAs use the default cta_url='/checkout' (no trailing slash) — so /checkout/dictia-X
for slug in ['dictia-8', 'dictia-16', 'dictia-cloud']:
assert f'href="/checkout/{slug}"' in body, f"Missing single-slash href for {slug}"
assert f'href="/checkout//{slug}"' not in body, f"Double-slash regression for {slug}"