From 0ae4053faa17b5605fc4bc060b89f00b54ed7130 Mon Sep 17 00:00:00 2001 From: Allison Date: Mon, 27 Apr 2026 18:50:33 -0400 Subject: [PATCH] feat(marketing): pricing 3 forfaits + ROI calculator Alpine.js --- static/css/marketing.css | 25 ++++++ static/js/roi_calculator.js | 22 +++++ templates/macros/pricing_card.html | 41 +++++++++ templates/marketing/landing.html | 79 +++++++++++++++++ tests/test_marketing_landing_template.py | 103 +++++++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 static/js/roi_calculator.js create mode 100644 templates/macros/pricing_card.html diff --git a/static/css/marketing.css b/static/css/marketing.css index 72b6535..3aef73f 100644 --- a/static/css/marketing.css +++ b/static/css/marketing.css @@ -97,6 +97,7 @@ --container-2xl: 42rem; --container-3xl: 48rem; --container-4xl: 56rem; + --container-5xl: 64rem; --container-6xl: 72rem; --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.75); @@ -365,6 +366,9 @@ .end { inset-inline-end: var(--spacing); } + .-top-3 { + top: calc(var(--spacing) * -3); + } .top-0 { top: calc(var(--spacing) * 0); } @@ -575,6 +579,9 @@ .mt-12 { margin-top: calc(var(--spacing) * 12); } + .mt-16 { + margin-top: calc(var(--spacing) * 16); + } .mt-20 { margin-top: calc(var(--spacing) * 20); } @@ -905,6 +912,9 @@ .max-w-4xl { max-width: var(--container-4xl); } + .max-w-5xl { + max-width: var(--container-5xl); + } .max-w-6xl { max-width: var(--container-6xl); } @@ -1105,6 +1115,9 @@ .items-start { align-items: flex-start; } + .items-stretch { + align-items: stretch; + } .justify-between { justify-content: space-between; } @@ -1295,6 +1308,9 @@ .rounded-\[18px\] { border-radius: 18px; } + .rounded-\[20px\] { + border-radius: 20px; + } .rounded-full { border-radius: calc(infinity * 1px); } @@ -2008,6 +2024,9 @@ .p-12 { padding: calc(var(--spacing) * 12); } + .p-\[1\.5px\] { + padding: 1.5px; + } .px-0\.5 { padding-inline: calc(var(--spacing) * 0.5); } @@ -2397,6 +2416,9 @@ .text-brand-navy\/70 { color: color-mix(in oklab, #060d1a 70%, transparent); } + .text-brand-navy\/80 { + color: color-mix(in oklab, #060d1a 80%, transparent); + } .text-emerald-500 { color: var(--color-emerald-500); } @@ -2570,6 +2592,9 @@ color: var(--text-muted); } } + .accent-brand-b1 { + accent-color: #0062ff; + } .opacity-0 { opacity: 0%; } diff --git a/static/js/roi_calculator.js b/static/js/roi_calculator.js new file mode 100644 index 0000000..996ba67 --- /dev/null +++ b/static/js/roi_calculator.js @@ -0,0 +1,22 @@ +// ROI calculator for DictIA pricing section. +// Hypotheses transparentes (cf. footnote dans landing.html) : +// - 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() { + return { + users: 5, + hours: 2, + rate: 200, + get savings() { + const hoursSaved = this.users * this.hours * 0.8 * 220; + return Math.round(hoursSaved * this.rate); + }, + get payback() { + const annualCost = 5750 + (201 * 12); + if (this.savings <= 0) return 999; + return Math.max(1, Math.round((annualCost / this.savings) * 12)); + } + }; +} +window.roiCalculator = roiCalculator; diff --git a/templates/macros/pricing_card.html b/templates/macros/pricing_card.html new file mode 100644 index 0000000..76b7fc8 --- /dev/null +++ b/templates/macros/pricing_card.html @@ -0,0 +1,41 @@ +{# Reusable pricing card macro. FlexiHub style — recommended tier gets a grad-bg outer border (1.5px gradient frame). + + Args: + slug : URL-safe identifier (goes into href, NOT piped through | safe — autoescape protects URL) + name : Display name (piped through | safe — may contain entities; current names like "DictIA 8" are entity-free) + price_setup : Setup price string with NBSP (e.g. '3 450 $') — piped through | safe + price_monthly : Monthly price string with NBSP (e.g. '173 $') — piped through | safe + target : Target audience tagline — piped through | safe (may contain entities) + features : List of feature strings, each piped through | safe (will contain   entities) + recommended : If True, wraps the card in grad-bg gradient frame + RECOMMANDÉ badge + cta_url : Base URL for the CTA — slug appended (NOT piped through | safe — URL injection guard) + + Note: CTA label is "Réserver [name]" not "Choisir" because product is in pre-launch + (cf. trust bar "Pré-inscription ouverte") — LPC art. 219 hygiene. + The button macro autoescapes its `text` arg, so `name` MUST NOT contain HTML entities + (verified: "DictIA 8", "DictIA 16", "DictIA Cloud" are all entity-free). #} +{%- macro pricing_card(slug, name, price_setup, price_monthly, target, features, recommended=False, cta_url='/checkout') -%} + +{%- endmacro -%} diff --git a/templates/marketing/landing.html b/templates/marketing/landing.html index 4ff42ff..6c447a0 100644 --- a/templates/marketing/landing.html +++ b/templates/marketing/landing.html @@ -206,4 +206,83 @@ + +{# ===== PRICING ===== #} +
+
+
+

TARIFS

+

+ Choisissez votre formule. +

+

+ Tous les forfaits incluent WhisperX Large-v3, volume illimité et zéro frais par utilisateur. Prix en CAD, taxes en sus (TPS 5 % + TVQ 9,975 %). +

+
+ + {% from 'macros/pricing_card.html' import pricing_card %} +
+ {{ pricing_card( + 'dictia-8', + 'DictIA 8', + '3 450 $', + '173 $', + 'PME · RH · Manufacturiers', + ['GPU 8 Go RTX', 'Volume illimité', 'WhisperX FR-CA', 'Diarisation 8 locuteurs', 'Support inclus'] + ) }} + {{ pricing_card( + 'dictia-16', + 'DictIA 16', + '5 750 $', + '201 $', + 'Cabinets juridiques · CPA · Finance', + ['GPU 16 Go RTX', 'Mistral 7B local', 'Q&R sur enregistrement', 'Tout DictIA 8', 'Support prioritaire'], + recommended=True + ) }} + {{ pricing_card( + 'dictia-cloud', + 'DictIA Cloud', + '0 $', + '369 $', + 'Organismes · Municipalités · Multi-sites', + ['Hébergé OVH Beauharnois (Québec)', 'Opérationnel sous 48 h', 'Aucun matériel à gérer', 'SLA visé 99,9 %', 'Conforme Loi 25'] + ) }} +
+ + {# ROI CALCULATOR — Alpine.js, hypotheses transparentes pour LPC art. 219 hygiene #} +
+

CALCULATEUR ROI

+

Combien DictIA peut vous faire économiser ?

+
+ + + +
+
+

É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. +

+
+
+
+{% endblock %} + +{% block scripts %} + {% endblock %} diff --git a/tests/test_marketing_landing_template.py b/tests/test_marketing_landing_template.py index 7af2430..67a3e78 100644 --- a/tests/test_marketing_landing_template.py +++ b/tests/test_marketing_landing_template.py @@ -300,3 +300,106 @@ def test_bento_renders_nbsp_entities_not_escaped(): # Q&R card title: French ampersand must survive as & in HTML, not &amp; assert 'Q&R' in body, "Q&R title should appear single-escaped" assert 'Q&amp;R' not in body, "Q&R title must not be double-escaped" + + +def test_pricing_section_present(): + """Pricing section is present after bento section, with eyebrow + H2 + tax disclaimer.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + assert 'pricing-title' in body, "Missing pricing section anchor" + assert 'Choisissez votre formule' in body, "Missing pricing H2" + # Tax disclaimer must be visible (LPC art. 219 — total cost transparency) + assert 'TPS' in body and 'TVQ' in body, "Missing tax disclaimer (TPS/TVQ)" + + +def test_pricing_3_tiers_with_canonical_amounts(): + """Pricing has 3 tiers: DictIA 8 (3450/173), DictIA 16 (5750/201), DictIA Cloud (0/369).""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # Names + for name in ['DictIA 8', 'DictIA 16', 'DictIA Cloud']: + assert name in body, f"Missing pricing tier: {name}" + # Canonical prices with NBSP per OQLF + assert '3 450 $' in body, "Missing DictIA 8 setup price" + assert '173 $' in body, "Missing DictIA 8 monthly price" + assert '5 750 $' in body, "Missing DictIA 16 setup price" + assert '201 $' in body, "Missing DictIA 16 monthly price" + assert '369 $' in body, "Missing DictIA Cloud monthly price (canonical 369$)" + + +def test_pricing_recommended_tier_is_dictia_16(): + """DictIA 16 is the visually-recommended tier (RECOMMANDÉ badge + grad-bg frame).""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + assert 'RECOMMAND' in body, "Missing RECOMMANDÉ badge" + # The recommended tier wraps in grad-bg p-[1.5px] rounded-[20px] FlexiHub style + assert 'grad-bg p-[1.5px] rounded-[20px]' in body, "Missing FlexiHub gradient frame on recommended tier" + + +def test_pricing_cta_uses_reserver_pre_launch_wording(): + """CTAs say 'Réserver' not 'Choisir' — pre-launch LPC art. 219 hygiene.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + for slug in ['dictia-8', 'dictia-16', 'dictia-cloud']: + assert f'href="/checkout/{slug}"' in body, f"Missing checkout link for {slug}" + assert 'Réserver DictIA 8' in body or 'Réserver DictIA 8' in body, "CTA must use 'Réserver' wording (pre-launch)" + + +def test_pricing_features_use_safe_filter_no_double_escape(): + """Pricing card features piped through | safe — ' ' must render single-escaped, not double.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # GPU sizes use NBSP + assert 'GPU 8 Go RTX' in body, "GPU 8 Go feature missing or NBSP double-escaped" + assert 'GPU 16 Go RTX' in body, "GPU 16 Go feature missing or NBSP double-escaped" + # Q&R card must use French Q&R, not English Q&A + assert 'Q&R' in body, "DictIA 16 must mention Q&R (French), not Q&A (English)" + assert 'Q&A' not in body, "Must use French Q&R consistently — no English Q&A" + # Loi 25 with NBSP + assert 'Conforme Loi 25' in body, "Conforme Loi 25 must use NBSP" + # SLA must be hedged ('visé') not absolute claim + assert 'SLA visé 99,9' in body, "SLA must be hedged 'visé' (pre-launch LPC art. 219 hygiene)" + # Negative: NO double-escape + assert '&nbsp;' not in body, "NBSP must not be double-escaped — | safe missing on pricing macro?" + + +def test_pricing_uses_wcag_safe_text_on_white(): + """Pricing card text uses text-brand-navy/70 or /80 minimum (WCAG AA on white).""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # No regression to weak opacities like /40 or /50 in pricing area + assert 'text-brand-navy/70' in body + # The features list uses /80 in our impl + assert 'text-brand-navy/80' in body, "Feature text should use /80 for WCAG AA" + + +def test_roi_calculator_present_with_alpine_bindings(): + """ROI calculator section present with Alpine.js bindings + transparent hypotheses footnote.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + assert 'CALCULATEUR ROI' in body + assert 'roi-title' in body, "ROI calculator must have aria-labelledby anchor" + assert 'x-data="roiCalculator()"' in body + # Three sliders with x-model.number for type coercion + assert 'x-model.number="users"' in body + assert 'x-model.number="hours"' in body + assert 'x-model.number="rate"' in body + # Live output bindings + assert 'x-text="savings' in body + assert 'x-text="\'Payback' in body + # 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) + assert 'aria-label="Nombre d' in body + + +def test_roi_calculator_script_loaded(): + """roi_calculator.js loaded via {% block scripts %} (deferred after Alpine.js).""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + assert '/static/js/roi_calculator.js' in body, "ROI script must be referenced" + # Must come AFTER alpine.min.js in the document order + alpine_pos = body.find('alpine.min.js') + 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"