diff --git a/app/modules/challenge-schema.test.ts b/app/modules/challenge-schema.test.ts index f1fb9346..baee8914 100644 --- a/app/modules/challenge-schema.test.ts +++ b/app/modules/challenge-schema.test.ts @@ -9,9 +9,10 @@ import { describe, expect, it } from 'vitest'; import { STAGE_IDS } from './bowtie-stages'; import { CHALLENGE_KEYS, ChallengeFrontmatterSchema } from './challenge-schema'; -// Copies of the golden fixtures of ocobo-revops/posts#94 -// (`scripts/__tests__/fixtures/challenges/`). Refresh them when the posts -// schema changes. +// Copies of the golden fixtures of the posts repo +// (`scripts/__tests__/fixtures/challenges/`, after posts#96 and posts#97). +// Refresh them when the posts schema changes. Only Sales Compensation has a +// partner block, and Revenue AI has no agent window. const FIXTURES_DIR = join(__dirname, '../test/fixtures/content/challenges/fr'); // Same frontmatter parsing as the content pipeline (`content/processor.ts`). @@ -48,11 +49,17 @@ const issuesOf = (data: unknown) => { : result.error.issues.map(({ path, message }) => ({ path, message })); }; -// Revenue AI sets every heading override. Adding a Story and workstream -// steps gives a file with every optional field set. +// Revenue AI sets every heading override. Adding a Story, workstream steps, +// the Sales Compensation partner block and the Revenue Performance agent +// window with a note gives a file with every optional field set. const complete = (() => { const data = loadFixture('revenue-ai.md'); data.case.story = 'acme'; + data.partner = loadFixture('sales-compensation.md').partner; + data.aiBridge.demo = { + ...loadFixture('revenue-performance.md').aiBridge.demo, + note: 'Comptes et montants fictifs.', + }; data.workstreams.items = data.workstreams.items.map( (item: Record, index: number) => ({ ...item, @@ -83,6 +90,23 @@ describe('challenge golden fixtures', () => { it.each(files)('%s validates', (file) => { expect(issuesOf(loadFixture(file))).toEqual([]); }); + + it('the Revenue AI fixture has no agent window', () => { + expect(loadFixture('revenue-ai.md').aiBridge.demo).toBeUndefined(); + }); + + it.each(files.filter((file) => file !== 'revenue-ai.md'))( + '%s carries an agent window of four elements', + (file) => { + expect(loadFixture(file).aiBridge.demo.elements).toHaveLength(4); + }, + ); + + it('only the Sales Compensation fixture has a partner block', () => { + expect(files.filter((file) => loadFixture(file).partner)).toEqual([ + 'sales-compensation.md', + ]); + }); }); describe('challenge optional fields', () => { @@ -97,6 +121,9 @@ describe('challenge optional fields', () => { ['method.title', ['method', 'title']], ['method step title', ['method', 'steps', 0, 'title']], ['case.story', ['case', 'story']], + ['partner', ['partner']], + ['aiBridge.demo', ['aiBridge', 'demo']], + ['aiBridge.demo.note', ['aiBridge', 'demo', 'note']], ])('accepts a file without %s', (_, path) => { expect(issuesOf(without(complete, path))).toEqual([]); }); @@ -212,8 +239,21 @@ const CAPS: [Path, number][] = [ [['workstreams', 'items', 0, 'description'], 210], [['workstreams', 'items', 0, 'deliverable'], 170], [['workstreams', 'items', 0, 'note'], 100], + [['partner', 'sentence'], 140], + [['partner', 'metrics', 0, 'before'], 12], + [['partner', 'metrics', 0, 'after'], 12], + [['partner', 'metrics', 0, 'label'], 90], + [['partner', 'metrics', 1, 'before'], 12], + [['partner', 'metrics', 1, 'after'], 12], + [['partner', 'metrics', 1, 'label'], 90], + [['partner', 'source'], 90], [['aiBridge', 'title'], 92], [['aiBridge', 'bullets', 0], 125], + [['aiBridge', 'demo', 'channel'], 28], + [['aiBridge', 'demo', 'agent'], 24], + [['aiBridge', 'demo', 'opening', 'text'], 70], + [['aiBridge', 'demo', 'footer'], 60], + [['aiBridge', 'demo', 'note'], 90], [['method', 'title'], 30], [['method', 'subtitle'], 75], [['method', 'steps', 0, 'title'], 28], @@ -246,7 +286,9 @@ const COUNTS: [Path, number, number][] = [ [['symptoms', 'items'], 5, 6], [['audience'], 4, 4], [['workstreams', 'items'], 4, 6], + [['partner', 'metrics'], 2, 2], [['aiBridge', 'bullets'], 3, 3], + [['aiBridge', 'demo', 'elements'], 1, 4], [['method', 'steps'], 3, 3], [['method', 'tools'], 0, 9], [['case', 'metrics'], 2, 2], @@ -290,27 +332,314 @@ describe('challenge unknown keys', () => { ]); }); - // Both come in #230, with the matching posts schema change. The test checks - // the key is unknown, so it fails as soon as the schema knows it. - it.each<[string, Path]>([ - ['partner', ['partner']], - ['aiBridge.demo', ['aiBridge', 'demo']], - ])('rejects %s as an unknown key until #230', (key, path) => { - const result = ChallengeFrontmatterSchema.safeParse( - withValue(complete, path, {}), - ); - expect(result.error?.issues).toEqual([ - expect.objectContaining({ - code: 'unrecognized_keys', + it.each<[Path]>([ + [['hero', 'subtitle']], + [['partner', 'logo']], + [['partner', 'metrics', 0, 'trend']], + [['aiBridge', 'demo', 'avatar']], + [['aiBridge', 'demo', 'opening', 'emoji']], + ])('rejects the unknown key %j', (path) => { + expect(issuesOf(withValue(complete, path, 'x'))).toEqual([ + { path: path.slice(0, -1), - keys: [key.split('.').pop()], - }), + message: `Unrecognized key: "${path[path.length - 1]}"`, + }, + ]); + }); +}); + +describe('challenge partner block', () => { + it.each([1, 3])('rejects %i metrics', (count) => { + const metrics = Array.from({ length: count }, () => + structuredClone(complete.partner.metrics[0]), + ); + expect( + issuesOf(withValue(complete, ['partner', 'metrics'], metrics)), + ).toEqual([{ path: ['partner', 'metrics'], message: expect.any(String) }]); + }); + + it.each<[Path]>([ + [['partner', 'tool']], + [['partner', 'sentence']], + [['partner', 'metrics']], + [['partner', 'source']], + [['partner', 'metrics', 1, 'after']], + ])('rejects a missing %j', (path) => { + expect(issuesOf(without(complete, path))).toEqual([ + { path, message: expect.any(String) }, + ]); + }); + + it('rejects a blank Tool slug', () => { + expect(issuesOf(withValue(complete, ['partner', 'tool'], ' '))).toEqual([ + { path: ['partner', 'tool'], message: 'must not be empty' }, + ]); + }); +}); + +const DEMO: Path = ['aiBridge', 'demo']; +const ELEMENTS_PATH: Path = [...DEMO, 'elements']; + +// One valid element of each type, from the mockup windows. +const ELEMENTS: Record> = { + kpis: { + type: 'kpis', + items: [ + { value: '1,24 M€', label: 'commit' }, + { value: '82 %', label: "de l'objectif" }, + { value: '+6 pts', label: 'vs. S-1' }, + ], + }, + figure: { + type: 'figure', + value: '3 840 €', + caption: 'de commission si signé ce mois-ci', + }, + progress: { type: 'progress', percent: 82, target: 100 }, + status: { + type: 'status', + label: 'Contrôle', + value: '1 deal compté deux fois', + state: 'alert', + }, + buttons: { type: 'buttons', labels: ['Ouvrir le compte', 'Préparer le RDV'] }, +}; + +const withElement = (element: Record) => + withValue(complete, ELEMENTS_PATH, [element]); + +const elementWith = (type: string, path: Path, value: unknown) => + withValue(ELEMENTS[type], path, value); + +// Same table as the posts repo tests: [type, path in the element, cap]. +const ELEMENT_CAPS: [string, Path, number][] = [ + ['kpis', ['items', 0, 'value'], 10], + ['kpis', ['items', 2, 'label'], 16], + ['figure', ['value'], 12], + ['figure', ['caption'], 48], + ['status', ['label'], 24], + ['status', ['value'], 32], + ['buttons', ['labels', 1], 20], +]; + +// Every required field of each element type. +const ELEMENT_FIELDS: [string, string][] = [ + ['kpis', 'items'], + ['figure', 'value'], + ['figure', 'caption'], + ['progress', 'percent'], + ['status', 'label'], + ['status', 'value'], + ['status', 'state'], + ['buttons', 'labels'], +]; + +describe('challenge agent window', () => { + it.each(Object.keys(ELEMENTS))('accepts a %s element', (type) => { + expect(issuesOf(withElement(ELEMENTS[type]))).toEqual([]); + }); + + it('accepts four elements of mixed types', () => { + const { figure, progress, status, buttons } = ELEMENTS; + expect( + issuesOf( + withValue(complete, ELEMENTS_PATH, [figure, progress, status, buttons]), + ), + ).toEqual([]); + }); + + it('rejects a fifth element', () => { + expect( + issuesOf(withValue(complete, ELEMENTS_PATH, Object.values(ELEMENTS))), + ).toEqual([{ path: ELEMENTS_PATH, message: expect.any(String) }]); + }); + + it('rejects an unknown element type', () => { + expect(issuesOf(withElement({ type: 'chart', value: '12' }))).toEqual([ + { path: [...ELEMENTS_PATH, 0, 'type'], message: expect.any(String) }, + ]); + }); + + it('rejects an element without a type', () => { + expect(issuesOf(withElement(without(ELEMENTS.figure, ['type'])))).toEqual([ + { path: [...ELEMENTS_PATH, 0, 'type'], message: expect.any(String) }, + ]); + }); + + it.each(ELEMENT_CAPS)('%s %j accepts %i characters', (type, path, cap) => { + expect( + issuesOf(withElement(elementWith(type, path, 'a'.repeat(cap)))), + ).toEqual([]); + }); + + it.each(ELEMENT_CAPS)( + '%s %j rejects one character over cap %i', + (type, path, cap) => { + expect( + issuesOf(withElement(elementWith(type, path, 'a'.repeat(cap + 1)))), + ).toEqual([ + { + path: [...ELEMENTS_PATH, 0, ...path], + message: `${cap + 1} characters, cap ${cap} (1 over)`, + }, + ]); + }, + ); + + it.each(ELEMENT_FIELDS)('rejects a %s element without %s', (type, field) => { + expect(issuesOf(withElement(without(ELEMENTS[type], [field])))).toEqual([ + { path: [...ELEMENTS_PATH, 0, field], message: expect.any(String) }, ]); }); - it('rejects an unknown key in a nested object', () => { + it.each(Object.keys(ELEMENTS))( + 'rejects an unknown key in a %s element', + (type) => { + expect(issuesOf(withElement({ ...ELEMENTS[type], icon: 'x' }))).toEqual([ + { path: [...ELEMENTS_PATH, 0], message: 'Unrecognized key: "icon"' }, + ]); + }, + ); + + it('rejects an unknown key in a KPI', () => { + expect( + issuesOf(withElement(elementWith('kpis', ['items', 0, 'trend'], 'x'))), + ).toEqual([ + { + path: [...ELEMENTS_PATH, 0, 'items', 0], + message: 'Unrecognized key: "trend"', + }, + ]); + }); + + it.each([2, 4])('rejects %i KPIs', (count) => { + const items = Array.from( + { length: count }, + (_, index) => ELEMENTS.kpis.items[index % 3], + ); + expect(issuesOf(withElement({ type: 'kpis', items }))).toEqual([ + { path: [...ELEMENTS_PATH, 0, 'items'], message: expect.any(String) }, + ]); + }); + + it.each([[[]], [['Un', 'Deux', 'Trois']]])( + 'rejects buttons with %j labels', + (labels) => { + expect(issuesOf(withElement({ type: 'buttons', labels }))).toEqual([ + { path: [...ELEMENTS_PATH, 0, 'labels'], message: expect.any(String) }, + ]); + }, + ); + + it('accepts a single button', () => { expect( - issuesOf(withValue(complete, ['hero', 'subtitle'], 'Extra')), - ).toEqual([expect.objectContaining({ path: ['hero'] })]); + issuesOf(withElement({ type: 'buttons', labels: ['Ouvrir le compte'] })), + ).toEqual([]); + }); + + it('accepts a progress bar without a target', () => { + expect(issuesOf(withElement({ type: 'progress', percent: 82 }))).toEqual( + [], + ); + }); + + it.each<[string, number]>([ + ['percent', 0], + ['percent', 100], + ['target', 0], + ['target', 100], + ])('accepts progress %s %i', (field, value) => { + expect( + issuesOf(withElement(elementWith('progress', [field], value))), + ).toEqual([]); + }); + + it.each<[string, unknown]>([ + ['percent', -1], + ['percent', 101], + ['percent', 82.5], + ['percent', '82'], + ['target', 101], + ['target', 99.5], + ])('rejects progress %s %j', (field, value) => { + expect( + issuesOf(withElement(elementWith('progress', [field], value))), + ).toEqual([ + { path: [...ELEMENTS_PATH, 0, field], message: expect.any(String) }, + ]); + }); + + it.each(['ok', 'alert'])('accepts status state %s', (state) => { + expect( + issuesOf(withElement(elementWith('status', ['state'], state))), + ).toEqual([]); + }); + + it('rejects an unknown status state', () => { + expect( + issuesOf(withElement(elementWith('status', ['state'], 'warning'))), + ).toEqual([ + { path: [...ELEMENTS_PATH, 0, 'state'], message: expect.any(String) }, + ]); + }); + + it.each(['question', 'statement'])('accepts opening kind %s', (kind) => { + expect( + issuesOf(withValue(complete, [...DEMO, 'opening', 'kind'], kind)), + ).toEqual([]); + }); + + it('rejects an unknown opening kind', () => { + expect( + issuesOf(withValue(complete, [...DEMO, 'opening', 'kind'], 'answer')), + ).toEqual([ + { path: [...DEMO, 'opening', 'kind'], message: expect.any(String) }, + ]); + }); + + it.each(['00:00', '09:12', '23:59'])('accepts time %s', (time) => { + expect(issuesOf(withValue(complete, [...DEMO, 'time'], time))).toEqual([]); + }); + + it.each(['24:00', '8:05', '08:60', '08h05', '08:05:00', ' 08:05'])( + 'rejects time %j', + (time) => { + expect(issuesOf(withValue(complete, [...DEMO, 'time'], time))).toEqual([ + { path: [...DEMO, 'time'], message: 'must be a 24-hour time, HH:MM' }, + ]); + }, + ); + + // The website parser (YAML 1.2 core schema) keeps an unquoted 14:32 as a + // string, so the file passes here. + it('accepts an unquoted time as the website parses it', () => { + const { time } = yaml.load('time: 14:32', { + schema: yaml.CORE_SCHEMA, + }) as { time: unknown }; + expect(time).toBe('14:32'); + expect(issuesOf(withValue(complete, [...DEMO, 'time'], time))).toEqual([]); + }); + + // The posts repo parses YAML 1.1, which reads the same 14:32 as 872 and + // rejects the file. Quote the time so both repos accept it. + it('asks to quote a time read as a number', () => { + expect(issuesOf(withValue(complete, [...DEMO, 'time'], 872))).toEqual([ + { path: [...DEMO, 'time'], message: 'quote the time, e.g. "14:32"' }, + ]); + }); + + it.each<[Path]>([ + [['channel']], + [['agent']], + [['time']], + [['opening']], + [['opening', 'kind']], + [['opening', 'text']], + [['elements']], + [['footer']], + ])('rejects a missing %j', (path) => { + expect(issuesOf(without(complete, [...DEMO, ...path]))).toEqual([ + { path: [...DEMO, ...path], message: expect.any(String) }, + ]); }); }); diff --git a/app/modules/challenge-schema.ts b/app/modules/challenge-schema.ts index 5b89a3bb..5fcbbc2c 100644 --- a/app/modules/challenge-schema.ts +++ b/app/modules/challenge-schema.ts @@ -2,11 +2,10 @@ * Challenge frontmatter schema: one file per challenge in * `challenges//.md`, frontmatter only. * - * Mirrors the core schema of the posts repo - * (`scripts/schemas/challenge.schema.js`, ocobo-revops/posts#94). The website - * must accept every file the posts repo accepts, so both schemas change in - * the same pair of PRs. The agent window demo (`aiBridge.demo`) and the - * partner block come in #230. + * Mirrors the schema of the posts repo (`scripts/schemas/challenge.schema.js`: + * core fields from ocobo-revops/posts#94, partner block from posts#97, agent + * window from posts#96). The website must accept every file the posts repo + * accepts, so both schemas change in the same pair of PRs. */ import { z } from 'zod'; @@ -54,6 +53,94 @@ const workstream = z.strictObject({ note: cappedText(100).optional(), }); +const percentage = z.number().int().min(0).max(100); + +/** + * Agent window: a frame and up to four typed body elements, rendered in + * order. Every mockup window has four. + */ +const demoElement = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('kpis'), + items: z + .array( + z.strictObject({ + value: cappedText(10), + label: cappedText(16), + }), + ) + .length(3), + }), + z.strictObject({ + type: z.literal('figure'), + value: cappedText(12), + caption: cappedText(48), + }), + z.strictObject({ + type: z.literal('progress'), + percent: percentage, + target: percentage.optional(), + }), + z.strictObject({ + type: z.literal('status'), + label: cappedText(24), + value: cappedText(32), + state: z.enum(['ok', 'alert']), + }), + z.strictObject({ + type: z.literal('buttons'), + labels: z.array(cappedText(20)).min(1).max(2), + }), +]); + +const demo = z.strictObject({ + channel: cappedText(28), + agent: cappedText(24), + /** + * Not trimmed: a fixed-format value, compared exactly between the French + * and English files. The posts repo parses YAML 1.1, which reads an + * unquoted 14:32 as the base 60 number 872, hence the hint. + */ + time: z + .string({ + error: (issue) => + typeof issue.input === 'number' + ? 'quote the time, e.g. "14:32"' + : undefined, + }) + .regex(/^([01]\d|2[0-3]):[0-5]\d$/, { + error: 'must be a 24-hour time, HH:MM', + }), + opening: z.strictObject({ + /** A question renders in quotes, a statement as plain agent text. */ + kind: z.enum(['question', 'statement']), + text: cappedText(70), + }), + elements: z.array(demoElement).min(1).max(4), + footer: cappedText(60), + /** Shown under the window (fictional accounts). */ + note: cappedText(90).optional(), +}); + +/** + * Open to every challenge (ADR 0002). The logo comes from the Tool file, and + * the figures belong to the partner, so their source is required. + */ +const partner = z.strictObject({ + tool: slug, + sentence: cappedText(140), + metrics: z + .array( + z.strictObject({ + before: cappedText(12), + after: cappedText(12), + label: cappedText(90), + }), + ) + .length(2), + source: cappedText(90), +}); + /** * Strict at every level: an unknown key (for example `extraSections`) fails * validation instead of being dropped. @@ -93,9 +180,12 @@ export const ChallengeFrontmatterSchema = z.strictObject({ intro: cappedText(120), items: z.array(workstream).min(4).max(6), }), + partner: partner.optional(), aiBridge: z.strictObject({ title: cappedText(92), bullets: z.array(cappedText(125)).length(3), + /** Revenue AI has no agent window. */ + demo: demo.optional(), }), method: z.strictObject({ /** Optional override of the heading from `locales/` (#234 decides). */ diff --git a/app/test/fixtures/content/challenges/fr/customer-experience.md b/app/test/fixtures/content/challenges/fr/customer-experience.md index 9c56f322..3d57aa2c 100644 --- a/app/test/fixtures/content/challenges/fr/customer-experience.md +++ b/app/test/fixtures/content/challenges/fr/customer-experience.md @@ -76,6 +76,31 @@ aiBridge: - Les tickets, mails et appels sont lus pour détecter un risque ou une opportunité. - Chaque chargé de compte reçoit un résumé du compte avant son rendez-vous. - Les revues de compte se préparent en quelques minutes. + demo: + channel: Alerte compte · Kestrel + agent: Agent Customer + time: "09:12" + opening: + kind: statement + text: "Risque de départ détecté. Score de santé : 72 → 54." + elements: + - type: status + label: Support + value: 3 tickets critiques en 10 j + state: alert + - type: status + label: Usage + value: "-30 % de connexions" + state: alert + - type: status + label: Appel QBR + value: Concurrent cité 2 fois + state: alert + - type: buttons + labels: + - Ouvrir le compte + - Préparer le RDV + footer: Résumé envoyé à Julie avant son RDV de 14:00 method: subtitle: De l'analyse du portefeuille à un revenu récurrent piloté. steps: diff --git a/app/test/fixtures/content/challenges/fr/revenue-performance.md b/app/test/fixtures/content/challenges/fr/revenue-performance.md index 075ea75f..bedf110b 100644 --- a/app/test/fixtures/content/challenges/fr/revenue-performance.md +++ b/app/test/fixtures/content/challenges/fr/revenue-performance.md @@ -66,6 +66,34 @@ aiBridge: - Les comptes rendus d'appel mettent à jour le CRM sans saisie. - Les deals à risque sont signalés avant la revue de pipe. - Le dirigeant pose sa question en français et obtient le chiffre. + demo: + channel: "# direction" + agent: Agent Forecast + time: "08:05" + opening: + kind: question + text: Où en est-on du trimestre ? + elements: + - type: kpis + items: + - value: 1,24 M€ + label: commit + - value: 82 % + label: de l'objectif + - value: +6 pts + label: vs. S-1 + - type: progress + percent: 82 + target: 100 + - type: status + label: Brightlane · 180 k€ + value: Sans activité 14 j + state: alert + - type: status + label: Kestrel · 95 k€ + value: Date de signature repoussée + state: alert + footer: Calculé sur HubSpot, mis à jour ce matin method: subtitle: Du diagnostic à un chiffre que vos équipes tiennent seules. steps: diff --git a/app/test/fixtures/content/challenges/fr/revenue-systems.md b/app/test/fixtures/content/challenges/fr/revenue-systems.md index 91caa8b1..79a3dc2b 100644 --- a/app/test/fixtures/content/challenges/fr/revenue-systems.md +++ b/app/test/fixtures/content/challenges/fr/revenue-systems.md @@ -79,6 +79,31 @@ aiBridge: - Les saisies se remplissent à partir des mails, des appels et des documents. - Vos assistants IA se connectent au CRM dans un cadre de sécurité maîtrisé. - Les règles de qualité de données sont surveillées en continu. + demo: + channel: Qualité de données + agent: Agent Data + time: "07:00" + opening: + kind: statement + text: Contrôle quotidien du CRM terminé. + elements: + - type: status + label: Doublons + value: 0,4 % + state: ok + - type: status + label: Champs obligatoires + value: 98 % remplis + state: ok + - type: status + label: Fiches complétées + value: 24 depuis les emails + state: ok + - type: status + label: Contacts sans compte + value: 12 à rattacher + state: alert + footer: Connecteur IA ↔ HubSpot · accès en lecture encadré method: subtitle: De l'audit de la stack à une équipe qui l'administre seule. steps: diff --git a/app/test/fixtures/content/challenges/fr/sales-compensation.md b/app/test/fixtures/content/challenges/fr/sales-compensation.md index f7b2f432..cfbc4ca8 100644 --- a/app/test/fixtures/content/challenges/fr/sales-compensation.md +++ b/app/test/fixtures/content/challenges/fr/sales-compensation.md @@ -68,12 +68,47 @@ workstreams: title: Un plan qui s'ajuste sur des faits description: Mesure de l'effet du plan sur les comportements, traitement des cas particuliers, revue annuelle avec vos RH. deliverable: Un rituel de revue et des règles de gestion des exceptions. +partner: + tool: qobra + sentence: "Qobra automatise le calcul. Ocobo conçoit le plan qu'il calcule, et s'assure qu'il produit les comportements attendus." + metrics: + - before: 7 jours + after: "1" + label: "Clôture mensuelle des commissions chez Doctolib (source : Qobra)" + - before: 3 jours + after: ½ + label: "Clôture mensuelle des commissions chez Pennylane (source : Qobra)" + source: Chiffres publiés par Qobra sur ses clients. aiBridge: title: Chaque commercial sait ce que lui rapporte le deal sur lequel il travaille. bullets: - Le commercial pose sa question en français et obtient le détail de sa commission. - Les scénarios de plan se simulent en quelques minutes, sur vos vraies données. - Les anomalies de calcul sont repérées avant la paie. + demo: + channel: Assistant variable + agent: Assistant variable + time: "10:18" + opening: + kind: question + text: Combien me rapporte le deal Brightlane ? + elements: + - type: figure + value: 3 840 € + caption: de commission si signé ce mois-ci + - type: status + label: Taux de base + value: 8 % × 48 k€ + state: ok + - type: status + label: Accélérateur + value: Palier 110 % atteint + state: ok + - type: status + label: Contrôle + value: 1 deal compté deux fois + state: alert + footer: Calcul vérifié avant la paie method: subtitle: De l'analyse du plan actuel à un variable qui se pilote seul. steps: diff --git a/app/test/fixtures/content/challenges/fr/sales-experience.md b/app/test/fixtures/content/challenges/fr/sales-experience.md index c5b87263..46b620f1 100644 --- a/app/test/fixtures/content/challenges/fr/sales-experience.md +++ b/app/test/fixtures/content/challenges/fr/sales-experience.md @@ -76,6 +76,31 @@ aiBridge: - "Les appels alimentent le CRM : qualification, objections, prochaines étapes." - Les écarts au processus remontent dans les tableaux de bord des managers. - Le playbook arrive dans l'outil du commercial, au moment où il en a besoin. + demo: + channel: Agent CRM · Northwind + agent: Agent CRM + time: "14:32" + opening: + kind: statement + text: "Appel Northwind terminé (28 min). Fiche mise à jour :" + elements: + - type: status + label: Besoin + value: Structurer 3 SDR + state: ok + - type: status + label: Objection + value: Prix vs. outil actuel + state: ok + - type: status + label: Prochaine étape + value: Démo le 12 + state: ok + - type: status + label: Décideur + value: Non identifié + state: alert + footer: Écart au process signalé au manager method: subtitle: De l'analyse du funnel à une organisation qui tient sans nous. steps: