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