@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.
@@ -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
+ }