@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
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @zevra/support-widget — le bouton « Aide » et son tiroir
|
|
2
|
+
|
|
3
|
+
Bouton flottant, puis un tiroir à trois onglets : **Signaler**,
|
|
4
|
+
**Mes demandes**, **Bêta**.
|
|
5
|
+
|
|
6
|
+
> **Ce paquet ne connaît aucune clé et aucune URL de Support.** Il appelle
|
|
7
|
+
> `/api/support/*` sur son propre domaine — les routes que
|
|
8
|
+
> `creerRoutesSupport()` de `@zevra/support` installe côté serveur de l'app.
|
|
9
|
+
> C'est ce relais qui détient `SUPPORT_API_KEY` et qui décide, à partir de la
|
|
10
|
+
> session de l'app, de qui parle.
|
|
11
|
+
|
|
12
|
+
Le guide d'intégration complet vit dans **`docs/integration.md`** du dépôt et
|
|
13
|
+
sur **https://support.zevra.tech/integration**.
|
|
14
|
+
|
|
15
|
+
## Pose
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
'use client'
|
|
19
|
+
import { WidgetSupport, installerTamponErreurs } from '@zevra/support-widget'
|
|
20
|
+
|
|
21
|
+
// ⚠️ Le tampon d'erreurs se pose LE PLUS TÔT POSSIBLE dans l'app, pas au
|
|
22
|
+
// montage du widget : posé au montage, il manque les erreurs du démarrage,
|
|
23
|
+
// c'est-à-dire celles qu'on signale.
|
|
24
|
+
useEffect(() => installerTamponErreurs(), [])
|
|
25
|
+
|
|
26
|
+
<WidgetSupport version={process.env.NEXT_PUBLIC_VERSION} />
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Props : `base` (défaut `/api/support`), `libelle`, `titre`, `position`
|
|
30
|
+
(`bas-droite` | `bas-gauche` | `haut-droite` | `haut-gauche` | `haut-centre` |
|
|
31
|
+
`bas-centre`), `decalage`, `discret`, `marque`,
|
|
32
|
+
`version`, `betaOfferte`, `captureOfferte`,
|
|
33
|
+
`contexteSupplementaire`, `client`, `onOuvrir`, `onEnvoye`.
|
|
34
|
+
|
|
35
|
+
Le formulaire ne demande qu'une phrase et une capture facultative : le type et
|
|
36
|
+
le titre sont posés par le triage IA de Support (depuis 0.2.0).
|
|
37
|
+
|
|
38
|
+
⚠️ `decalage` va avec les positions hautes : le widget ne connaît pas la
|
|
39
|
+
hauteur de votre barre de navigation, et se poserait par-dessus.
|
|
40
|
+
|
|
41
|
+
## Ce qu'il relève tout seul
|
|
42
|
+
|
|
43
|
+
URL, user agent, système déduit, taille de la fenêtre, langue, version de
|
|
44
|
+
l'app, et les **20 dernières erreurs de console**. Capture d'écran facultative
|
|
45
|
+
(`html-to-image`, chargé dynamiquement au clic), de la page entière ou d'une
|
|
46
|
+
**zone tracée à la souris** : le widget se masque, la page se voile, Échap
|
|
47
|
+
annule. La géométrie du recadrage vit dans `src/zone.ts`, pure et testée —
|
|
48
|
+
c'est là que se règlent le défilement et le facteur d'échelle de l'image.
|
|
49
|
+
|
|
50
|
+
⚠️ **Sur l'URL** : les valeurs des paramètres sont retirées, leurs noms
|
|
51
|
+
conservés — `?jeton=abc&page=2` devient `?jeton&page`. Une query string porte
|
|
52
|
+
régulièrement un jeton de lien magique ; copiée dans un ticket, elle est
|
|
53
|
+
recopiée dans la base du support et dans l'accusé de réception. Le fragment
|
|
54
|
+
part entièrement.
|
|
55
|
+
|
|
56
|
+
## L'habillage
|
|
57
|
+
|
|
58
|
+
Une feuille à lui, entièrement préfixée `zvs-`, dont **chaque valeur de marque
|
|
59
|
+
lit un token Zevra avec un repli littéral** : `color: var(--ink, #0b1020)`.
|
|
60
|
+
|
|
61
|
+
Là où `@zevra/ui` est chargé, le widget est dans les couleurs de la maison, y
|
|
62
|
+
compris après un changement de charte. Là où il ne l'est pas, le repli
|
|
63
|
+
s'applique. Aucune classe de l'hôte n'est lue, aucune classe `zvs-` n'existe
|
|
64
|
+
ailleurs : la collision est impossible dans les deux sens. Un `var()` sans
|
|
65
|
+
repli serait un bug invisible chez nous (toutes nos apps ont le paquet) et du
|
|
66
|
+
texte blanc sur blanc ailleurs — c'est `tests/styles.test.ts` qui tient la
|
|
67
|
+
règle.
|
|
68
|
+
|
|
69
|
+
## Tests
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx vitest run --config packages/widget/vitest.config.ts
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Ce qui est couvert : le nettoyage d'URL, l'ordre des tests de user agent
|
|
76
|
+
(Android contient « Linux », iPhone contient « Mac OS X »), la mesure absente
|
|
77
|
+
qui n'est pas `0 × 0`, le tampon borné à 20 dans l'ordre, un `Symbol` / un objet
|
|
78
|
+
circulaire / un `BigInt` qui ne doivent jamais faire jeter la console de
|
|
79
|
+
l'hôte, l'idempotence de la pose, chaque token de la feuille qui a son repli,
|
|
80
|
+
et la validation qui rend tous les défauts d'un coup.
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zevra/support-widget",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Widget React « Support Zevra » : bouton, tiroir, capture d'une zone.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.tsx",
|
|
8
|
+
"types": "./src/index.tsx",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.tsx"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"html-to-image": "^1.11.13"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": ">=18",
|
|
24
|
+
"react-dom": ">=18"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// La capture d'écran, facultative de bout en bout.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ `html-to-image` est chargé en IMPORT DYNAMIQUE, au moment du clic.
|
|
6
|
+
//
|
|
7
|
+
// Deux raisons mesurables. D'abord le poids : la bibliothèque fait plusieurs
|
|
8
|
+
// dizaines de kilo-octets, et elle serait dans le bundle initial de CHAQUE
|
|
9
|
+
// page d'une app qui pose le widget — pour une fonction que la plupart des
|
|
10
|
+
// signalements n'emploient pas. Ensuite la robustesse : la capture échoue
|
|
11
|
+
// légitimement (une image d'une autre origine « salit » le canevas, une police
|
|
12
|
+
// distante refuse le CORS, la page est trop grande). Un import statique ferait
|
|
13
|
+
// de cet échec une panne de chargement du widget entier.
|
|
14
|
+
//
|
|
15
|
+
// Résultat : sans la bibliothèque, ou si la capture échoue, le formulaire
|
|
16
|
+
// reste utilisable et le dit. La capture est un CONFORT, jamais un passage
|
|
17
|
+
// obligé.
|
|
18
|
+
|
|
19
|
+
import { zoneDansImage, type Rectangle } from './zone'
|
|
20
|
+
|
|
21
|
+
/** Au-delà, la capture pèse plus que tout le reste de la demande. */
|
|
22
|
+
export const TAILLE_MAX_CAPTURE_OCTETS = 3_000_000
|
|
23
|
+
|
|
24
|
+
export const TYPE_MIME_CAPTURE = 'image/png'
|
|
25
|
+
|
|
26
|
+
export interface CaptureFaite {
|
|
27
|
+
/** Le PNG, prêt à être déposé par PUT présigné. */
|
|
28
|
+
binaire: Blob
|
|
29
|
+
/** La même image en data-URL, pour la vignette de relecture. */
|
|
30
|
+
apercu: string
|
|
31
|
+
octets: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ResultatCapture =
|
|
35
|
+
| { faite: true; capture: CaptureFaite }
|
|
36
|
+
| { faite: false; motif: string }
|
|
37
|
+
|
|
38
|
+
export interface OptionsCapture {
|
|
39
|
+
/**
|
|
40
|
+
* Le tracé à garder, en coordonnées de FENÊTRE (voir `zone.ts`). Absent :
|
|
41
|
+
* on garde l'élément entier, comme avant.
|
|
42
|
+
*/
|
|
43
|
+
zone?: Rectangle | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Capture un élément (par défaut le corps de la page), éventuellement recadré.
|
|
48
|
+
*
|
|
49
|
+
* Ne jette JAMAIS : rend `{ faite: false, motif }`, parce qu'une capture ratée
|
|
50
|
+
* ne doit pas faire perdre un formulaire à moitié rempli.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ LE RECADRAGE SE FAIT APRÈS, PAS PENDANT. On rend l'élément entier, puis
|
|
53
|
+
* on découpe l'image. Demander à la bibliothèque de ne rendre que la zone
|
|
54
|
+
* supposerait de lui passer un ÉLÉMENT, or une zone tracée à la main tombe
|
|
55
|
+
* régulièrement à cheval sur deux blocs — c'est même le cas courant quand on
|
|
56
|
+
* entoure « le bouton et le message d'erreur à côté ». Le coût est une image
|
|
57
|
+
* intermédiaire, jamais envoyée.
|
|
58
|
+
*/
|
|
59
|
+
export async function capturerEcran(
|
|
60
|
+
element: HTMLElement | null | undefined,
|
|
61
|
+
options: OptionsCapture = {},
|
|
62
|
+
): Promise<ResultatCapture> {
|
|
63
|
+
if (!element) return { faite: false, motif: "Rien à capturer sur cette page." }
|
|
64
|
+
|
|
65
|
+
let versPng: (noeud: HTMLElement, options?: Record<string, unknown>) => Promise<string>
|
|
66
|
+
try {
|
|
67
|
+
const bibliotheque = await import('html-to-image')
|
|
68
|
+
versPng = bibliotheque.toPng
|
|
69
|
+
} catch {
|
|
70
|
+
return {
|
|
71
|
+
faite: false,
|
|
72
|
+
motif: "La capture d'écran n'est pas disponible dans cette application.",
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const dataUrl = await versPng(element, {
|
|
78
|
+
// Une capture n'a pas besoin d'être nette au pixel : elle sert à voir
|
|
79
|
+
// l'état de l'écran. `pixelRatio: 1` divise le poids par quatre sur un
|
|
80
|
+
// écran Retina, où la capture brute dépasse allègrement les 3 Mo.
|
|
81
|
+
pixelRatio: 1,
|
|
82
|
+
// Le widget lui-même n'a rien à faire sur la capture : il masque
|
|
83
|
+
// précisément la partie de l'écran qu'on veut montrer.
|
|
84
|
+
filter: (noeud: unknown) => {
|
|
85
|
+
const element_ = noeud as { classList?: { contains(c: string): boolean } }
|
|
86
|
+
return !element_?.classList?.contains?.('zvs-racine')
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
const image = options.zone
|
|
90
|
+
? await recadrer(dataUrl, element, options.zone)
|
|
91
|
+
: dataUrl
|
|
92
|
+
if (image === null) {
|
|
93
|
+
return {
|
|
94
|
+
faite: false,
|
|
95
|
+
motif: 'La zone choisie est en dehors de la capture. Retracez-la sur la page.',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const octets = decoderDataUrl(image)
|
|
100
|
+
if (octets === null) {
|
|
101
|
+
return {
|
|
102
|
+
faite: false,
|
|
103
|
+
motif: "La capture d'écran n'a rien produit de lisible sur cette page.",
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const binaire = new Blob([octets], { type: TYPE_MIME_CAPTURE })
|
|
107
|
+
if (binaire.size > TAILLE_MAX_CAPTURE_OCTETS) {
|
|
108
|
+
return {
|
|
109
|
+
faite: false,
|
|
110
|
+
motif: 'La capture est trop lourde pour être jointe. Décrivez plutôt ce que vous voyez.',
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { faite: true, capture: { binaire, apercu: image, octets: binaire.size } }
|
|
114
|
+
} catch {
|
|
115
|
+
// Cas le plus fréquent : une image d'une autre origine a « sali » le
|
|
116
|
+
// canevas et le navigateur refuse de l'exporter. Rien à corriger côté
|
|
117
|
+
// utilisateur, et surtout rien qui justifie de perdre sa saisie.
|
|
118
|
+
return {
|
|
119
|
+
faite: false,
|
|
120
|
+
motif: "La capture d'écran a échoué sur cette page. Le reste de votre signalement part quand même.",
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Découpe une image déjà rendue au rectangle demandé. IMPUR (canevas).
|
|
127
|
+
*
|
|
128
|
+
* Rend `null` quand il n'y a rien à découper : image illisible, zone hors de
|
|
129
|
+
* l'élément, canevas refusé. L'appelant en fait une phrase, jamais une image
|
|
130
|
+
* vide.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ AUCUN RISQUE DE CANEVAS SALI ICI. La source est la data-URL que la
|
|
133
|
+
* capture vient de produire, donc de même origine par construction :
|
|
134
|
+
* `toDataURL` ne peut pas être refusé pour cause d'origine croisée. C'est la
|
|
135
|
+
* capture elle-même qui échoue quand la page porte une image d'ailleurs, et
|
|
136
|
+
* elle le dit déjà.
|
|
137
|
+
*/
|
|
138
|
+
async function recadrer(
|
|
139
|
+
dataUrl: string,
|
|
140
|
+
element: HTMLElement,
|
|
141
|
+
zone: Rectangle,
|
|
142
|
+
): Promise<string | null> {
|
|
143
|
+
const image = await chargerImage(dataUrl)
|
|
144
|
+
if (image === null) return null
|
|
145
|
+
|
|
146
|
+
const cadre = element.getBoundingClientRect()
|
|
147
|
+
const decoupe = zoneDansImage({
|
|
148
|
+
zone,
|
|
149
|
+
// Le cadre est relevé MAINTENANT, pas au moment du tracé : entre les deux,
|
|
150
|
+
// rien n'a défilé (le voile bloque), mais un relevé figé se désynchroniserait
|
|
151
|
+
// le jour où on rendrait le voile défilable.
|
|
152
|
+
cadre: { x: cadre.left, y: cadre.top },
|
|
153
|
+
imageLargeur: image.naturalWidth || image.width,
|
|
154
|
+
imageHauteur: image.naturalHeight || image.height,
|
|
155
|
+
elementLargeur: element.offsetWidth || cadre.width,
|
|
156
|
+
elementHauteur: element.offsetHeight || cadre.height,
|
|
157
|
+
})
|
|
158
|
+
if (decoupe === null) return null
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const canevas = document.createElement('canvas')
|
|
162
|
+
canevas.width = decoupe.largeur
|
|
163
|
+
canevas.height = decoupe.hauteur
|
|
164
|
+
const pinceau = canevas.getContext('2d')
|
|
165
|
+
if (pinceau === null) return null
|
|
166
|
+
pinceau.drawImage(
|
|
167
|
+
image,
|
|
168
|
+
decoupe.x,
|
|
169
|
+
decoupe.y,
|
|
170
|
+
decoupe.largeur,
|
|
171
|
+
decoupe.hauteur,
|
|
172
|
+
0,
|
|
173
|
+
0,
|
|
174
|
+
decoupe.largeur,
|
|
175
|
+
decoupe.hauteur,
|
|
176
|
+
)
|
|
177
|
+
return canevas.toDataURL(TYPE_MIME_CAPTURE)
|
|
178
|
+
} catch {
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Charge une data-URL en image. Rend `null` au lieu de jeter ou d'attendre. */
|
|
184
|
+
function chargerImage(dataUrl: string): Promise<HTMLImageElement | null> {
|
|
185
|
+
return new Promise((resoudre) => {
|
|
186
|
+
try {
|
|
187
|
+
const image = new Image()
|
|
188
|
+
image.onload = () => resoudre(image)
|
|
189
|
+
image.onerror = () => resoudre(null)
|
|
190
|
+
image.src = dataUrl
|
|
191
|
+
} catch {
|
|
192
|
+
resoudre(null)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Décode une data-URL base64 en octets. PUR, total : rend `null` plutôt que
|
|
199
|
+
* de jeter.
|
|
200
|
+
*
|
|
201
|
+
* ⚠️ POURQUOI PAS `await fetch(dataUrl)`, qui tient en une ligne : une
|
|
202
|
+
* politique de sécurité de contenu un peu sérieuse écrit `connect-src 'self'`,
|
|
203
|
+
* et `data:` n'y est presque jamais listé. `fetch()` sur une data-URL est
|
|
204
|
+
* alors BLOQUÉ par le navigateur — la capture échoue, le widget affiche « la
|
|
205
|
+
* capture a échoué sur cette page », et on cherche du côté de html-to-image un
|
|
206
|
+
* problème qui vient de l'en-tête CSP de l'app hôte. Le décodage manuel ne
|
|
207
|
+
* traverse aucune politique réseau.
|
|
208
|
+
*
|
|
209
|
+
* Le type de retour nomme son tampon (`Uint8Array<ArrayBuffer>`) : un
|
|
210
|
+
* `Uint8Array` tout court porte `ArrayBufferLike`, qui inclut
|
|
211
|
+
* `SharedArrayBuffer`, et `new Blob([…])` le refuse — l'erreur ne parle alors
|
|
212
|
+
* que de variance, jamais du fait qu'un tampon partagé n'est pas transférable.
|
|
213
|
+
*
|
|
214
|
+
* `atob` ne connaît que le base64 : une data-URL en texte brut
|
|
215
|
+
* (`data:image/svg+xml,<svg…>`) est refusée ici, ce qui est correct — la seule
|
|
216
|
+
* qu'on décode est celle que `toPng` produit, toujours en base64.
|
|
217
|
+
*/
|
|
218
|
+
export function decoderDataUrl(dataUrl: string): Uint8Array<ArrayBuffer> | null {
|
|
219
|
+
if (typeof dataUrl !== 'string') return null
|
|
220
|
+
const virgule = dataUrl.indexOf(',')
|
|
221
|
+
if (!dataUrl.startsWith('data:') || virgule < 0) return null
|
|
222
|
+
if (!/;base64$/i.test(dataUrl.slice(0, virgule))) return null
|
|
223
|
+
const charge = dataUrl.slice(virgule + 1)
|
|
224
|
+
if (charge === '') return null
|
|
225
|
+
try {
|
|
226
|
+
const binaire = atob(charge)
|
|
227
|
+
const octets = new Uint8Array(binaire.length)
|
|
228
|
+
for (let i = 0; i < binaire.length; i += 1) octets[i] = binaire.charCodeAt(i)
|
|
229
|
+
return octets
|
|
230
|
+
} catch {
|
|
231
|
+
// base64 tronqué ou invalide : `atob` jette. Une capture illisible ne doit
|
|
232
|
+
// pas faire perdre un formulaire à moitié rempli.
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Dépose un binaire sur l'URL présignée rendue par le relais.
|
|
239
|
+
*
|
|
240
|
+
* ⚠️ Le `Content-Type` doit être EXACTEMENT celui qui a été annoncé au moment
|
|
241
|
+
* de demander l'URL : il est signé avec elle. Un octet d'écart et S3 répond
|
|
242
|
+
* 403 — sans quoi une app pourrait annoncer `image/png` et déposer un
|
|
243
|
+
* exécutable.
|
|
244
|
+
*/
|
|
245
|
+
export async function deposerBinaire(
|
|
246
|
+
urlPut: string,
|
|
247
|
+
binaire: Blob,
|
|
248
|
+
typeMime: string,
|
|
249
|
+
): Promise<boolean> {
|
|
250
|
+
try {
|
|
251
|
+
const reponse = await fetch(urlPut, {
|
|
252
|
+
method: 'PUT',
|
|
253
|
+
body: binaire,
|
|
254
|
+
headers: { 'Content-Type': typeMime },
|
|
255
|
+
})
|
|
256
|
+
return reponse.ok
|
|
257
|
+
} catch {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Le seul interlocuteur du widget : les routes `/api/support/*` de l'app hôte.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ AUCUNE CLÉ, AUCUNE URL DE SUPPORT, AUCUN EN-TÊTE D'AUTHENTIFICATION dans
|
|
6
|
+
// ce fichier — ni ailleurs dans ce paquet. Le widget parle à SON PROPRE
|
|
7
|
+
// domaine, en `same-origin`, et c'est le relais (`creerRoutesSupport()` de
|
|
8
|
+
// `@zevra/support`) qui détient `SUPPORT_API_KEY` et qui décide de qui parle.
|
|
9
|
+
//
|
|
10
|
+
// Ce n'est pas une précaution de style : la clé authentifie l'app entière, et
|
|
11
|
+
// tout ce qui traverse ce fichier est lisible dans l'onglet Réseau du premier
|
|
12
|
+
// visiteur venu.
|
|
13
|
+
|
|
14
|
+
import type { DemandeLue, ErreurDuRelais, ProgrammeActif, ResumeDemande } from './types'
|
|
15
|
+
|
|
16
|
+
export const BASE_PAR_DEFAUT = '/api/support'
|
|
17
|
+
|
|
18
|
+
export class ErreurRelais extends Error {
|
|
19
|
+
readonly code: string
|
|
20
|
+
readonly statut: number
|
|
21
|
+
readonly champs?: Record<string, string>
|
|
22
|
+
|
|
23
|
+
constructor(donnees: ErreurDuRelais & { statut: number }) {
|
|
24
|
+
super(donnees.message)
|
|
25
|
+
this.name = 'ErreurRelais'
|
|
26
|
+
this.code = donnees.code
|
|
27
|
+
this.statut = donnees.statut
|
|
28
|
+
this.champs = donnees.champs
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ClientRelais {
|
|
33
|
+
creer(corps: unknown): Promise<{ id: string; reference: string }>
|
|
34
|
+
lister(): Promise<{ demandes: ResumeDemande[] }>
|
|
35
|
+
lire(id: string): Promise<DemandeLue>
|
|
36
|
+
repondre(id: string, corps: string, pieces?: readonly string[]): Promise<void>
|
|
37
|
+
deposerPiece(fichier: DescripteurPiece): Promise<{ id: string; url_put: string }>
|
|
38
|
+
programmeActif(): Promise<ProgrammeActif>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DescripteurPiece {
|
|
42
|
+
nom: string
|
|
43
|
+
type_mime: string
|
|
44
|
+
octets: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function creerClientRelais(
|
|
48
|
+
base: string = BASE_PAR_DEFAUT,
|
|
49
|
+
appelFetch: typeof fetch = globalThis.fetch,
|
|
50
|
+
): ClientRelais {
|
|
51
|
+
const racine = base.replace(/\/+$/, '')
|
|
52
|
+
|
|
53
|
+
async function appeler<T>(chemin: string, init: RequestInit = {}): Promise<T> {
|
|
54
|
+
let reponse: Response
|
|
55
|
+
try {
|
|
56
|
+
reponse = await appelFetch(`${racine}${chemin}`, {
|
|
57
|
+
...init,
|
|
58
|
+
// ⚠️ `same-origin` : le widget appelle SON app. `include` enverrait les
|
|
59
|
+
// cookies à une origine tierce le jour où quelqu'un passerait une base
|
|
60
|
+
// absolue — exactement ce que ce paquet ne doit pas permettre.
|
|
61
|
+
credentials: 'same-origin',
|
|
62
|
+
headers: {
|
|
63
|
+
Accept: 'application/json',
|
|
64
|
+
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
|
65
|
+
...(init.headers as Record<string, string> | undefined),
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
} catch {
|
|
69
|
+
// Hors ligne, ou l'app est tombée. Le message parle à la personne, pas
|
|
70
|
+
// au journal : elle n'a rien à corriger, elle doit savoir que rien n'est
|
|
71
|
+
// parti — sans quoi elle enverra trois fois le même signalement.
|
|
72
|
+
throw new ErreurRelais({
|
|
73
|
+
code: 'reseau',
|
|
74
|
+
message: "Impossible de joindre l'application. Votre message n'a pas été envoyé.",
|
|
75
|
+
statut: 0,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const donnees = await lireJson(reponse)
|
|
80
|
+
if (!reponse.ok) {
|
|
81
|
+
const enveloppe = (donnees as { erreur?: ErreurDuRelais } | null)?.erreur
|
|
82
|
+
throw new ErreurRelais({
|
|
83
|
+
code: enveloppe?.code ?? 'interne',
|
|
84
|
+
message: enveloppe?.message ?? "Le support n'a pas pu traiter cette demande.",
|
|
85
|
+
champs: enveloppe?.champs,
|
|
86
|
+
statut: reponse.status,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
return donnees as T
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
creer: (corps) =>
|
|
94
|
+
appeler('/demandes', { method: 'POST', body: JSON.stringify(corps) }),
|
|
95
|
+
lister: () => appeler('/demandes'),
|
|
96
|
+
lire: (id) => appeler(`/demandes/${encodeURIComponent(id)}`),
|
|
97
|
+
repondre: async (id, corps, pieces) => {
|
|
98
|
+
await appeler(`/demandes/${encodeURIComponent(id)}/messages`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
body: JSON.stringify({ corps, pieces }),
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
deposerPiece: (fichier) =>
|
|
104
|
+
appeler('/pieces', { method: 'POST', body: JSON.stringify(fichier) }),
|
|
105
|
+
programmeActif: () => appeler('/programmes/actif'),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Lit le corps sans jamais jeter.
|
|
111
|
+
*
|
|
112
|
+
* Une route d'app hôte mal branchée rend du HTML (la page 404 de Next). Laisser
|
|
113
|
+
* `response.json()` jeter remplacerait « 404 » par une erreur de syntaxe JSON,
|
|
114
|
+
* et on chercherait un bug de sérialisation qui n'existe pas — alors que le
|
|
115
|
+
* vrai problème est un fichier de route jamais créé.
|
|
116
|
+
*/
|
|
117
|
+
async function lireJson(reponse: Response): Promise<unknown> {
|
|
118
|
+
let texte: string
|
|
119
|
+
try {
|
|
120
|
+
texte = await reponse.text()
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
if (texte.trim() === '') return null
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(texte)
|
|
127
|
+
} catch {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Les quelques primitives du widget. Aucune dépendance, aucune classe de l'hôte.
|
|
5
|
+
// ============================================================================
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ Ce ne sont PAS des composants de design system, et elles n'ont pas
|
|
8
|
+
// vocation à le devenir : elles n'existent que parce que le widget doit se
|
|
9
|
+
// rendre dans une app qui n'a peut-être pas `@zevra/ui`. Tout ce qui se rend
|
|
10
|
+
// dans NOS écrans (la console, le portail, la page d'intégration) s'habille
|
|
11
|
+
// avec le paquet, pas avec ceci.
|
|
12
|
+
//
|
|
13
|
+
// Elles ne portent que des classes `zvs-` : voir `styles.ts` pour le
|
|
14
|
+
// raisonnement complet sur la cohabitation avec l'app hôte.
|
|
15
|
+
|
|
16
|
+
import type { ReactNode } from 'react'
|
|
17
|
+
|
|
18
|
+
import type { StatutDemande } from '../types'
|
|
19
|
+
import { LIBELLES_STATUT } from '../types'
|
|
20
|
+
|
|
21
|
+
export function Champ({
|
|
22
|
+
label,
|
|
23
|
+
htmlFor,
|
|
24
|
+
erreur,
|
|
25
|
+
aide,
|
|
26
|
+
children,
|
|
27
|
+
}: {
|
|
28
|
+
label: string
|
|
29
|
+
htmlFor: string
|
|
30
|
+
erreur?: string
|
|
31
|
+
aide?: ReactNode
|
|
32
|
+
children: ReactNode
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="zvs-champ">
|
|
36
|
+
<label className="zvs-label" htmlFor={htmlFor}>
|
|
37
|
+
{label}
|
|
38
|
+
</label>
|
|
39
|
+
{children}
|
|
40
|
+
{/* L'aide DISPARAÎT pendant l'erreur : les deux au même endroit se
|
|
41
|
+
chevauchent, et c'est l'erreur qui doit se lire. */}
|
|
42
|
+
{erreur ? (
|
|
43
|
+
<p className="zvs-erreur" id={`${htmlFor}-err`}>
|
|
44
|
+
{erreur}
|
|
45
|
+
</p>
|
|
46
|
+
) : aide ? (
|
|
47
|
+
<p className="zvs-aide">{aide}</p>
|
|
48
|
+
) : null}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function Bouton({
|
|
54
|
+
variante = 'plein',
|
|
55
|
+
occupe,
|
|
56
|
+
onClick,
|
|
57
|
+
children,
|
|
58
|
+
...reste
|
|
59
|
+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
60
|
+
variante?: 'plein' | 'fantome'
|
|
61
|
+
occupe?: boolean
|
|
62
|
+
}) {
|
|
63
|
+
return (
|
|
64
|
+
<button
|
|
65
|
+
// ⚠️ `onClick` est SORTI de `reste` et posé APRÈS l'étalement. Laissé
|
|
66
|
+
// dedans, il réapparaîtrait par l'étalement et écraserait la garde
|
|
67
|
+
// `occupe` juste en dessous : un deuxième clic pendant l'envoi partirait,
|
|
68
|
+
// et la personne créerait deux demandes pour un seul problème. Même
|
|
69
|
+
// raison pour `type` : un bouton sans type, dans un formulaire, vaut
|
|
70
|
+
// « submit » et recharge la page.
|
|
71
|
+
{...reste}
|
|
72
|
+
type="button"
|
|
73
|
+
className={`zvs-bouton${variante === 'fantome' ? ' zvs-bouton--fantome' : ''}`}
|
|
74
|
+
// ⚠️ `aria-disabled` et non `disabled` : un bouton désactivé sort du
|
|
75
|
+
// parcours clavier au moment précis où l'attente s'annonce, et le
|
|
76
|
+
// lecteur d'écran perd le focus sans rien dire. La garde du clic est
|
|
77
|
+
// donc à notre charge — c'est la ligne suivante.
|
|
78
|
+
aria-disabled={occupe ? true : undefined}
|
|
79
|
+
aria-busy={occupe ? true : undefined}
|
|
80
|
+
onClick={occupe ? undefined : onClick}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</button>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function Alerte({
|
|
88
|
+
ton = 'info',
|
|
89
|
+
children,
|
|
90
|
+
}: {
|
|
91
|
+
ton?: 'info' | 'ok' | 'err'
|
|
92
|
+
children: ReactNode
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
className={`zvs-alerte${ton === 'err' ? ' zvs-alerte--err' : ton === 'ok' ? ' zvs-alerte--ok' : ''}`}
|
|
97
|
+
role={ton === 'err' ? 'alert' : undefined}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* La pastille de statut.
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ Le tableau est EXHAUSTIF sur le vocabulaire fermé, et un statut inconnu
|
|
108
|
+
* (une valeur ajoutée côté Support plus tard) reste NEUTRE, avec son libellé
|
|
109
|
+
* brut. Le replier d'office sur « résolue » annoncerait à quelqu'un que son
|
|
110
|
+
* problème est réglé alors que personne ne l'a dit.
|
|
111
|
+
*/
|
|
112
|
+
export function Pastille({ statut }: { statut: StatutDemande | string }) {
|
|
113
|
+
const modificateur =
|
|
114
|
+
statut === 'resolue' || statut === 'fermee'
|
|
115
|
+
? ' zvs-pastille--resolue'
|
|
116
|
+
: statut === 'en_attente_requerant'
|
|
117
|
+
? ' zvs-pastille--attente'
|
|
118
|
+
: statut === 'ouverte' || statut === 'nouvelle'
|
|
119
|
+
? ' zvs-pastille--ouverte'
|
|
120
|
+
: ''
|
|
121
|
+
const libelle = LIBELLES_STATUT[statut as StatutDemande] ?? statut
|
|
122
|
+
return <span className={`zvs-pastille${modificateur}`}>{libelle}</span>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Un horodatage court, en Europe/Paris, sans dépendance de formatage. */
|
|
126
|
+
export function Horodatage({ valeur }: { valeur: string | null | undefined }) {
|
|
127
|
+
if (!valeur) return <span className="zvs-meta">—</span>
|
|
128
|
+
const date = new Date(valeur)
|
|
129
|
+
if (Number.isNaN(date.getTime())) return <span className="zvs-meta">—</span>
|
|
130
|
+
return (
|
|
131
|
+
<time className="zvs-meta" dateTime={valeur}>
|
|
132
|
+
{date.toLocaleDateString('fr-FR', {
|
|
133
|
+
day: '2-digit',
|
|
134
|
+
month: '2-digit',
|
|
135
|
+
year: 'numeric',
|
|
136
|
+
hour: '2-digit',
|
|
137
|
+
minute: '2-digit',
|
|
138
|
+
timeZone: 'Europe/Paris',
|
|
139
|
+
})}
|
|
140
|
+
</time>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function Vide({ children }: { children: ReactNode }) {
|
|
145
|
+
return <p className="zvs-vide">{children}</p>
|
|
146
|
+
}
|