feat(marketing): pricing 3 forfaits + ROI calculator Alpine.js

This commit is contained in:
Allison
2026-04-27 18:50:33 -04:00
parent b87f35ea4a
commit 0ae4053faa
5 changed files with 270 additions and 0 deletions

View File

@@ -97,6 +97,7 @@
--container-2xl: 42rem; --container-2xl: 42rem;
--container-3xl: 48rem; --container-3xl: 48rem;
--container-4xl: 56rem; --container-4xl: 56rem;
--container-5xl: 64rem;
--container-6xl: 72rem; --container-6xl: 72rem;
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75); --text-xs--line-height: calc(1 / 0.75);
@@ -365,6 +366,9 @@
.end { .end {
inset-inline-end: var(--spacing); inset-inline-end: var(--spacing);
} }
.-top-3 {
top: calc(var(--spacing) * -3);
}
.top-0 { .top-0 {
top: calc(var(--spacing) * 0); top: calc(var(--spacing) * 0);
} }
@@ -575,6 +579,9 @@
.mt-12 { .mt-12 {
margin-top: calc(var(--spacing) * 12); margin-top: calc(var(--spacing) * 12);
} }
.mt-16 {
margin-top: calc(var(--spacing) * 16);
}
.mt-20 { .mt-20 {
margin-top: calc(var(--spacing) * 20); margin-top: calc(var(--spacing) * 20);
} }
@@ -905,6 +912,9 @@
.max-w-4xl { .max-w-4xl {
max-width: var(--container-4xl); max-width: var(--container-4xl);
} }
.max-w-5xl {
max-width: var(--container-5xl);
}
.max-w-6xl { .max-w-6xl {
max-width: var(--container-6xl); max-width: var(--container-6xl);
} }
@@ -1105,6 +1115,9 @@
.items-start { .items-start {
align-items: flex-start; align-items: flex-start;
} }
.items-stretch {
align-items: stretch;
}
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
@@ -1295,6 +1308,9 @@
.rounded-\[18px\] { .rounded-\[18px\] {
border-radius: 18px; border-radius: 18px;
} }
.rounded-\[20px\] {
border-radius: 20px;
}
.rounded-full { .rounded-full {
border-radius: calc(infinity * 1px); border-radius: calc(infinity * 1px);
} }
@@ -2008,6 +2024,9 @@
.p-12 { .p-12 {
padding: calc(var(--spacing) * 12); padding: calc(var(--spacing) * 12);
} }
.p-\[1\.5px\] {
padding: 1.5px;
}
.px-0\.5 { .px-0\.5 {
padding-inline: calc(var(--spacing) * 0.5); padding-inline: calc(var(--spacing) * 0.5);
} }
@@ -2397,6 +2416,9 @@
.text-brand-navy\/70 { .text-brand-navy\/70 {
color: color-mix(in oklab, #060d1a 70%, transparent); color: color-mix(in oklab, #060d1a 70%, transparent);
} }
.text-brand-navy\/80 {
color: color-mix(in oklab, #060d1a 80%, transparent);
}
.text-emerald-500 { .text-emerald-500 {
color: var(--color-emerald-500); color: var(--color-emerald-500);
} }
@@ -2570,6 +2592,9 @@
color: var(--text-muted); color: var(--text-muted);
} }
} }
.accent-brand-b1 {
accent-color: #0062ff;
}
.opacity-0 { .opacity-0 {
opacity: 0%; opacity: 0%;
} }

View File

@@ -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;

View File

