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:
@@ -3,7 +3,7 @@
|
||||
// - 80% du temps de transcription manuelle est économisé
|
||||
// - 220 jours ouvrables/an
|
||||
// - Coût annuel comparé = DictIA 16 = 5 750 $ + (201 $ × 12) = 8 162 $
|
||||
function roiCalculator() {
|
||||
window.roiCalculator = function roiCalculator() {
|
||||
return {
|
||||
users: 5,
|
||||
hours: 2,
|
||||
@@ -14,9 +14,8 @@ function roiCalculator() {
|
||||
},
|
||||
get payback() {
|
||||
const annualCost = 5750 + (201 * 12);
|
||||
if (this.savings <= 0) return 999;
|
||||
return Math.max(1, Math.round((annualCost / this.savings) * 12));
|
||||
if (this.savings <= 0) return null;
|
||||
return (annualCost / this.savings) * 12;
|
||||
}
|
||||
};
|
||||
}
|
||||
window.roiCalculator = roiCalculator;
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% 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>
|
||||
{%- endmacro -%}
|
||||
|
||||
@@ -256,12 +256,12 @@
|
||||
<div class="grid sm:grid-cols-3 gap-4 mb-6">
|
||||
<label class="flex flex-col">
|
||||
<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>
|
||||
</label>
|
||||
<label class="flex flex-col">
|
||||
<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>
|
||||
</label>
|
||||
<label class="flex flex-col">
|
||||
@@ -272,8 +272,10 @@
|
||||
</div>
|
||||
<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-5xl font-black grad-text" x-text="savings.toLocaleString('fr-CA') + ' $'"></p>
|
||||
<p class="text-sm text-brand-navy/70 mt-2" x-text="'Payback : ' + payback + ' mois'"></p>
|
||||
<p class="text-5xl font-black grad-text" aria-live="polite" aria-atomic="true"
|
||||
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>
|
||||
<p class="text-xs text-brand-navy/70 mt-6 text-center">
|
||||
Hypothèses : 80 % du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à DictIA 16 (5 750 $ + 201 $/mois). Estimation à titre indicatif.
|
||||
|
||||
@@ -386,7 +386,9 @@ def test_roi_calculator_present_with_alpine_bindings():
|
||||
assert 'x-model.number="rate"' in body
|
||||
# Live output bindings
|
||||
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'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
|
||||
assert '80 %' in body and 'jours ouvrables' in body, "Missing transparent hypothesis footnote"
|
||||
# Sliders accessible (aria-label on each input)
|
||||
@@ -403,3 +405,37 @@ def test_roi_calculator_script_loaded():
|
||||
roi_pos = body.find('roi_calculator.js')
|
||||
assert alpine_pos != -1 and roi_pos != -1
|
||||
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}"
|
||||
|
||||
Reference in New Issue
Block a user