@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,156 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// L'onglet « Bêta » — la campagne en cours et ses scénarios.
|
|
5
|
+
// ============================================================================
|
|
6
|
+
//
|
|
7
|
+
// Il lit `GET /api/support/programmes/actif`, que le relais transmet à
|
|
8
|
+
// `GET /api/v1/programmes/actif` (PLAN.md §4).
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ « Pas de campagne en cours » est un ÉTAT NORMAL, pas une panne : la
|
|
11
|
+
// plupart du temps, une app n'a aucune bêta ouverte. L'écran le dit calmement
|
|
12
|
+
// et n'affiche ni erreur, ni bouton « réessayer ».
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
15
|
+
|
|
16
|
+
import { ErreurRelais, type ClientRelais } from '../client-relais'
|
|
17
|
+
import type { ProgrammeActif } from '../types'
|
|
18
|
+
import type { PrefillSignalement } from './onglet-signaler'
|
|
19
|
+
import { Alerte, Bouton, Vide } from './briques'
|
|
20
|
+
|
|
21
|
+
type Etat =
|
|
22
|
+
| { phase: 'chargement' }
|
|
23
|
+
| { phase: 'lu'; actif: ProgrammeActif }
|
|
24
|
+
| { phase: 'echec'; message: string }
|
|
25
|
+
|
|
26
|
+
export function OngletBeta({
|
|
27
|
+
client,
|
|
28
|
+
onSignalerScenario,
|
|
29
|
+
}: {
|
|
30
|
+
client: ClientRelais
|
|
31
|
+
onSignalerScenario: (prefill: PrefillSignalement) => void
|
|
32
|
+
}) {
|
|
33
|
+
const [etat, setEtat] = useState<Etat>({ phase: 'chargement' })
|
|
34
|
+
|
|
35
|
+
// Voir `onglet-mes-demandes.tsx` pour le pourquoi des rappels et du témoin
|
|
36
|
+
// de vie : l'état initial est déjà « chargement », et le bouton
|
|
37
|
+
// « Réessayer » le repose lui-même.
|
|
38
|
+
const charger = useCallback(
|
|
39
|
+
(estVivant: () => boolean = () => true) =>
|
|
40
|
+
client.programmeActif().then(
|
|
41
|
+
(actif) => {
|
|
42
|
+
if (estVivant()) setEtat({ phase: 'lu', actif })
|
|
43
|
+
},
|
|
44
|
+
(erreur: unknown) => {
|
|
45
|
+
if (!estVivant()) return
|
|
46
|
+
setEtat({
|
|
47
|
+
phase: 'echec',
|
|
48
|
+
message:
|
|
49
|
+
erreur instanceof ErreurRelais
|
|
50
|
+
? erreur.message
|
|
51
|
+
: 'Le programme de bêta n’a pas pu être lu.',
|
|
52
|
+
})
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
[client],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
let vivant = true
|
|
60
|
+
void charger(() => vivant)
|
|
61
|
+
return () => {
|
|
62
|
+
vivant = false
|
|
63
|
+
}
|
|
64
|
+
}, [charger])
|
|
65
|
+
|
|
66
|
+
if (etat.phase === 'chargement') return <Vide>Lecture du programme de bêta…</Vide>
|
|
67
|
+
|
|
68
|
+
if (etat.phase === 'echec') {
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<Alerte ton="err">{etat.message}</Alerte>
|
|
72
|
+
<Bouton
|
|
73
|
+
variante="fantome"
|
|
74
|
+
onClick={() => {
|
|
75
|
+
setEtat({ phase: 'chargement' })
|
|
76
|
+
void charger()
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
Réessayer
|
|
80
|
+
</Bouton>
|
|
81
|
+
</>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { programme, campagne } = etat.actif
|
|
86
|
+
|
|
87
|
+
if (!programme) {
|
|
88
|
+
return (
|
|
89
|
+
<Vide>
|
|
90
|
+
Aucun programme de bêta n’est ouvert sur cette application. Vos retours restent
|
|
91
|
+
les bienvenus dans l’onglet « Signaler ».
|
|
92
|
+
</Vide>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<>
|
|
98
|
+
<div className="zvs-carte">
|
|
99
|
+
<strong className="zvs-coupe">{programme.nom}</strong>
|
|
100
|
+
{programme.description ? (
|
|
101
|
+
<p className="zvs-message__corps">{programme.description}</p>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{!campagne ? (
|
|
106
|
+
<Vide>
|
|
107
|
+
Le programme est ouvert, mais aucune campagne n’est en cours. Il n’y a rien à
|
|
108
|
+
dérouler pour l’instant.
|
|
109
|
+
</Vide>
|
|
110
|
+
) : (
|
|
111
|
+
<div className="zvs-carte">
|
|
112
|
+
<strong className="zvs-coupe">{campagne.titre}</strong>
|
|
113
|
+
{campagne.consignes ? (
|
|
114
|
+
<p className="zvs-message__corps">{campagne.consignes}</p>
|
|
115
|
+
) : null}
|
|
116
|
+
|
|
117
|
+
{campagne.scenarios.length === 0 ? (
|
|
118
|
+
<p className="zvs-aide">Cette campagne n’a pas de scénario à dérouler.</p>
|
|
119
|
+
) : (
|
|
120
|
+
campagne.scenarios.map((scenario) => (
|
|
121
|
+
<div key={scenario.cle} className="zvs-scenario">
|
|
122
|
+
<strong className="zvs-coupe">{scenario.titre}</strong>
|
|
123
|
+
{scenario.etapes.length > 0 ? (
|
|
124
|
+
<ol className="zvs-etapes">
|
|
125
|
+
{scenario.etapes.map((etape, rang) => (
|
|
126
|
+
<li key={`${scenario.cle}-${rang}`}>{etape}</li>
|
|
127
|
+
))}
|
|
128
|
+
</ol>
|
|
129
|
+
) : null}
|
|
130
|
+
<Bouton
|
|
131
|
+
variante="fantome"
|
|
132
|
+
onClick={() =>
|
|
133
|
+
onSignalerScenario({
|
|
134
|
+
type: 'beta',
|
|
135
|
+
titre: scenario.titre,
|
|
136
|
+
campagneId: campagne.id,
|
|
137
|
+
// ⚠️ La CLÉ du scénario, pas son titre : c'est elle qui
|
|
138
|
+
// agrège les retours dans le tableau de la campagne. Un
|
|
139
|
+
// titre reformulé casserait le regroupement sans que
|
|
140
|
+
// rien ne le signale.
|
|
141
|
+
scenario: scenario.cle,
|
|
142
|
+
scenarioLibelle: scenario.titre,
|
|
143
|
+
demanderScore: true,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
>
|
|
147
|
+
Donner mon retour sur ce scénario
|
|
148
|
+
</Bouton>
|
|
149
|
+
</div>
|
|
150
|
+
))
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// L'onglet « Mes demandes » — la liste, puis le fil d'une demande.
|
|
5
|
+
// ============================================================================
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ Le relais ne rend que les demandes de la personne connectée, et refuse en
|
|
8
|
+
// 404 la lecture d'un fil qui n'est pas le sien (voir `relais/corps.ts` du
|
|
9
|
+
// SDK). Ce composant n'est donc PAS la garde : il ne filtre rien, il affiche.
|
|
10
|
+
// Une garde écrite ici se contournerait avec l'onglet Réseau.
|
|
11
|
+
|
|
12
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
13
|
+
|
|
14
|
+
import { ErreurRelais, type ClientRelais } from '../client-relais'
|
|
15
|
+
import type { DemandeLue, ResumeDemande } from '../types'
|
|
16
|
+
import { Alerte, Bouton, Horodatage, Pastille, Vide } from './briques'
|
|
17
|
+
|
|
18
|
+
type Etat =
|
|
19
|
+
| { phase: 'chargement' }
|
|
20
|
+
| { phase: 'liste'; demandes: ResumeDemande[] }
|
|
21
|
+
| { phase: 'echec'; message: string }
|
|
22
|
+
|
|
23
|
+
export function OngletMesDemandes({ client }: { client: ClientRelais }) {
|
|
24
|
+
const [etat, setEtat] = useState<Etat>({ phase: 'chargement' })
|
|
25
|
+
const [ouverte, setOuverte] = useState<string | null>(null)
|
|
26
|
+
|
|
27
|
+
// ⚠️ Les écritures d'état vivent dans les RAPPELS de la promesse, jamais dans
|
|
28
|
+
// le corps de l'effet, et elles sont gardées par `estVivant`. Les deux vont
|
|
29
|
+
// ensemble :
|
|
30
|
+
// - un `setState` synchrone dans un effet relance un rendu avant la
|
|
31
|
+
// peinture (react-hooks/set-state-in-effect) ;
|
|
32
|
+
// - une réponse qui arrive APRÈS la fermeture du tiroir écrirait dans un
|
|
33
|
+
// composant démonté — cas banal ici, le tiroir s'ouvre et se ferme en
|
|
34
|
+
// deux secondes, et la requête, elle, met le temps qu'elle met.
|
|
35
|
+
//
|
|
36
|
+
// `charger` ne repasse pas en « chargement » de lui-même : l'état initial
|
|
37
|
+
// l'est déjà, et c'est le bouton « Réessayer » — un geste, pas un effet —
|
|
38
|
+
// qui le repose.
|
|
39
|
+
const charger = useCallback(
|
|
40
|
+
(estVivant: () => boolean = () => true) =>
|
|
41
|
+
client.lister().then(
|
|
42
|
+
(page) => {
|
|
43
|
+
if (estVivant()) setEtat({ phase: 'liste', demandes: page.demandes ?? [] })
|
|
44
|
+
},
|
|
45
|
+
(erreur: unknown) => {
|
|
46
|
+
if (estVivant()) setEtat({ phase: 'echec', message: messageDe(erreur) })
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
[client],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let vivant = true
|
|
54
|
+
void charger(() => vivant)
|
|
55
|
+
return () => {
|
|
56
|
+
vivant = false
|
|
57
|
+
}
|
|
58
|
+
}, [charger])
|
|
59
|
+
|
|
60
|
+
if (ouverte) {
|
|
61
|
+
return <FilDemande client={client} id={ouverte} onRetour={() => setOuverte(null)} />
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (etat.phase === 'chargement') return <Vide>Lecture de vos demandes…</Vide>
|
|
65
|
+
|
|
66
|
+
if (etat.phase === 'echec') {
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<Alerte ton="err">{etat.message}</Alerte>
|
|
70
|
+
<Bouton
|
|
71
|
+
variante="fantome"
|
|
72
|
+
onClick={() => {
|
|
73
|
+
setEtat({ phase: 'chargement' })
|
|
74
|
+
void charger()
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
Réessayer
|
|
78
|
+
</Bouton>
|
|
79
|
+
</>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (etat.demandes.length === 0) {
|
|
84
|
+
return <Vide>Vous n’avez encore envoyé aucune demande depuis cette application.</Vide>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
{etat.demandes.map((demande) => (
|
|
90
|
+
<button
|
|
91
|
+
key={demande.id}
|
|
92
|
+
type="button"
|
|
93
|
+
className="zvs-carte zvs-carte--cliquable"
|
|
94
|
+
onClick={() => setOuverte(demande.id)}
|
|
95
|
+
>
|
|
96
|
+
<span className="zvs-ligne">
|
|
97
|
+
<span className="zvs-reference">{demande.reference}</span>
|
|
98
|
+
<Pastille statut={demande.statut} />
|
|
99
|
+
</span>
|
|
100
|
+
<span className="zvs-coupe">{demande.titre}</span>
|
|
101
|
+
<Horodatage valeur={demande.dernier_message_at} />
|
|
102
|
+
</button>
|
|
103
|
+
))}
|
|
104
|
+
</>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ─── Le fil ────────────────────────────────────────────────────────── */
|
|
109
|
+
|
|
110
|
+
type EtatFil =
|
|
111
|
+
| { phase: 'chargement' }
|
|
112
|
+
| { phase: 'lue'; demande: DemandeLue }
|
|
113
|
+
| { phase: 'echec'; message: string }
|
|
114
|
+
|
|
115
|
+
function FilDemande({
|
|
116
|
+
client,
|
|
117
|
+
id,
|
|
118
|
+
onRetour,
|
|
119
|
+
}: {
|
|
120
|
+
client: ClientRelais
|
|
121
|
+
id: string
|
|
122
|
+
onRetour: () => void
|
|
123
|
+
}) {
|
|
124
|
+
const [etat, setEtat] = useState<EtatFil>({ phase: 'chargement' })
|
|
125
|
+
const [reponse, setReponse] = useState('')
|
|
126
|
+
const [envoi, setEnvoi] = useState(false)
|
|
127
|
+
const [echecReponse, setEchecReponse] = useState<string | null>(null)
|
|
128
|
+
|
|
129
|
+
// Même dispositif que la liste — et un bénéfice de plus ici : après l'envoi
|
|
130
|
+
// d'une réponse, le fil se recharge SANS clignoter par un écran d'attente.
|
|
131
|
+
const charger = useCallback(
|
|
132
|
+
(estVivant: () => boolean = () => true) =>
|
|
133
|
+
client.lire(id).then(
|
|
134
|
+
(demande) => {
|
|
135
|
+
if (estVivant()) setEtat({ phase: 'lue', demande })
|
|
136
|
+
},
|
|
137
|
+
(erreur: unknown) => {
|
|
138
|
+
if (estVivant()) setEtat({ phase: 'echec', message: messageDe(erreur) })
|
|
139
|
+
},
|
|
140
|
+
),
|
|
141
|
+
[client, id],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
let vivant = true
|
|
146
|
+
void charger(() => vivant)
|
|
147
|
+
return () => {
|
|
148
|
+
vivant = false
|
|
149
|
+
}
|
|
150
|
+
}, [charger])
|
|
151
|
+
|
|
152
|
+
async function envoyer() {
|
|
153
|
+
if (reponse.trim() === '' || envoi) return
|
|
154
|
+
setEnvoi(true)
|
|
155
|
+
setEchecReponse(null)
|
|
156
|
+
try {
|
|
157
|
+
await client.repondre(id, reponse.trim())
|
|
158
|
+
setReponse('')
|
|
159
|
+
await charger()
|
|
160
|
+
} catch (erreur) {
|
|
161
|
+
setEchecReponse(messageDe(erreur))
|
|
162
|
+
} finally {
|
|
163
|
+
setEnvoi(false)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<>
|
|
169
|
+
<Bouton variante="fantome" onClick={onRetour}>
|
|
170
|
+
← Toutes mes demandes
|
|
171
|
+
</Bouton>
|
|
172
|
+
|
|
173
|
+
{etat.phase === 'chargement' ? <Vide>Lecture du fil…</Vide> : null}
|
|
174
|
+
{etat.phase === 'echec' ? <Alerte ton="err">{etat.message}</Alerte> : null}
|
|
175
|
+
|
|
176
|
+
{etat.phase === 'lue' ? (
|
|
177
|
+
<>
|
|
178
|
+
<div className="zvs-carte">
|
|
179
|
+
<span className="zvs-ligne">
|
|
180
|
+
<span className="zvs-reference">{etat.demande.demande.reference}</span>
|
|
181
|
+
<Pastille statut={etat.demande.demande.statut} />
|
|
182
|
+
</span>
|
|
183
|
+
<strong className="zvs-coupe">{etat.demande.demande.titre}</strong>
|
|
184
|
+
<p className="zvs-message__corps">{etat.demande.demande.description}</p>
|
|
185
|
+
<Horodatage valeur={etat.demande.demande.created_at} />
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{etat.demande.messages.length === 0 ? (
|
|
189
|
+
<Vide>Aucune réponse pour l’instant.</Vide>
|
|
190
|
+
) : (
|
|
191
|
+
etat.demande.messages.map((message) => (
|
|
192
|
+
<div
|
|
193
|
+
key={message.id}
|
|
194
|
+
className={`zvs-message${message.auteur === 'equipe' ? ' zvs-message--equipe' : ''}`}
|
|
195
|
+
>
|
|
196
|
+
<span className="zvs-meta">
|
|
197
|
+
{message.auteur === 'equipe' ? 'Équipe Zevra' : 'Vous'} ·{' '}
|
|
198
|
+
<Horodatage valeur={message.created_at} />
|
|
199
|
+
</span>
|
|
200
|
+
<p className="zvs-message__corps">{message.corps}</p>
|
|
201
|
+
</div>
|
|
202
|
+
))
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{etat.demande.pieces.length > 0 ? (
|
|
206
|
+
<ul className="zvs-champ">
|
|
207
|
+
{etat.demande.pieces.map((piece) => (
|
|
208
|
+
<li key={piece.id} className="zvs-meta zvs-coupe">
|
|
209
|
+
{piece.url ? (
|
|
210
|
+
// `rel="noreferrer"` : l'URL présignée ne doit pas partir
|
|
211
|
+
// en `Referer` vers la page de destination.
|
|
212
|
+
<a href={piece.url} target="_blank" rel="noreferrer">
|
|
213
|
+
{piece.nom}
|
|
214
|
+
</a>
|
|
215
|
+
) : (
|
|
216
|
+
piece.nom
|
|
217
|
+
)}
|
|
218
|
+
</li>
|
|
219
|
+
))}
|
|
220
|
+
</ul>
|
|
221
|
+
) : null}
|
|
222
|
+
|
|
223
|
+
{echecReponse ? <Alerte ton="err">{echecReponse}</Alerte> : null}
|
|
224
|
+
|
|
225
|
+
<div className="zvs-champ">
|
|
226
|
+
<label className="zvs-label" htmlFor="zvs-reponse">
|
|
227
|
+
RÉPONDRE
|
|
228
|
+
</label>
|
|
229
|
+
<textarea
|
|
230
|
+
id="zvs-reponse"
|
|
231
|
+
className="zvs-zone"
|
|
232
|
+
value={reponse}
|
|
233
|
+
onChange={(e) => setReponse(e.target.value)}
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="zvs-pied">
|
|
237
|
+
<Bouton onClick={envoyer} occupe={envoi}>
|
|
238
|
+
{envoi ? 'Envoi…' : 'Envoyer la réponse'}
|
|
239
|
+
</Bouton>
|
|
240
|
+
</div>
|
|
241
|
+
</>
|
|
242
|
+
) : null}
|
|
243
|
+
</>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* ⚠️ On affiche le message du relais tel quel quand il vient de lui : il est
|
|
249
|
+
* écrit pour être lu. Pour tout le reste (une exception JavaScript, un HTML de
|
|
250
|
+
* page 404), un générique — recopier un message technique dans une interface
|
|
251
|
+
* n'aide personne et peut divulguer une URL interne.
|
|
252
|
+
*/
|
|
253
|
+
function messageDe(erreur: unknown): string {
|
|
254
|
+
if (erreur instanceof ErreurRelais) return erreur.message
|
|
255
|
+
return 'Vos demandes n’ont pas pu être lues. Réessayez dans un instant.'
|
|
256
|
+
}
|