feat(marketing): A-2.7b témoignages placeholder + FAQ accordion + CTA + JSON-LD
- Pre-launch testimonials section: 3 placeholder cards (avocat, CPA, municipal)
with persona icons + 'Témoignage à venir' label — NO fabricated quotes
(LPC art. 219). Expected publication mai-juin 2026 from T-4.1 interviews.
- FAQ accordion: 7 verifiable Q&A using Alpine.js core (x-data + x-show +
built-in x-transition; NO x-collapse plugin). Each item has @click toggle,
:aria-expanded, aria-controls, role="region" panel, focusable button.
- Schema.org FAQPage JSON-LD inline at end of FAQ section — striptags +
replace(' ', ' ') to normalize entities for Google FAQ rich result.
- CTA final: 'Réservez votre pré-inscription' (mailto + #tarifs anchor),
cosmic orbs to mirror Hero (page closure), ghost variant secondary button.
- Inline TESTIMONIALS and FAQ Python lists in src/marketing/routes.py
(no PyYAML dep — YAGNI; T-4.1 can introduce it when real data warrants).
- 8 new tests covering testimonials placeholders, forbidden fake names,
7 FAQ panels, Alpine bindings, JSON-LD schema, CTA wording, route data.
This commit is contained in:
@@ -9,6 +9,62 @@ from flask import render_template
|
|||||||
from . import marketing_bp
|
from . import marketing_bp
|
||||||
|
|
||||||
|
|
||||||
|
# Pre-launch placeholder testimonials — T-4.1 will replace these with real
|
||||||
|
# pilot-client interviews (avocat + CPA + municipalité) in mai-juin 2026.
|
||||||
|
# Until then, render placeholder cards (LPC art. 219: no fabricated quotes).
|
||||||
|
TESTIMONIALS = [
|
||||||
|
{
|
||||||
|
'persona': 'avocat',
|
||||||
|
'placeholder_label': 'Cabinet juridique pilote',
|
||||||
|
'expected': 'Mai 2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'persona': 'cpa',
|
||||||
|
'placeholder_label': 'Cabinet CPA pilote',
|
||||||
|
'expected': 'Mai 2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'persona': 'municipal',
|
||||||
|
'placeholder_label': 'Municipalité pilote',
|
||||||
|
'expected': 'Juin 2026',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# FAQ — 7 verifiable Q&A. Each question/answer must remain factually defensible
|
||||||
|
# (LPC art. 219). Answers reference public sources where applicable.
|
||||||
|
FAQ = [
|
||||||
|
{
|
||||||
|
'q': 'Comment DictIA est-il conforme à la Loi 25 (RPRP)?',
|
||||||
|
'a': 'DictIA héberge les données chez OVHcloud Beauharnois (Québec), produit un audit trail intégré conforme à l\'art. 3.5 LPRPSP, fournit un modèle d\'évaluation des facteurs relatifs à la vie privée (EFVP) art. 3.3, et trace les consentements art. 14. Code source AGPL v3 entièrement vérifiable.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Quelle est la différence entre DictIA Cloud et DictIA on-premise?',
|
||||||
|
'a': 'DictIA Cloud (à partir de 369 $/mois) est hébergé chez OVH Beauharnois — aucun matériel à gérer, opérationnel sous 48 h. DictIA on-premise (DictIA 8 et DictIA 16, à partir de 3 450 $ + 173 $/mois) tourne sur GPU dans vos murs — vos données ne sortent jamais de votre réseau.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Quelle précision puis-je attendre pour le français québécois?',
|
||||||
|
'a': 'DictIA utilise WhisperX Large-v3 fine-tuné sur audio professionnel québécois. La précision typique observée sur nos jeux de tests internes dépasse 95 %. La méthodologie complète (corpus, métriques WER, conditions) est disponible sur demande : <a href="mailto:info@dictia.ca" class="grad-text underline">info@dictia.ca</a>.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Pouvez-vous transcrire des audiences ou des interrogatoires confidentiels?',
|
||||||
|
'a': 'Oui, à condition que vous respectiez les obligations de votre ordre (Barreau, Chambre des notaires, etc.) en matière de consentement et de confidentialité. DictIA on-premise est recommandé pour ce type d\'usage : les données ne quittent jamais votre infrastructure. Consultez votre ordre avant tout déploiement.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Quels formats d\'export sont supportés?',
|
||||||
|
'a': 'DOCX, PDF, SRT, VTT, TXT, JSON et MD. Modèles spécifiques disponibles pour avocats (interrogatoire numéroté), notaires (procès-verbal d\'assemblée) et CPA (transcription d\'entrevue). Intégrations natives : Word, Outlook, Teams, Notion, Obsidian, Zapier, Make, n8n.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Que se passe-t-il si je résilie mon abonnement?',
|
||||||
|
'a': 'Vos données restent exportables pendant 90 jours après résiliation (DOCX/PDF/JSON). Passé ce délai, suppression définitive avec confirmation écrite — politique conforme à l\'art. 23 LPRPSP. Aucun frais de résiliation. Détails dans nos <a href="/legal/conditions" class="grad-text underline">conditions d\'utilisation</a>.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'q': 'Le code source est-il vraiment open source AGPL v3?',
|
||||||
|
'a': 'Oui. Code source complet sur <a href="https://gitea.innova-ai.ca/Innova-AI/dictia-public" target="_blank" rel="noopener" class="grad-text underline">Gitea public</a>. Conséquence pratique de l\'AGPL : tout fork hébergé doit publier ses modifications. Transparence vérifiable par vos auditeurs internes ou un tiers de confiance.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@marketing_bp.route('/')
|
@marketing_bp.route('/')
|
||||||
def landing():
|
def landing():
|
||||||
"""Marketing landing page — public, indexable, French-Canadian.
|
"""Marketing landing page — public, indexable, French-Canadian.
|
||||||
@@ -16,4 +72,8 @@ def landing():
|
|||||||
Called directly (not via redirect) from src/api/recordings.py:index
|
Called directly (not via redirect) from src/api/recordings.py:index
|
||||||
when the visitor is anonymous. See B-1.3 fix commit af29539 for context.
|
when the visitor is anonymous. See B-1.3 fix commit af29539 for context.
|
||||||
"""
|
"""
|
||||||
return render_template('marketing/landing.html')
|
return render_template(
|
||||||
|
'marketing/landing.html',
|
||||||
|
testimonials=TESTIMONIALS,
|
||||||
|
faq=FAQ,
|
||||||
|
)
|
||||||
|
|||||||
@@ -414,6 +414,9 @@
|
|||||||
.right-1\.5 {
|
.right-1\.5 {
|
||||||
right: calc(var(--spacing) * 1.5);
|
right: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
|
.right-1\/3 {
|
||||||
|
right: calc(1 / 3 * 100%);
|
||||||
|
}
|
||||||
.right-1\/4 {
|
.right-1\/4 {
|
||||||
right: calc(1 / 4 * 100%);
|
right: calc(1 / 4 * 100%);
|
||||||
}
|
}
|
||||||
@@ -741,6 +744,9 @@
|
|||||||
.h-14 {
|
.h-14 {
|
||||||
height: calc(var(--spacing) * 14);
|
height: calc(var(--spacing) * 14);
|
||||||
}
|
}
|
||||||
|
.h-16 {
|
||||||
|
height: calc(var(--spacing) * 16);
|
||||||
|
}
|
||||||
.h-20 {
|
.h-20 {
|
||||||
height: calc(var(--spacing) * 20);
|
height: calc(var(--spacing) * 20);
|
||||||
}
|
}
|
||||||
@@ -762,6 +768,9 @@
|
|||||||
.h-\[400px\] {
|
.h-\[400px\] {
|
||||||
height: 400px;
|
height: 400px;
|
||||||
}
|
}
|
||||||
|
.h-\[450px\] {
|
||||||
|
height: 450px;
|
||||||
|
}
|
||||||
.h-\[500px\] {
|
.h-\[500px\] {
|
||||||
height: 500px;
|
height: 500px;
|
||||||
}
|
}
|
||||||
@@ -897,6 +906,9 @@
|
|||||||
.w-\[400px\] {
|
.w-\[400px\] {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
}
|
}
|
||||||
|
.w-\[450px\] {
|
||||||
|
width: 450px;
|
||||||
|
}
|
||||||
.w-\[500px\] {
|
.w-\[500px\] {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
@@ -933,6 +945,9 @@
|
|||||||
.max-w-\[300px\] {
|
.max-w-\[300px\] {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
.max-w-\[820px\] {
|
||||||
|
max-width: 820px;
|
||||||
|
}
|
||||||
.max-w-\[1060px\] {
|
.max-w-\[1060px\] {
|
||||||
max-width: 1060px;
|
max-width: 1060px;
|
||||||
}
|
}
|
||||||
@@ -1029,6 +1044,9 @@
|
|||||||
--tw-scale-z: 100%;
|
--tw-scale-z: 100%;
|
||||||
scale: var(--tw-scale-x) var(--tw-scale-y);
|
scale: var(--tw-scale-x) var(--tw-scale-y);
|
||||||
}
|
}
|
||||||
|
.rotate-45 {
|
||||||
|
rotate: 45deg;
|
||||||
|
}
|
||||||
.rotate-180 {
|
.rotate-180 {
|
||||||
rotate: 180deg;
|
rotate: 180deg;
|
||||||
}
|
}
|
||||||
@@ -2276,6 +2294,9 @@
|
|||||||
.text-\[clamp\(2\.5rem\,4vw\,4rem\)\] {
|
.text-\[clamp\(2\.5rem\,4vw\,4rem\)\] {
|
||||||
font-size: clamp(2.5rem, 4vw, 4rem);
|
font-size: clamp(2.5rem, 4vw, 4rem);
|
||||||
}
|
}
|
||||||
|
.text-\[clamp\(2\.25rem\,4vw\,3\.5rem\)\] {
|
||||||
|
font-size: clamp(2.25rem, 4vw, 3.5rem);
|
||||||
|
}
|
||||||
.text-\[clamp\(2rem\,3vw\,2\.75rem\)\] {
|
.text-\[clamp\(2rem\,3vw\,2\.75rem\)\] {
|
||||||
font-size: clamp(2rem, 3vw, 2.75rem);
|
font-size: clamp(2rem, 3vw, 2.75rem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,6 +430,131 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{# ===== TÉMOIGNAGES (placeholder pré-lancement) ===== #}
|
||||||
|
<section class="bg-brand-bg py-20" aria-labelledby="testimonials-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">TÉMOIGNAGES</p>
|
||||||
|
<h2 id="testimonials-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-4 text-brand-navy">
|
||||||
|
Premiers cabinets pilotes interviewés au printemps 2026.
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-brand-navy/70">
|
||||||
|
Trois pilotes confidentiels en cours : un cabinet juridique, un cabinet CPA, une municipalité. Témoignages publiés ici dès l'aboutissement des entrevues, accompagnés d'une métrique vérifiable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-6">
|
||||||
|
{% for t in testimonials %}
|
||||||
|
<article class="bg-white p-6 rounded-[18px] border border-brand-border flex flex-col items-center text-center"
|
||||||
|
aria-label="Témoignage {{ t.placeholder_label }} à venir">
|
||||||
|
<div class="w-16 h-16 rounded-full grad-bg flex items-center justify-center mb-4 shadow-cta" aria-hidden="true">
|
||||||
|
{%- if t.persona == 'avocat' -%}<span class="text-2xl">⚖️</span>
|
||||||
|
{%- elif t.persona == 'cpa' -%}<span class="text-2xl">📊</span>
|
||||||
|
{%- elif t.persona == 'municipal' -%}<span class="text-2xl">🏛️</span>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-bold text-brand-navy mb-1">{{ t.placeholder_label | safe }}</p>
|
||||||
|
<p class="text-xs text-brand-navy/70 mb-4">Témoignage à venir · {{ t.expected | safe }}</p>
|
||||||
|
<p class="text-sm text-brand-navy/70 italic">
|
||||||
|
« Citation et métrique publiées après validation par le pilote. »
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ===== FAQ ===== #}
|
||||||
|
<section class="bg-white py-20" aria-labelledby="faq-title">
|
||||||
|
<div class="max-w-[820px] mx-auto px-6">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<p class="eyebrow grad-text mb-4">FAQ</p>
|
||||||
|
<h2 id="faq-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black mb-4 text-brand-navy">
|
||||||
|
Vos questions les plus fréquentes.
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-brand-navy/70">
|
||||||
|
Une question qui n'est pas couverte ici ? Écrivez-nous :
|
||||||
|
<a href="mailto:info@dictia.ca" class="grad-text font-semibold hover:underline">info@dictia.ca</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-brand-border border-y border-brand-border" role="list">
|
||||||
|
{% for item in faq %}
|
||||||
|
<div x-data="{ open: false }" class="py-2" role="listitem">
|
||||||
|
<h3>
|
||||||
|
<button type="button"
|
||||||
|
class="w-full flex items-center justify-between gap-4 py-4 text-left hover:bg-brand-bg/50 transition-colors rounded-md px-2"
|
||||||
|
@click="open = !open"
|
||||||
|
:aria-expanded="open.toString()"
|
||||||
|
aria-controls="faq-panel-{{ loop.index }}">
|
||||||
|
<span class="font-semibold text-brand-navy text-base">{{ item.q | safe }}</span>
|
||||||
|
<span class="grad-text text-2xl flex-shrink-0 transition-transform"
|
||||||
|
:class="open ? 'rotate-45' : ''" aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div id="faq-panel-{{ loop.index }}"
|
||||||
|
x-show="open"
|
||||||
|
x-transition.opacity.duration.200ms
|
||||||
|
role="region">
|
||||||
|
<p class="px-2 pb-4 text-sm text-brand-navy/80 leading-relaxed">{{ item.a | safe }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Schema.org FAQPage JSON-LD for SEO/GEO — inline so it travels with this page only #}
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": [
|
||||||
|
{%- for item in faq -%}
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": {{ (item.q | striptags | replace(' ', ' '))|tojson }},
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": {{ (item.a | striptags | replace(' ', ' '))|tojson }}
|
||||||
|
}
|
||||||
|
}{{ "," if not loop.last }}
|
||||||
|
{%- endfor -%}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ===== CTA FINAL ===== #}
|
||||||
|
<section class="relative bg-brand-navy text-white py-24 overflow-hidden" aria-labelledby="cta-title">
|
||||||
|
{# Two warm cosmic orbs to mirror the Hero — visual closure of the page #}
|
||||||
|
<div class="absolute inset-0 pointer-events-none" aria-hidden="true">
|
||||||
|
<div class="absolute top-1/4 left-1/3 w-[500px] h-[500px] rounded-full"
|
||||||
|
style="background: radial-gradient(circle, rgba(0,98,255,0.14) 0%, transparent 60%); filter: blur(50px);"></div>
|
||||||
|
<div class="absolute bottom-1/4 right-1/3 w-[450px] h-[450px] rounded-full"
|
||||||
|
style="background: radial-gradient(circle, rgba(0,200,150,0.10) 0%, transparent 60%); filter: blur(50px);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative max-w-[820px] mx-auto px-6 text-center">
|
||||||
|
<p class="eyebrow grad-text mb-4">PRÊT ?</p>
|
||||||
|
<h2 id="cta-title" class="text-[clamp(2.25rem,4vw,3.5rem)] font-black mb-6">
|
||||||
|
Réservez votre <span class="grad-text">pré-inscription</span>.
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-white/80 mb-8">
|
||||||
|
Lancement printemps 2026. Les premiers utilisateurs bénéficient d'une remise de bienvenue et d'un accompagnement direct par notre équipe technique. Aucun engagement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
{% from 'macros/button.html' import button %}
|
||||||
|
{{ button('Pré-inscription par courriel', href='mailto:info@dictia.ca?subject=Pré-inscription%20DictIA', variant='primary', size='lg', icon='✉️') }}
|
||||||
|
{{ button('Voir les forfaits', href='#tarifs', variant='ghost', size='lg') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-white/70 mt-8">
|
||||||
|
Pré-inscription par courriel jusqu'à l'ouverture de la plateforme. Inscription en ligne disponible dès le lancement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
@@ -621,3 +621,120 @@ def test_comparatif_check_marks_consistently_mean_good():
|
|||||||
assert 'Exposé au Cloud Act' not in body, "Row 2 must be reworded to positive convention"
|
assert 'Exposé au Cloud Act' not in body, "Row 2 must be reworded to positive convention"
|
||||||
# Specifically check Teams gets ✗ for the territoriality criterion (was ⚠ before)
|
# Specifically check Teams gets ✗ for the territoriality criterion (was ⚠ before)
|
||||||
assert '✗ Soumis Cloud Act' in body, "Teams must show ✗ for non-Loi-25-compliant transfer"
|
assert '✗ Soumis Cloud Act' in body, "Teams must show ✗ for non-Loi-25-compliant transfer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_testimonials_section_present_with_placeholder_cards():
|
||||||
|
"""Témoignages section present with 3 placeholder cards (no fabricated quotes)."""
|
||||||
|
client = app.test_client()
|
||||||
|
body = client.get('/').data.decode('utf-8')
|
||||||
|
assert 'testimonials-title' in body
|
||||||
|
assert 'Premiers cabinets pilotes' in body
|
||||||
|
# 3 personas
|
||||||
|
assert 'Cabinet juridique pilote' in body
|
||||||
|
assert 'Cabinet CPA pilote' in body
|
||||||
|
assert 'Municipalit' in body and 'pilote' in body
|
||||||
|
# No fabricated quotes — must say "Témoignage à venir"
|
||||||
|
assert 'Témoignage à venir' in body or 'Témoignage à venir' in body, \
|
||||||
|
"Pre-launch testimonials must show 'Témoignage à venir' (LPC art. 219)"
|
||||||
|
# Expected publication months
|
||||||
|
assert 'Mai 2026' in body
|
||||||
|
assert 'Juin 2026' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_testimonials_use_personas_not_fake_names():
|
||||||
|
"""Testimonials must NOT contain fabricated names (Me Tremblay, Mme Bouchard, etc.)."""
|
||||||
|
client = app.test_client()
|
||||||
|
body = client.get('/').data.decode('utf-8')
|
||||||
|
forbidden_names = ['Me Tremblay', 'Mme Bouchard', 'M. Lefebvre',
|
||||||
|
'Cabinet Pilote A', 'Cabinet Pilote B', 'Municipalité Pilote C']
|
||||||
|
for name in forbidden_names:
|
||||||
|
assert name not in body, f"Forbidden fabricated testimonial name: {name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_faq_section_with_7_questions():
|
||||||
|
"""FAQ section present with 7 questions."""
|
||||||
|
client = app.test_client()
|
||||||
|
body = client.get('/').data.decode('utf-8')
|
||||||
|
assert 'faq-title' in body
|
||||||
|
# 7 panel IDs (loop.index is 1-indexed)
|
||||||
|
for i in range(1, 8):
|
||||||
|
assert f'id="faq-panel-{i}"' in body, f"Missing FAQ panel {i}"
|
||||||
|
# Question topic anchors
|
||||||
|
topics = ['Loi', 'Cloud et DictIA on-premise', 'fran', 'audiences', 'formats',
|
||||||
|
'résilie', 'AGPL']
|
||||||
|
for topic in topics:
|
||||||
|
assert topic in body, f"FAQ missing topic anchor: {topic}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_faq_alpine_accordion_bindings():
|
||||||
|
"""FAQ uses Alpine.js x-data + @click + :aria-expanded for accessible accordion."""
|
||||||
|
client = app.test_client()
|
||||||
|
body = client.get('/').data.decode('utf-8')
|
||||||
|
# 7 x-data="{ open: false }" instances
|
||||||
|
assert body.count('x-data="{ open: false }"') == 7, \
|
||||||
|
"FAQ must have 7 independent Alpine accordion items"
|
||||||
|
# Each toggle button has @click and :aria-expanded
|
||||||
|
assert body.count('@click="open = !open"') == 7
|
||||||
|
assert body.count(':aria-expanded="open.toString()"') == 7
|
||||||
|
# Use built-in x-transition (NOT x-collapse plugin which is not bundled)
|
||||||
|
assert 'x-collapse' not in body, "Must NOT use x-collapse plugin (not loaded — use x-transition)"
|
||||||
|
assert 'x-transition.opacity' in body, "FAQ panels must use built-in x-transition"
|
||||||
|
# aria-controls links button to panel
|
||||||
|
assert 'aria-controls="faq-panel-1"' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_faq_jsonld_schema_present():
|
||||||
|
"""FAQ section embeds Schema.org FAQPage JSON-LD for SEO/GEO."""
|
||||||
|
client = app.test_client()
|
||||||
|
body = client.get('/').data.decode('utf-8')
|
||||||
|
# Inline JSON-LD script
|
||||||
|
assert '<script type="application/ld+json">' in body
|
||||||
|
assert '"@type": "FAQPage"' in body
|
||||||
|
assert '"@type": "Question"' in body
|
||||||
|
assert '"@type": "Answer"' in body
|
||||||
|
# NBSP must be normalized to plain space in JSON-LD (Google would otherwise parse ' ')
|
||||||
|
assert ' ' not in body[body.find('"FAQPage"'):body.find('</script>', body.find('"FAQPage"'))], \
|
||||||
|
"JSON-LD must not contain raw ' ' entities — strip them server-side"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cta_final_section():
|
||||||
|
"""CTA final section with mailto pré-inscription + ghost button to #tarifs."""
|
||||||
|
client = app.test_client()
|
||||||
|
body = client.get('/').data.decode('utf-8')
|
||||||
|
assert 'cta-title' in body
|
||||||
|
assert 'pré-inscription' in body or 'pré-inscription' in body
|
||||||
|
# mailto with subject
|
||||||
|
assert 'href="mailto:info@dictia.ca?subject=Pr%C3%A9-inscription%20DictIA"' in body or \
|
||||||
|
'href="mailto:info@dictia.ca?subject=Pré-inscription%20DictIA"' in body, \
|
||||||
|
"CTA must have mailto with subject prefilled"
|
||||||
|
# Anchor link to existing #tarifs section
|
||||||
|
assert 'href="#tarifs"' in body, "Secondary CTA must anchor to pricing"
|
||||||
|
# Ghost variant button
|
||||||
|
assert 'border-white/[0.08]' in body # ghost button class
|
||||||
|
|
||||||
|
|
||||||
|
def test_cta_final_uses_safe_pre_launch_wording():
|
||||||
|
"""CTA must use 'Réservez votre pré-inscription' not 'Achetez maintenant' (pre-launch)."""
|
||||||
|
client = app.test_client()
|
||||||
|
body = client.get('/').data.decode('utf-8')
|
||||||
|
forbidden = ['Achetez maintenant', 'Acheter maintenant', 'Acheter immédiatement',
|
||||||
|
'Acheter dès aujourd', 'Disponible immédiatement']
|
||||||
|
for phrase in forbidden:
|
||||||
|
assert phrase not in body, f"Forbidden pre-launch CTA: {phrase}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes_passes_testimonials_and_faq_to_template():
|
||||||
|
"""marketing.routes.landing() must pass testimonials and faq to render_template."""
|
||||||
|
# Import the module to verify the data lists exist
|
||||||
|
from src.marketing import routes
|
||||||
|
assert hasattr(routes, 'TESTIMONIALS'), "Module must define TESTIMONIALS list"
|
||||||
|
assert hasattr(routes, 'FAQ'), "Module must define FAQ list"
|
||||||
|
assert len(routes.TESTIMONIALS) == 3, "Must have 3 placeholder testimonials"
|
||||||
|
assert len(routes.FAQ) == 7, "Must have 7 FAQ entries"
|
||||||
|
# Each testimonial must NOT contain a 'quote' field (no fabricated quotes pre-launch)
|
||||||
|
for t in routes.TESTIMONIALS:
|
||||||
|
assert 'quote' not in t, "Pre-launch testimonials must not contain quote fields"
|
||||||
|
assert 'persona' in t and 'placeholder_label' in t and 'expected' in t
|
||||||
|
# Each FAQ entry must have 'q' and 'a'
|
||||||
|
for item in routes.FAQ:
|
||||||
|
assert 'q' in item and 'a' in item
|
||||||
|
|||||||
Reference in New Issue
Block a user