feat(marketing): pricing 3 forfaits + ROI calculator Alpine.js
This commit is contained in:
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
22
static/js/roi_calculator.js
Normal file
22
static/js/roi_calculator.js
Normal 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;
|
||||||
41
templates/macros/pricing_card.html
Normal file
41
templates/macros/pricing_card.html
Normal 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 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') -%}
|
||||||
|
<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 -%}
|
||||||
@@ -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 5 % + TVQ 9,975 %).
|
||||||
|
</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 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']
|
||||||
|
) }}
|
||||||
|
</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 ?</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 : 80 % du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à DictIA 16 (5 750 $ + 201 $/mois). Estimation à titre indicatif.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/roi_calculator.js" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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;
|
# 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&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"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user