From 7d67b64ddc4f1bcde8cb20e4696ca3e09069b297 Mon Sep 17 00:00:00 2001 From: Allison Date: Mon, 27 Apr 2026 19:05:36 -0400 Subject: [PATCH] =?UTF-8?q?fix(marketing):=20pricing=20=E2=80=94=20honest?= =?UTF-8?q?=20ROI=20payback=20+=20capped=20sliders=20+=20URL=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- static/js/roi_calculator.js | 9 +++--- templates/macros/pricing_card.html | 2 +- templates/marketing/landing.html | 10 ++++--- tests/test_marketing_landing_template.py | 38 +++++++++++++++++++++++- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/static/js/roi_calculator.js b/static/js/roi_calculator.js index 996ba67..3b0c889 100644 --- a/static/js/roi_calculator.js +++ b/static/js/roi_calculator.js @@ -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; +}; diff --git a/templates/macros/pricing_card.html b/templates/macros/pricing_card.html index 76b7fc8..46d7640 100644 --- a/templates/macros/pricing_card.html +++ b/templates/macros/pricing_card.html @@ -35,7 +35,7 @@ {% endfor %} {% 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') }} {%- endmacro -%} diff --git a/templates/marketing/landing.html b/templates/marketing/landing.html index 6c447a0..540cc63 100644 --- a/templates/marketing/landing.html +++ b/templates/marketing/landing.html @@ -256,12 +256,12 @@

Économies estimées par an

-

-

+

+

Hypothèses : 80 % du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à DictIA 16 (5 750 $ + 201 $/mois). Estimation à titre indicatif. diff --git a/tests/test_marketing_landing_template.py b/tests/test_marketing_landing_template.py index 67a3e78..922c166 100644 --- a/tests/test_marketing_landing_template.py +++ b/tests/test_marketing_landing_template.py @@ -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

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}"