@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,211 @@
1
+ // ============================================================================
2
+ // Les vingt dernières erreurs de console. PUR (le tampon), impur (la pose).
3
+ // ============================================================================
4
+ //
5
+ // POURQUOI UN TAMPON, ET PAS UNE LECTURE AU MOMENT DU CLIC : la console d'un
6
+ // navigateur n'est pas lisible depuis JavaScript. Ce qui s'y est affiché avant
7
+ // l'ouverture du widget est perdu — or c'est exactement ce qu'on veut : la
8
+ // pile d'erreurs qui a précédé « ça ne marche pas ». On garde donc une copie,
9
+ // bornée, posée le plus tôt possible.
10
+ //
11
+ // ⚠️ CE TAMPON EST BRANCHÉ SUR LA CONSOLE DE L'APP HÔTE. Il ne doit JAMAIS
12
+ // jeter : une exception dans `console.error` casserait le journal de l'app, et
13
+ // l'app, et personne ne soupçonnerait le bouton « Aide ». D'où le try/catch
14
+ // autour de chaque formatage, et le repli « [argument illisible] » plutôt
15
+ // qu'une erreur propagée.
16
+
17
+ /** Vingt entrées (PLAN.md §7 lot 5). Au-delà, ce n'est plus un contexte. */
18
+ export const TAILLE_TAMPON = 20
19
+
20
+ /** Une entrée plus longue que ça est une pile complète : elle noie le reste. */
21
+ export const LONGUEUR_ENTREE = 300
22
+
23
+ export type NiveauConsole = 'error' | 'warn' | 'exception' | 'rejet'
24
+
25
+ const MARQUE_ILLISIBLE = '[argument illisible]'
26
+
27
+ /**
28
+ * Rend un argument de console en texte. PUR, TOTAL — ne jette jamais.
29
+ *
30
+ * Chaque branche existe pour une valeur qui a réellement fait tomber un
31
+ * formateur naïf :
32
+ * - un `Symbol` : `` `${valeur}` `` lève un TypeError (il faut `String()`) ;
33
+ * - un objet circulaire, un `BigInt`, un getter qui jette : `JSON.stringify`
34
+ * lève ;
35
+ * - `Object.create(null)` n'a pas de `toString` : `String()` lève.
36
+ */
37
+ export function formaterArgument(valeur: unknown): string {
38
+ try {
39
+ if (valeur === null) return 'null'
40
+ if (valeur === undefined) return 'undefined'
41
+ if (typeof valeur === 'string') return valeur
42
+ if (typeof valeur === 'symbol') return String(valeur)
43
+ if (typeof valeur === 'function') {
44
+ const nom = (valeur as { name?: string }).name
45
+ return `[fonction ${nom && nom !== '' ? nom : 'anonyme'}]`
46
+ }
47
+ if (valeur instanceof Error) return formaterErreur(valeur)
48
+ if (typeof valeur === 'object') {
49
+ const rendu = JSON.stringify(valeur)
50
+ // `JSON.stringify` rend `undefined` sur certaines valeurs (une fonction
51
+ // dans un tableau, un objet de symboles seuls) sans jeter : le repli
52
+ // doit couvrir ce cas-là aussi.
53
+ return rendu === undefined ? '[objet non sérialisable]' : rendu
54
+ }
55
+ return String(valeur)
56
+ } catch {
57
+ // Circulaire, BigInt, getter qui jette, objet sans prototype : l'important
58
+ // est que l'entrée existe et que le tampon survive.
59
+ try {
60
+ return typeof valeur === 'object' ? '[objet non sérialisable]' : MARQUE_ILLISIBLE
61
+ } catch {
62
+ return MARQUE_ILLISIBLE
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Une erreur en une ligne : son nom, son message, et la PREMIÈRE ligne de
69
+ * pile.
70
+ *
71
+ * La pile entière ferait 4 Ko par entrée — vingt entrées suffiraient alors à
72
+ * dépasser à elles seules la limite de corps de l'API. La première ligne
73
+ * suffit à savoir d'où ça vient ; le reste est dans le navigateur de la
74
+ * personne, si on le lui demande.
75
+ */
76
+ function formaterErreur(erreur: Error): string {
77
+ const base = `${erreur.name}: ${erreur.message}`
78
+ const pile = typeof erreur.stack === 'string' ? erreur.stack : ''
79
+ const lignes = pile.split('\n').map((l) => l.trim())
80
+ const premiereTrame = lignes.find((l) => l.startsWith('at '))
81
+ return premiereTrame ? `${base} — ${premiereTrame}` : base
82
+ }
83
+
84
+ /** Formate une entrée complète, horodatée. PUR. */
85
+ export function formaterEntree(
86
+ niveau: NiveauConsole,
87
+ args: readonly unknown[],
88
+ horodatage?: Date,
89
+ ): string {
90
+ const corps = args.map(formaterArgument).join(' ')
91
+ const heure = horodatage ? `${horodatage.toISOString().slice(11, 19)} ` : ''
92
+ const ligne = `${heure}[${niveau}] ${corps}`
93
+ return ligne.length <= LONGUEUR_ENTREE ? ligne : `${ligne.slice(0, LONGUEUR_ENTREE - 1)}…`
94
+ }
95
+
96
+ /**
97
+ * Le tampon circulaire. PUR (aucune I/O), et incassable par construction.
98
+ */
99
+ export class TamponErreurs {
100
+ private readonly entrees: string[] = []
101
+
102
+ constructor(private readonly taille: number = TAILLE_TAMPON) {}
103
+
104
+ /** Ajoute une entrée. Ne jette jamais : le formatage est déjà total. */
105
+ ajouter(niveau: NiveauConsole, args: readonly unknown[], horodatage?: Date): void {
106
+ try {
107
+ this.entrees.push(formaterEntree(niveau, args, horodatage))
108
+ // La plus ANCIENNE part : ce qui vient de se passer explique mieux que
109
+ // ce qui s'est passé au chargement de la page.
110
+ while (this.entrees.length > this.taille) this.entrees.shift()
111
+ } catch {
112
+ // Rien. Un tampon qui jette casse la console de l'app hôte.
113
+ }
114
+ }
115
+
116
+ /** Les entrées, de la plus ancienne à la plus récente. Copie défensive. */
117
+ lire(): string[] {
118
+ return [...this.entrees]
119
+ }
120
+
121
+ vider(): void {
122
+ this.entrees.length = 0
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Le tampon partagé du widget.
128
+ *
129
+ * ⚠️ UN SEUL, au niveau du module : deux tampons se partageraient les erreurs
130
+ * au hasard de l'ordre des poses, et chacun en aurait la moitié.
131
+ */
132
+ export const tamponErreurs = new TamponErreurs()
133
+
134
+ /* ─── La pose (impure) ──────────────────────────────────────────────── */
135
+
136
+ interface ConsoleObservee {
137
+ error?: (...args: unknown[]) => void
138
+ warn?: (...args: unknown[]) => void
139
+ }
140
+
141
+ export interface PorteePose {
142
+ console?: ConsoleObservee
143
+ addEventListener?: (type: string, ecouteur: (evenement: unknown) => void) => void
144
+ removeEventListener?: (type: string, ecouteur: (evenement: unknown) => void) => void
145
+ }
146
+
147
+ let poseFaite = false
148
+
149
+ /**
150
+ * Branche le tampon sur la console et sur les erreurs non rattrapées.
151
+ *
152
+ * ⚠️ À APPELER LE PLUS TÔT POSSIBLE dans l'app hôte, avant le rendu — pas
153
+ * seulement au montage du widget. Posé au montage, le tampon manque
154
+ * exactement les erreurs du démarrage : celles qu'on signale.
155
+ *
156
+ * Rend la fonction de dépose. Idempotent : deux appels ne doublent pas les
157
+ * entrées (c'est ce qui arriverait si l'app le pose ET que le widget se monte).
158
+ */
159
+ export function installerTamponErreurs(
160
+ portee: PorteePose = globalThis as unknown as PorteePose,
161
+ tampon: TamponErreurs = tamponErreurs,
162
+ ): () => void {
163
+ if (poseFaite) return () => {}
164
+ const console_ = portee?.console
165
+ if (!console_) return () => {}
166
+
167
+ // ⚠️ On garde la référence ORIGINALE, pas une copie liée : c'est elle qu'on
168
+ // remettra à la dépose. Restaurer un `bind()` laisserait à l'app une
169
+ // fonction qui n'est plus la sienne — un autre outil posé par-dessus (une
170
+ // sonde d'erreurs, un test qui espionne la console) ne se retrouverait plus.
171
+ // L'appel, lui, passe par `apply` avec la console pour `this`.
172
+ const erreurOrigine = console_.error
173
+ const avertissementOrigine = console_.warn
174
+
175
+ if (erreurOrigine) {
176
+ console_.error = (...args: unknown[]) => {
177
+ tampon.ajouter('error', args, new Date())
178
+ erreurOrigine.apply(console_, args)
179
+ }
180
+ }
181
+ if (avertissementOrigine) {
182
+ console_.warn = (...args: unknown[]) => {
183
+ tampon.ajouter('warn', args, new Date())
184
+ avertissementOrigine.apply(console_, args)
185
+ }
186
+ }
187
+
188
+ // Une exception non rattrapée n'atteint PAS `console.error` du point de vue
189
+ // du code : le navigateur l'affiche lui-même. Sans ces deux écouteurs, le
190
+ // tampon serait vide précisément quand l'app vient de casser.
191
+ const surErreur = (evenement: unknown) => {
192
+ const e = evenement as { message?: string; error?: unknown }
193
+ tampon.ajouter('exception', [e?.error ?? e?.message ?? evenement], new Date())
194
+ }
195
+ const surRejet = (evenement: unknown) => {
196
+ const e = evenement as { reason?: unknown }
197
+ tampon.ajouter('rejet', [e?.reason ?? evenement], new Date())
198
+ }
199
+ portee.addEventListener?.('error', surErreur)
200
+ portee.addEventListener?.('unhandledrejection', surRejet)
201
+
202
+ poseFaite = true
203
+
204
+ return () => {
205
+ if (erreurOrigine) console_.error = erreurOrigine
206
+ if (avertissementOrigine) console_.warn = avertissementOrigine
207
+ portee.removeEventListener?.('error', surErreur)
208
+ portee.removeEventListener?.('unhandledrejection', surRejet)
209
+ poseFaite = false
210
+ }
211
+ }
package/src/types.ts ADDED
@@ -0,0 +1,105 @@
1
+ // ============================================================================
2
+ // Ce que le widget voit passer. Le fil, vu du navigateur.
3
+ // ============================================================================
4
+ //
5
+ // ⚠️ Ces types DOUBLENT ceux de `@zevra/support`, et c'est délibéré : le
6
+ // widget ne doit avoir aucun lien, même de type, avec le SDK serveur. Un
7
+ // `import type` suffirait à faire remonter le paquet serveur dans le graphe de
8
+ // dépendances de l'app cliente — le typage disparaît à la compilation, mais
9
+ // l'entrée dans `package.json`, elle, reste, et c'est par là qu'un jour
10
+ // quelqu'un importera « juste une fonction ». Le paquet qui détient la clé
11
+ // n'est pas installable dans un bundle de navigateur ; ce paquet-ci l'est.
12
+ //
13
+ // La forme vient de PLAN.md §4. Elle est rendue par les routes de relais, qui
14
+ // transmettent la réponse de l'API v1 telle quelle.
15
+
16
+ export type TypeDemande = 'bug' | 'question' | 'idee' | 'beta'
17
+
18
+ export type StatutDemande =
19
+ | 'nouvelle'
20
+ | 'ouverte'
21
+ | 'en_attente_requerant'
22
+ | 'resolue'
23
+ | 'fermee'
24
+
25
+ export type AuteurMessage = 'requerant' | 'equipe' | 'ia' | 'systeme'
26
+
27
+ export const LIBELLES_TYPE: Record<TypeDemande, string> = {
28
+ bug: 'Un problème',
29
+ question: 'Une question',
30
+ idee: 'Une idée',
31
+ beta: 'Retour de bêta',
32
+ }
33
+
34
+ export const LIBELLES_STATUT: Record<StatutDemande, string> = {
35
+ nouvelle: 'Nouvelle',
36
+ ouverte: 'En cours',
37
+ en_attente_requerant: 'Votre réponse est attendue',
38
+ resolue: 'Résolue',
39
+ fermee: 'Fermée',
40
+ }
41
+
42
+ export interface ContexteReleve {
43
+ url?: string
44
+ user_agent?: string
45
+ version?: string
46
+ os?: string
47
+ viewport?: string
48
+ langue?: string
49
+ console?: string[]
50
+ extra?: Record<string, unknown>
51
+ }
52
+
53
+ export interface ResumeDemande {
54
+ id: string
55
+ reference: string
56
+ type: TypeDemande
57
+ statut: StatutDemande
58
+ titre: string
59
+ created_at: string
60
+ dernier_message_at: string
61
+ }
62
+
63
+ export interface MessageLu {
64
+ id: string
65
+ auteur: AuteurMessage
66
+ corps: string
67
+ created_at: string
68
+ }
69
+
70
+ export interface PieceLue {
71
+ id: string
72
+ nom: string
73
+ type_mime: string
74
+ octets: number
75
+ url?: string | null
76
+ }
77
+
78
+ export interface DemandeLue {
79
+ demande: ResumeDemande & { description: string }
80
+ messages: MessageLu[]
81
+ pieces: PieceLue[]
82
+ }
83
+
84
+ export interface ScenarioCampagne {
85
+ cle: string
86
+ titre: string
87
+ etapes: string[]
88
+ }
89
+
90
+ export interface ProgrammeActif {
91
+ programme?: { id: string; nom: string; description?: string | null } | null
92
+ campagne?: {
93
+ id: string
94
+ titre: string
95
+ consignes?: string | null
96
+ scenarios: ScenarioCampagne[]
97
+ } | null
98
+ }
99
+
100
+ /** L'enveloppe d'erreur, identique côté API v1 et côté relais. */
101
+ export interface ErreurDuRelais {
102
+ code: string
103
+ message: string
104
+ champs?: Record<string, string>
105
+ }
@@ -0,0 +1,113 @@
1
+ // ============================================================================
2
+ // Ce qu'un signalement doit contenir pour valoir la peine d'être envoyé. PUR.
3
+ // ============================================================================
4
+ //
5
+ // Le serveur revérifie tout (c'est lui qui décide), et c'est bien pour ça que
6
+ // cette validation-ci existe : elle ne protège pas la base, elle évite un
7
+ // aller-retour et un message générique là où on peut dire précisément ce qui
8
+ // manque, à côté du champ concerné.
9
+ //
10
+ // ⚠️ On rend TOUS les défauts d'un coup, comme `verifierDemandeEntrante` du
11
+ // socle. Corriger un champ pour découvrir le suivant à chaque envoi est la
12
+ // manière la plus sûre de faire abandonner quelqu'un qui voulait signaler un
13
+ // bug.
14
+
15
+ import type { TypeDemande } from './types'
16
+
17
+ /** Le schéma borne `demande.titre` à 200 (PLAN.md §3). */
18
+ export const LONGUEUR_TITRE = 200
19
+
20
+ /** La description part dans un corps borné à 256 Ko, contexte compris. */
21
+ export const LONGUEUR_DESCRIPTION = 5000
22
+
23
+ /** Nombre minimal de caractères pour qu'une description diagnostique. */
24
+ export const MINIMUM_DESCRIPTION = 10
25
+
26
+ const TYPES: readonly string[] = ['bug', 'question', 'idee', 'beta']
27
+
28
+ export interface SaisieSignalement {
29
+ type?: string | null
30
+ titre?: string | null
31
+ description?: string | null
32
+ /** Retour de bêta : 1 à 5, ou rien. */
33
+ score?: number | null
34
+ }
35
+
36
+ /**
37
+ * Rend un message par champ fautif. `{}` = rien à redire. PUR.
38
+ *
39
+ * Les messages sont écrits pour la personne qui remplit le formulaire, pas
40
+ * pour le journal : ils disent quoi faire, pas ce qui a échoué.
41
+ */
42
+ export function verifierSignalement(saisie: SaisieSignalement): Record<string, string> {
43
+ const defauts: Record<string, string> = {}
44
+
45
+ // ⚠️ Type et titre FACULTATIFS : le formulaire ne demande plus qu'une
46
+ // phrase, et c'est le triage IA de Support qui les pose. Ils ne sont
47
+ // vérifiés que s'ils sont fournis (pré-remplissage d'un retour de bêta).
48
+ if (saisie.type !== null && saisie.type !== undefined && !TYPES.includes(saisie.type)) {
49
+ defauts.type = 'Choisissez de quoi il s’agit.'
50
+ }
51
+
52
+ const titre = (saisie.titre ?? '').trim()
53
+ if (titre.length > LONGUEUR_TITRE) {
54
+ // On DIT le dépassement plutôt que de tronquer en silence : un titre coupé
55
+ // au milieu d'un mot dans la boîte de l'équipe a l'air d'un bug du support.
56
+ defauts.titre = `Le titre dépasse ${LONGUEUR_TITRE} caractères (${titre.length}).`
57
+ }
58
+
59
+ const description = (saisie.description ?? '').trim()
60
+ if (description === '') {
61
+ defauts.description = 'Dites-nous en une phrase ce qui se passe.'
62
+ } else if (description.length < MINIMUM_DESCRIPTION) {
63
+ defauts.description = 'Quelques mots de plus aideraient beaucoup.'
64
+ } else if (description.length > LONGUEUR_DESCRIPTION) {
65
+ defauts.description = `La description dépasse ${LONGUEUR_DESCRIPTION} caractères (${description.length}). Une pièce jointe passe mieux qu’un copier-coller.`
66
+ }
67
+
68
+ // Le score est FACULTATIF : absent, il ne vaut pas zéro. Un zéro écrit à la
69
+ // place d'une absence ferait descendre la moyenne d'une campagne de bêta
70
+ // avec des notes que personne n'a données.
71
+ if (saisie.score !== null && saisie.score !== undefined) {
72
+ const score = Number(saisie.score)
73
+ if (!Number.isFinite(score) || score < 1 || score > 5 || !Number.isInteger(score)) {
74
+ defauts.score = 'La note va de 1 à 5.'
75
+ }
76
+ }
77
+
78
+ return defauts
79
+ }
80
+
81
+ /** `true` quand la saisie peut partir. PUR. */
82
+ export function signalementValide(saisie: SaisieSignalement): boolean {
83
+ return Object.keys(verifierSignalement(saisie)).length === 0
84
+ }
85
+
86
+ /** Garde de type sur le vocabulaire fermé. PUR. */
87
+ export function estTypeDemande(valeur: unknown): valeur is TypeDemande {
88
+ return typeof valeur === 'string' && TYPES.includes(valeur)
89
+ }
90
+
91
+ /**
92
+ * Les types à proposer dans la liste déroulante. PUR.
93
+ *
94
+ * ⚠️ Le type pré-rempli est AJOUTÉ s'il manque, et ce n'est pas du confort.
95
+ * `typesOfferts` vaut par défaut `['bug','question','idee']` — pas `beta` —
96
+ * alors que l'onglet Bêta pré-remplit justement `beta`. Un `<select>` contrôlé
97
+ * dont la valeur n'a aucune option correspondante s'affiche VIDE
98
+ * (`selectedIndex = -1`) : le champ « de quoi s'agit-il ? » paraît non rempli,
99
+ * et le premier geste de la personne — ouvrir la liste et choisir — remplace
100
+ * silencieusement `beta` par `bug` tout en gardant la campagne et le scénario.
101
+ * Le retour de bêta arrive alors dans la console sous un type qui n'est pas le
102
+ * sien, et la campagne compte un problème de moins.
103
+ *
104
+ * Rien de tout cela ne lève d'erreur : c'est un champ qui a l'air vide.
105
+ */
106
+ export function typesAfficher(
107
+ typesOfferts: readonly TypeDemande[],
108
+ typePrefill: TypeDemande | undefined,
109
+ ): TypeDemande[] {
110
+ const liste = [...typesOfferts]
111
+ if (typePrefill && !liste.includes(typePrefill)) liste.unshift(typePrefill)
112
+ return liste
113
+ }