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