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:
Allison
2026-04-27 19:52:36 -04:00
parent 31fada46d4
commit 824ea638de
4 changed files with 324 additions and 1 deletions

View File

@@ -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&nbsp;%. La méthodologie complète (corpus, métriques WER, conditions) est disponible sur demande&nbsp;: <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&nbsp;: 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&nbsp;: 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&nbsp;jours après résiliation (DOCX/PDF/JSON). Passé ce délai, suppression définitive avec confirmation écrite — politique conforme à l\'art.&nbsp;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&nbsp;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&nbsp;: 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,
)

View File

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

View File

@@ -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&nbsp;2026.
</h2>
<p class="text-lg text-brand-navy/70">
Trois pilotes confidentiels en cours&nbsp;: 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 &middot; {{ 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&nbsp;? Écrivez-nous&nbsp;:
<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('&nbsp;', ' '))|tojson }},
"acceptedAnswer": {
"@type": "Answer",
"text": {{ (item.a | striptags | replace('&nbsp;', ' '))|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&nbsp;?</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&nbsp;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 %}

View File

@@ -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&eacute;moignage &agrave; 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&eacute;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 '&nbsp;')
assert '&nbsp;' not in body[body.find('"FAQPage"'):body.find('</script>', body.find('"FAQPage"'))], \
"JSON-LD must not contain raw '&nbsp;' 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&eacute;-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