@zevra/support-widget 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/package.json +26 -0
- package/src/capture.ts +260 -0
- package/src/client-relais.ts +130 -0
- package/src/composants/briques.tsx +146 -0
- package/src/composants/onglet-beta.tsx +156 -0
- package/src/composants/onglet-mes-demandes.tsx +256 -0
- package/src/composants/onglet-signaler.tsx +369 -0
- package/src/composants/tiroir.tsx +163 -0
- package/src/contexte.ts +173 -0
- package/src/index.tsx +241 -0
- package/src/styles.ts +211 -0
- package/src/tampon-erreurs.ts +211 -0
- package/src/types.ts +105 -0
- package/src/validation.ts +113 -0
- package/src/zone.ts +284 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// L'onglet « Signaler » — une phrase, une capture, et c'est parti.
|
|
5
|
+
// ============================================================================
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ UN SEUL CHAMP (03/10/2026). Le formulaire demandait un type, un titre et
|
|
8
|
+
// une description : trois décisions pour quelqu'un qui veut juste dire « ça
|
|
9
|
+
// ne marche pas ». Il ne demande plus que ce qui se passe. Le type et le titre
|
|
10
|
+
// sont posés par le triage IA de Support (`titre_provisoire`,
|
|
11
|
+
// `type_provisoire`), qui ne remplace ainsi aucun choix humain. Un
|
|
12
|
+
// pré-remplissage (retour de bêta) les envoie encore : là, ils sont connus.
|
|
13
|
+
|
|
14
|
+
import { useCallback, useRef, useState } from 'react'
|
|
15
|
+
|
|
16
|
+
import { capturerEcran, deposerBinaire, TYPE_MIME_CAPTURE, type CaptureFaite } from '../capture'
|
|
17
|
+
import { choisirZone } from '../zone'
|
|
18
|
+
import { ErreurRelais, type ClientRelais } from '../client-relais'
|
|
19
|
+
import { relever, type SourceContexte } from '../contexte'
|
|
20
|
+
import { tamponErreurs } from '../tampon-erreurs'
|
|
21
|
+
import type { ContexteReleve, TypeDemande } from '../types'
|
|
22
|
+
import { verifierSignalement } from '../validation'
|
|
23
|
+
import { Alerte, Bouton, Champ } from './briques'
|
|
24
|
+
|
|
25
|
+
export interface PrefillSignalement {
|
|
26
|
+
type?: TypeDemande
|
|
27
|
+
titre?: string
|
|
28
|
+
campagneId?: string
|
|
29
|
+
/** La CLÉ du scénario — c'est elle qui agrège les retours d'une campagne. */
|
|
30
|
+
scenario?: string
|
|
31
|
+
/** Le titre du scénario, pour l'écran. La clé (`connexion-sso`) n'est pas
|
|
32
|
+
* une phrase : affichée telle quelle, elle a l'air d'une fuite technique. */
|
|
33
|
+
scenarioLibelle?: string
|
|
34
|
+
/** Affiche la note 1-5 : n'a de sens que sur un retour de bêta. */
|
|
35
|
+
demanderScore?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProprietesSignaler {
|
|
39
|
+
client: ClientRelais
|
|
40
|
+
/** Gardé pour la compatibilité des intégrations : le formulaire ne propose plus de type. */
|
|
41
|
+
typesOfferts?: readonly TypeDemande[]
|
|
42
|
+
version?: string | null
|
|
43
|
+
contexteSupplementaire?: () => Record<string, unknown>
|
|
44
|
+
prefill?: PrefillSignalement
|
|
45
|
+
captureOfferte: boolean
|
|
46
|
+
onEnvoye?: (reference: string) => void
|
|
47
|
+
/**
|
|
48
|
+
* Abandonner le pré-remplissage : appelé par « Signaler autre chose ».
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ Ce n'est pas un confort. Sans lui, le formulaire suivant repart avec la
|
|
51
|
+
* `campagne_id` et le `scenario` du retour précédent — un bug d'affichage
|
|
52
|
+
* signalé après un retour de bêta serait compté comme un problème DE CE
|
|
53
|
+
* SCÉNARIO, et fausserait le tableau de la campagne sans que rien ne le
|
|
54
|
+
* signale.
|
|
55
|
+
*/
|
|
56
|
+
onAbandonnerPrefill?: () => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Etat =
|
|
60
|
+
| { phase: 'saisie' }
|
|
61
|
+
| { phase: 'envoi' }
|
|
62
|
+
| { phase: 'envoye'; reference: string }
|
|
63
|
+
| { phase: 'echec'; message: string }
|
|
64
|
+
|
|
65
|
+
export function OngletSignaler({
|
|
66
|
+
client,
|
|
67
|
+
version,
|
|
68
|
+
contexteSupplementaire,
|
|
69
|
+
prefill,
|
|
70
|
+
captureOfferte,
|
|
71
|
+
onEnvoye,
|
|
72
|
+
onAbandonnerPrefill,
|
|
73
|
+
}: ProprietesSignaler) {
|
|
74
|
+
// Connus seulement par pré-remplissage : sinon, l'IA de Support les pose.
|
|
75
|
+
const type = prefill?.type ?? null
|
|
76
|
+
const titre = prefill?.titre ?? ''
|
|
77
|
+
const [description, setDescription] = useState('')
|
|
78
|
+
const [score, setScore] = useState<number | null>(null)
|
|
79
|
+
const [capture, setCapture] = useState<CaptureFaite | null>(null)
|
|
80
|
+
const [motifCapture, setMotifCapture] = useState<string | null>(null)
|
|
81
|
+
const [etat, setEtat] = useState<Etat>({ phase: 'saisie' })
|
|
82
|
+
const [montreDefauts, setMontreDefauts] = useState(false)
|
|
83
|
+
const enCapture = useRef(false)
|
|
84
|
+
|
|
85
|
+
const defauts = verifierSignalement({
|
|
86
|
+
type,
|
|
87
|
+
titre,
|
|
88
|
+
description,
|
|
89
|
+
score: prefill?.demanderScore ? score : null,
|
|
90
|
+
})
|
|
91
|
+
const defaut = (champ: string) => (montreDefauts ? defauts[champ] : undefined)
|
|
92
|
+
|
|
93
|
+
// ⚠️ DEUX relevés, et ce n'est pas une redondance.
|
|
94
|
+
//
|
|
95
|
+
// Celui-ci sert à MONTRER ce qui sera joint (le repli « ce qui sera joint
|
|
96
|
+
// automatiquement »). Le relevé qui PART est refait dans `envoyer()`, à
|
|
97
|
+
// l'instant de l'envoi : entre l'ouverture du widget et le clic, la personne
|
|
98
|
+
// a pu naviguer (route côté client), tourner son téléphone, ou provoquer
|
|
99
|
+
// l'erreur qu'elle vient décrire. Montrer un relevé figé au montage et en
|
|
100
|
+
// envoyer un autre serait une promesse non tenue ; n'en faire qu'un, figé,
|
|
101
|
+
// enverrait un contexte qui ne correspond plus à rien.
|
|
102
|
+
const relever_ = useCallback(
|
|
103
|
+
() => relevrLaFenetre(version, contexteSupplementaire),
|
|
104
|
+
[version, contexteSupplementaire],
|
|
105
|
+
)
|
|
106
|
+
// État initial PARESSEUX : le relevé a lieu au montage, pendant le premier
|
|
107
|
+
// rendu, et non dans un effet qui écrirait l'état juste après (ce qui
|
|
108
|
+
// relance un rendu avant la peinture). Le composant n'est monté qu'après un
|
|
109
|
+
// clic — jamais au rendu serveur — donc `window` est là.
|
|
110
|
+
const [contexte, setContexte] = useState<ContexteReleve>(relever_)
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Joint une capture, de la page entière ou d'une zone tracée.
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ RENONCER N'EST PAS ÉCHOUER. Échap pendant le tracé rend `null` : on
|
|
116
|
+
* repart sans message, parce qu'un « la capture a échoué » après un geste
|
|
117
|
+
* d'annulation ferait croire à une panne et relancerait la personne.
|
|
118
|
+
*
|
|
119
|
+
* ⚠️ `try/finally` : sans lui, un tracé abandonné laisserait le verrou posé
|
|
120
|
+
* et le bouton mort pour le reste de la session.
|
|
121
|
+
*/
|
|
122
|
+
async function surCapture(surUneZone: boolean) {
|
|
123
|
+
if (enCapture.current) return
|
|
124
|
+
enCapture.current = true
|
|
125
|
+
setMotifCapture(null)
|
|
126
|
+
try {
|
|
127
|
+
const zone = surUneZone ? await choisirZone() : null
|
|
128
|
+
if (surUneZone && zone === null) return
|
|
129
|
+
|
|
130
|
+
const resultat = await capturerEcran(
|
|
131
|
+
typeof document !== 'undefined' ? document.body : null,
|
|
132
|
+
{ zone },
|
|
133
|
+
)
|
|
134
|
+
if (resultat.faite) setCapture(resultat.capture)
|
|
135
|
+
else setMotifCapture(resultat.motif)
|
|
136
|
+
} finally {
|
|
137
|
+
enCapture.current = false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function envoyer() {
|
|
142
|
+
setMontreDefauts(true)
|
|
143
|
+
if (Object.keys(defauts).length > 0) return
|
|
144
|
+
setEtat({ phase: 'envoi' })
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const pieces = capture ? await deposerLaCapture(client, capture) : []
|
|
148
|
+
const creee = await client.creer({
|
|
149
|
+
type,
|
|
150
|
+
titre: titre.trim() || null,
|
|
151
|
+
description: description.trim(),
|
|
152
|
+
// Relevé À L'INSTANT DE L'ENVOI — voir plus haut.
|
|
153
|
+
contexte: relever_(),
|
|
154
|
+
pieces,
|
|
155
|
+
campagne_id: prefill?.campagneId,
|
|
156
|
+
scenario: prefill?.scenario,
|
|
157
|
+
score: prefill?.demanderScore ? score : null,
|
|
158
|
+
})
|
|
159
|
+
setEtat({ phase: 'envoye', reference: creee.reference })
|
|
160
|
+
onEnvoye?.(creee.reference)
|
|
161
|
+
} catch (erreur) {
|
|
162
|
+
// Le message de l'API est déjà écrit pour être lu (« Le titre est
|
|
163
|
+
// vide »). Le remplacer par un générique perdrait la seule information
|
|
164
|
+
// utile ; le compléter par un code technique la noierait.
|
|
165
|
+
const message =
|
|
166
|
+
erreur instanceof ErreurRelais
|
|
167
|
+
? erreur.message
|
|
168
|
+
: "Votre signalement n'a pas pu partir. Réessayez dans un instant."
|
|
169
|
+
setEtat({ phase: 'echec', message })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (etat.phase === 'envoye') {
|
|
174
|
+
return (
|
|
175
|
+
<>
|
|
176
|
+
<Alerte ton="ok">
|
|
177
|
+
<strong>Merci, c’est envoyé.</strong>
|
|
178
|
+
<br />
|
|
179
|
+
Votre demande porte la référence <strong>{etat.reference}</strong>. Vous la
|
|
180
|
+
retrouverez dans l’onglet « Mes demandes », et la réponse arrivera par courriel.
|
|
181
|
+
</Alerte>
|
|
182
|
+
<Bouton
|
|
183
|
+
variante="fantome"
|
|
184
|
+
onClick={() => {
|
|
185
|
+
setDescription('')
|
|
186
|
+
setCapture(null)
|
|
187
|
+
setMontreDefauts(false)
|
|
188
|
+
setEtat({ phase: 'saisie' })
|
|
189
|
+
// ⚠️ Et on LÂCHE le scénario de bêta. Le garder rattacherait le
|
|
190
|
+
// signalement suivant — qui n'a rien à voir — à la campagne qu'on
|
|
191
|
+
// vient de quitter. Le tiroir change alors la `key` du formulaire,
|
|
192
|
+
// donc ce composant est remonté à neuf : les `setState` ci-dessus
|
|
193
|
+
// portent sur un état qui va disparaître, et c'est sans
|
|
194
|
+
// conséquence (ils rendent la même chose qu'un montage neuf).
|
|
195
|
+
onAbandonnerPrefill?.()
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
Signaler autre chose
|
|
199
|
+
</Bouton>
|
|
200
|
+
</>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<>
|
|
206
|
+
{etat.phase === 'echec' ? <Alerte ton="err">{etat.message}</Alerte> : null}
|
|
207
|
+
|
|
208
|
+
{prefill?.scenario ? (
|
|
209
|
+
<Alerte>
|
|
210
|
+
Ce retour sera rattaché au scénario{' '}
|
|
211
|
+
<strong>{prefill.scenarioLibelle ?? prefill.scenario}</strong> de la campagne en
|
|
212
|
+
cours.
|
|
213
|
+
</Alerte>
|
|
214
|
+
) : null}
|
|
215
|
+
|
|
216
|
+
<Champ
|
|
217
|
+
label="QUE SE PASSE-T-IL ?"
|
|
218
|
+
htmlFor="zvs-description"
|
|
219
|
+
erreur={defaut('description')}
|
|
220
|
+
aide="Une phrase suffit : on s’occupe du reste."
|
|
221
|
+
>
|
|
222
|
+
<textarea
|
|
223
|
+
id="zvs-description"
|
|
224
|
+
className="zvs-zone"
|
|
225
|
+
value={description}
|
|
226
|
+
placeholder="Ex. : le bouton Payer ne réagit plus depuis ce matin."
|
|
227
|
+
autoFocus
|
|
228
|
+
aria-invalid={defaut('description') ? true : undefined}
|
|
229
|
+
aria-describedby={defaut('description') ? 'zvs-description-err' : undefined}
|
|
230
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
231
|
+
onKeyDown={(e) => {
|
|
232
|
+
// ⌘⏎ / Ctrl⏎ envoie, comme dans une messagerie.
|
|
233
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
234
|
+
e.preventDefault()
|
|
235
|
+
void envoyer()
|
|
236
|
+
}
|
|
237
|
+
}}
|
|
238
|
+
/>
|
|
239
|
+
</Champ>
|
|
240
|
+
|
|
241
|
+
{prefill?.demanderScore ? (
|
|
242
|
+
<Champ
|
|
243
|
+
label="VOTRE NOTE SUR CE SCÉNARIO"
|
|
244
|
+
htmlFor="zvs-score"
|
|
245
|
+
erreur={defaut('score')}
|
|
246
|
+
aide="Facultatif — 1 pénible, 5 fluide."
|
|
247
|
+
>
|
|
248
|
+
<select
|
|
249
|
+
id="zvs-score"
|
|
250
|
+
className="zvs-liste-deroulante"
|
|
251
|
+
value={score ?? ''}
|
|
252
|
+
onChange={(e) => setScore(e.target.value === '' ? null : Number(e.target.value))}
|
|
253
|
+
>
|
|
254
|
+
<option value="">Sans avis</option>
|
|
255
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
256
|
+
<option key={n} value={n}>
|
|
257
|
+
{n}
|
|
258
|
+
</option>
|
|
259
|
+
))}
|
|
260
|
+
</select>
|
|
261
|
+
</Champ>
|
|
262
|
+
) : null}
|
|
263
|
+
|
|
264
|
+
{captureOfferte ? (
|
|
265
|
+
<div className="zvs-champ">
|
|
266
|
+
<span className="zvs-label">CAPTURE D’ÉCRAN</span>
|
|
267
|
+
{capture ? (
|
|
268
|
+
<>
|
|
269
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- ce
|
|
270
|
+
paquet n'est pas une app Next : `next/image` n'y existe pas,
|
|
271
|
+
et la source est une data-URL produite à l'instant. */}
|
|
272
|
+
<img className="zvs-vignette" src={capture.apercu} alt="Aperçu de la capture" />
|
|
273
|
+
<Bouton variante="fantome" onClick={() => setCapture(null)}>
|
|
274
|
+
Retirer la capture
|
|
275
|
+
</Bouton>
|
|
276
|
+
</>
|
|
277
|
+
) : (
|
|
278
|
+
<>
|
|
279
|
+
{/* La zone d'abord : c'est le geste qui produit une image
|
|
280
|
+
lisible dans un ticket. La page entière reste à côté, parce
|
|
281
|
+
qu'elle est le seul chemin possible au clavier — on ne trace
|
|
282
|
+
pas un rectangle sans pointeur. */}
|
|
283
|
+
<div className="zvs-ligne">
|
|
284
|
+
<Bouton variante="fantome" onClick={() => surCapture(true)}>
|
|
285
|
+
Choisir une zone
|
|
286
|
+
</Bouton>
|
|
287
|
+
<Bouton variante="fantome" onClick={() => surCapture(false)}>
|
|
288
|
+
Toute la page
|
|
289
|
+
</Bouton>
|
|
290
|
+
</div>
|
|
291
|
+
{motifCapture ? <p className="zvs-aide">{motifCapture}</p> : null}
|
|
292
|
+
</>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
) : null}
|
|
296
|
+
|
|
297
|
+
{/* Le relevé se rafraîchit à l'ouverture du repli : c'est le moment où
|
|
298
|
+
quelqu'un vient VOIR ce qui sera joint, et il doit voir l'état
|
|
299
|
+
courant, pas celui du montage. */}
|
|
300
|
+
<details className="zvs-repli" onToggle={() => setContexte(relever_())}>
|
|
301
|
+
<summary>Ce qui sera joint automatiquement</summary>
|
|
302
|
+
<p className="zvs-aide">
|
|
303
|
+
Ces informations aident à reproduire le problème. Les valeurs des paramètres
|
|
304
|
+
d’URL sont retirées ; seuls leurs noms sont conservés.
|
|
305
|
+
</p>
|
|
306
|
+
<pre className="zvs-code">{JSON.stringify(contexte, null, 2)}</pre>
|
|
307
|
+
</details>
|
|
308
|
+
|
|
309
|
+
<div className="zvs-pied">
|
|
310
|
+
<Bouton onClick={envoyer} occupe={etat.phase === 'envoi'}>
|
|
311
|
+
{etat.phase === 'envoi' ? 'Envoi…' : 'Envoyer'}
|
|
312
|
+
</Bouton>
|
|
313
|
+
</div>
|
|
314
|
+
</>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Le relevé réel, lu sur la fenêtre. Impur, isolé ici pour cette raison. */
|
|
319
|
+
function relevrLaFenetre(
|
|
320
|
+
version: string | null | undefined,
|
|
321
|
+
supplement: (() => Record<string, unknown>) | undefined,
|
|
322
|
+
): ContexteReleve {
|
|
323
|
+
if (typeof window === 'undefined') return {}
|
|
324
|
+
const source: SourceContexte = {
|
|
325
|
+
url: window.location?.href,
|
|
326
|
+
userAgent: window.navigator?.userAgent,
|
|
327
|
+
langue: window.navigator?.language,
|
|
328
|
+
largeur: window.innerWidth,
|
|
329
|
+
hauteur: window.innerHeight,
|
|
330
|
+
version,
|
|
331
|
+
erreurs: tamponErreurs.lire(),
|
|
332
|
+
extra: lireSupplement(supplement),
|
|
333
|
+
}
|
|
334
|
+
return relever(source)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Le supplément vient de l'app hôte : il n'a pas le droit de faire tomber le
|
|
338
|
+
* formulaire de quelqu'un qui essaie de signaler un bug. */
|
|
339
|
+
function lireSupplement(
|
|
340
|
+
supplement: (() => Record<string, unknown>) | undefined,
|
|
341
|
+
): Record<string, unknown> | null {
|
|
342
|
+
if (!supplement) return null
|
|
343
|
+
try {
|
|
344
|
+
return supplement()
|
|
345
|
+
} catch {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Dépose la capture et rend son identifiant.
|
|
352
|
+
*
|
|
353
|
+
* Le fichier part DIRECTEMENT vers S3 par l'URL présignée : il ne traverse ni
|
|
354
|
+
* le relais ni le support. Un échec de dépôt ne fait pas échouer l'envoi — une
|
|
355
|
+
* demande sans sa capture vaut infiniment mieux qu'une demande perdue.
|
|
356
|
+
*/
|
|
357
|
+
async function deposerLaCapture(client: ClientRelais, capture: CaptureFaite): Promise<string[]> {
|
|
358
|
+
try {
|
|
359
|
+
const depot = await client.deposerPiece({
|
|
360
|
+
nom: 'capture.png',
|
|
361
|
+
type_mime: TYPE_MIME_CAPTURE,
|
|
362
|
+
octets: capture.octets,
|
|
363
|
+
})
|
|
364
|
+
const depose = await deposerBinaire(depot.url_put, capture.binaire, TYPE_MIME_CAPTURE)
|
|
365
|
+
return depose ? [depot.id] : []
|
|
366
|
+
} catch {
|
|
367
|
+
return []
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Le tiroir : la coquille des trois onglets.
|
|
5
|
+
// ============================================================================
|
|
6
|
+
//
|
|
7
|
+
// Pourquoi un tiroir plutôt qu'une modale : le widget s'ouvre PAR-DESSUS une
|
|
8
|
+
// page qu'on est en train de décrire. Une modale centrée masque exactement ce
|
|
9
|
+
// dont on parle ; un panneau latéral laisse voir l'écran, et bascule en
|
|
10
|
+
// panneau bas sur téléphone (feuille de style, pas JavaScript — le serveur ne
|
|
11
|
+
// connaît pas la largeur de l'écran).
|
|
12
|
+
|
|
13
|
+
import { useEffect, useRef, useState } from 'react'
|
|
14
|
+
|
|
15
|
+
import type { ClientRelais } from '../client-relais'
|
|
16
|
+
import type { TypeDemande } from '../types'
|
|
17
|
+
import { OngletBeta } from './onglet-beta'
|
|
18
|
+
import { OngletMesDemandes } from './onglet-mes-demandes'
|
|
19
|
+
import { OngletSignaler, type PrefillSignalement } from './onglet-signaler'
|
|
20
|
+
|
|
21
|
+
export type IdOnglet = 'signaler' | 'mes-demandes' | 'beta'
|
|
22
|
+
|
|
23
|
+
const ONGLETS: readonly { id: IdOnglet; libelle: string }[] = [
|
|
24
|
+
{ id: 'signaler', libelle: 'Signaler' },
|
|
25
|
+
{ id: 'mes-demandes', libelle: 'Mes demandes' },
|
|
26
|
+
{ id: 'beta', libelle: 'Bêta' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export interface ProprietesTiroir {
|
|
30
|
+
client: ClientRelais
|
|
31
|
+
titre: string
|
|
32
|
+
typesOfferts: readonly TypeDemande[]
|
|
33
|
+
version?: string | null
|
|
34
|
+
contexteSupplementaire?: () => Record<string, unknown>
|
|
35
|
+
betaOfferte: boolean
|
|
36
|
+
captureOfferte: boolean
|
|
37
|
+
onFermer: () => void
|
|
38
|
+
onEnvoye?: (reference: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Tiroir({
|
|
42
|
+
client,
|
|
43
|
+
titre,
|
|
44
|
+
typesOfferts,
|
|
45
|
+
version,
|
|
46
|
+
contexteSupplementaire,
|
|
47
|
+
betaOfferte,
|
|
48
|
+
captureOfferte,
|
|
49
|
+
onFermer,
|
|
50
|
+
onEnvoye,
|
|
51
|
+
}: ProprietesTiroir) {
|
|
52
|
+
const [onglet, setOnglet] = useState<IdOnglet>('signaler')
|
|
53
|
+
const [prefill, setPrefill] = useState<PrefillSignalement | undefined>()
|
|
54
|
+
const panneau = useRef<HTMLDivElement | null>(null)
|
|
55
|
+
|
|
56
|
+
const onglets = ONGLETS.filter((o) => o.id !== 'beta' || betaOfferte)
|
|
57
|
+
|
|
58
|
+
// Échap ferme. L'écouteur est posé sur le document parce que le focus peut
|
|
59
|
+
// être n'importe où dans le panneau — y compris sur un champ de saisie.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const surTouche = (evenement: KeyboardEvent) => {
|
|
62
|
+
if (evenement.key === 'Escape') onFermer()
|
|
63
|
+
}
|
|
64
|
+
document.addEventListener('keydown', surTouche)
|
|
65
|
+
return () => document.removeEventListener('keydown', surTouche)
|
|
66
|
+
}, [onFermer])
|
|
67
|
+
|
|
68
|
+
// Le focus entre dans le panneau à l'ouverture : sans ce déplacement, le
|
|
69
|
+
// clavier reste derrière le voile et un lecteur d'écran continue d'annoncer
|
|
70
|
+
// la page, sur laquelle on ne peut plus cliquer.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
panneau.current?.focus()
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="zvs-racine">
|
|
77
|
+
{/* Le voile ferme au clic : c'est le geste attendu, et il double le
|
|
78
|
+
bouton pour qui l'aurait manqué. Il n'a pas de rôle ARIA — c'est le
|
|
79
|
+
bouton « Fermer » qui porte l'action pour le clavier. */}
|
|
80
|
+
<div className="zvs-voile" onClick={onFermer} aria-hidden="true" />
|
|
81
|
+
<div
|
|
82
|
+
ref={panneau}
|
|
83
|
+
className="zvs-tiroir"
|
|
84
|
+
role="dialog"
|
|
85
|
+
aria-modal="true"
|
|
86
|
+
aria-label={titre}
|
|
87
|
+
tabIndex={-1}
|
|
88
|
+
>
|
|
89
|
+
<div className="zvs-tete">
|
|
90
|
+
<h2 className="zvs-titre">{titre}</h2>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
className="zvs-fermer"
|
|
94
|
+
onClick={onFermer}
|
|
95
|
+
aria-label="Fermer l’aide"
|
|
96
|
+
>
|
|
97
|
+
✕
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="zvs-onglets" role="tablist" aria-label="Sections de l’aide">
|
|
102
|
+
{onglets.map((entree) => (
|
|
103
|
+
<button
|
|
104
|
+
key={entree.id}
|
|
105
|
+
type="button"
|
|
106
|
+
role="tab"
|
|
107
|
+
id={`zvs-onglet-${entree.id}`}
|
|
108
|
+
aria-selected={onglet === entree.id}
|
|
109
|
+
aria-controls="zvs-panneau"
|
|
110
|
+
className="zvs-onglet"
|
|
111
|
+
onClick={() => {
|
|
112
|
+
setOnglet(entree.id)
|
|
113
|
+
// Changer d'onglet à la main efface le pré-remplissage : sans
|
|
114
|
+
// ça, un retour de bêta resterait rattaché à un scénario que
|
|
115
|
+
// la personne a quitté, et fausserait les statistiques de la
|
|
116
|
+
// campagne sans que personne ne s'en aperçoive.
|
|
117
|
+
if (entree.id === 'signaler') setPrefill(undefined)
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{entree.libelle}
|
|
121
|
+
</button>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div
|
|
126
|
+
className="zvs-corps"
|
|
127
|
+
id="zvs-panneau"
|
|
128
|
+
role="tabpanel"
|
|
129
|
+
aria-labelledby={`zvs-onglet-${onglet}`}
|
|
130
|
+
>
|
|
131
|
+
{onglet === 'signaler' ? (
|
|
132
|
+
<OngletSignaler
|
|
133
|
+
// ⚠️ La `key` force un formulaire NEUF quand le
|
|
134
|
+
// pré-remplissage change : sans elle, React garde l'état de
|
|
135
|
+
// l'ancien et le titre du scénario précédent resterait affiché.
|
|
136
|
+
key={prefill?.scenario ?? 'libre'}
|
|
137
|
+
client={client}
|
|
138
|
+
typesOfferts={typesOfferts}
|
|
139
|
+
version={version}
|
|
140
|
+
contexteSupplementaire={contexteSupplementaire}
|
|
141
|
+
prefill={prefill}
|
|
142
|
+
captureOfferte={captureOfferte}
|
|
143
|
+
onEnvoye={onEnvoye}
|
|
144
|
+
onAbandonnerPrefill={() => setPrefill(undefined)}
|
|
145
|
+
/>
|
|
146
|
+
) : null}
|
|
147
|
+
|
|
148
|
+
{onglet === 'mes-demandes' ? <OngletMesDemandes client={client} /> : null}
|
|
149
|
+
|
|
150
|
+
{onglet === 'beta' ? (
|
|
151
|
+
<OngletBeta
|
|
152
|
+
client={client}
|
|
153
|
+
onSignalerScenario={(valeurs) => {
|
|
154
|
+
setPrefill(valeurs)
|
|
155
|
+
setOnglet('signaler')
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
158
|
+
) : null}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|