@@ -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&nbsp;450&nbsp;$') — piped through | safe
price_monthly : Monthly price string with NBSP (e.g. '173&nbsp;$') — piped through | safe
target : Target audience tagline — piped through | safe (may contain entities)
features : List of feature strings, each piped through | safe (will contain &nbsp; 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') -%}
<div class="relative {% if recommended %}grad-bg p-[1.5px] rounded-[20px]{% endif %}">
{% if recommended %}<span class="absolute -top-3 left-1/2 -translate-x-1/2 grad-bg text-white text-xs font-bold px-3 py-1 rounded-full shadow-cta">★ RECOMMANDÉ</span>{% endif %}
<div class="bg-white p-8 rounded-[18px] border border-brand-border h-full flex flex-col">
<div class="mb-6">
<h3 class="text-xl font-black mb-1 text-brand-navy">{{ name | safe }}</h3>
<p class="text-sm text-brand-navy/70">{{ target | safe }}</p>
</div>
<div class="mb-6">
<div class="text-4xl font-black grad-text">{{ price_setup | safe }}</div>
<div class="text-sm text-brand-navy/70">+ {{ price_monthly | safe }} / mois</div>
</div>
<ul class="space-y-3 mb-8 flex-grow">
{% for f in features %}
<li class="flex items-start gap-2 text-sm text-brand-navy/80">
<span class="grad-text font-black flex-shrink-0" aria-hidden="true"></span>
<span>{{ f | safe }}</span>
</li>
{% endfor %}
</ul>
{% from 'macros/button.html' import button %}
{{ button('Réserver ' + name, href=cta_url + '/' + slug, variant='primary' if recommended else 'secondary', size='lg') }}
</div>
</div>
{%- endmacro -%}

View File

@@ -206,4 +206,83 @@
</div> </div>
</div> </div>
</section> </section>
{# ===== PRICING ===== #}
<section class="bg-brand-bg py-20" id="tarifs" aria-labelledby="pricing-title">
<div class="max-w-[1200px] mx-auto px-6">
<div class="text-center max-w-2xl mx-auto mb-12">
<p class="eyebrow grad-text mb-4">TARIFS</p>
<h2 id="pricing-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-4 text-brand-navy">
Choisissez votre formule.
</h2>
<p class="text-lg text-brand-navy/70">
Tous les forfaits incluent WhisperX Large-v3, volume illimité et zéro frais par utilisateur. Prix en CAD, taxes en sus (TPS&nbsp;5&nbsp;% + TVQ&nbsp;9,975&nbsp;%).
</p>
</div>
{% from 'macros/pricing_card.html' import pricing_card %}
<div class="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto items-stretch">
{{ pricing_card(
'dictia-8',
'DictIA 8',
'3&nbsp;450&nbsp;$',
'173&nbsp;$',
'PME · RH · Manufacturiers',
['GPU 8&nbsp;Go RTX', 'Volume illimité', 'WhisperX FR-CA', 'Diarisation 8 locuteurs', 'Support inclus']
) }}
{{ pricing_card(
'dictia-16',
'DictIA 16',
'5&nbsp;750&nbsp;$',
'201&nbsp;$',
'Cabinets juridiques · CPA · Finance',
['GPU 16&nbsp;Go RTX', 'Mistral 7B local', 'Q&amp;R sur enregistrement', 'Tout DictIA 8', 'Support prioritaire'],
recommended=True
) }}
{{ pricing_card(
'dictia-cloud',
'DictIA Cloud',
'0&nbsp;$',
'369&nbsp;$',
'Organismes · Municipalités · Multi-sites',
['Hébergé OVH Beauharnois (Québec)', 'Opérationnel sous 48&nbsp;h', 'Aucun matériel à gérer', 'SLA visé 99,9&nbsp;%', 'Conforme Loi&nbsp;25']
) }}
</div>
{# ROI CALCULATOR — Alpine.js, hypotheses transparentes pour LPC art. 219 hygiene #}
<div x-data="roiCalculator()" class="mt-16 max-w-3xl mx-auto bg-white p-8 rounded-[18px] border border-brand-border" aria-labelledby="roi-title">
<p class="eyebrow text-center grad-text mb-2">CALCULATEUR ROI</p>
<h3 id="roi-title" class="text-2xl font-black text-center mb-6 text-brand-navy">Combien DictIA peut vous faire économiser&nbsp;?</h3>
<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">
<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">
<span class="text-sm text-brand-navy/70" x-text="hours + ' h/jour'"></span>
</label>
<label class="flex flex-col">
<span class="text-xs font-semibold mb-1 text-brand-navy">Tarif horaire ($)</span>
<input type="range" x-model.number="rate" min="50" max="500" step="25" class="accent-brand-b1" aria-label="Tarif horaire en dollars">
<span class="text-sm text-brand-navy/70" x-text="rate + ' $/h'"></span>
</label>
</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>
</div>
<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.
</p>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/roi_calculator.js" defer></script>
{% endblock %} {% endblock %}

View File

@@ -300,3 +300,106 @@ def test_bento_renders_nbsp_entities_not_escaped():
# Q&R card title: French ampersand must survive as &amp; in HTML, not &amp;amp; # Q&R card title: French ampersand must survive as &amp; in HTML, not &amp;amp;
assert 'Q&amp;R' in body, "Q&R title should appear single-escaped" assert 'Q&amp;R' in body, "Q&R title should appear single-escaped"
assert 'Q&amp;amp;R' not in body, "Q&R title must not be double-escaped" assert 'Q&amp;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&nbsp;450&nbsp;$' in body, "Missing DictIA 8 setup price"
assert '173&nbsp;$' in body, "Missing DictIA 8 monthly price"
assert '5&nbsp;750&nbsp;$' in body, "Missing DictIA 16 setup price"
assert '201&nbsp;$' in body, "Missing DictIA 16 monthly price"
assert '369&nbsp;$' 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&eacute;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 — '&nbsp;' must render single-escaped, not double."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
# GPU sizes use NBSP
assert 'GPU 8&nbsp;Go RTX' in body, "GPU 8 Go feature missing or NBSP double-escaped"
assert 'GPU 16&nbsp;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&amp;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&nbsp;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 '&amp;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&nbsp;%' 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"