makethisbetter 1.0.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.
Files changed (168) hide show
  1. package/LICENSE +62 -0
  2. package/README.md +113 -0
  3. package/dist/annotate/draw.d.ts +21 -0
  4. package/dist/annotate/draw.d.ts.map +1 -0
  5. package/dist/annotate/draw.test.d.ts +2 -0
  6. package/dist/annotate/draw.test.d.ts.map +1 -0
  7. package/dist/annotate/highlighter.d.ts +11 -0
  8. package/dist/annotate/highlighter.d.ts.map +1 -0
  9. package/dist/annotate/highlighter.test.d.ts +2 -0
  10. package/dist/annotate/highlighter.test.d.ts.map +1 -0
  11. package/dist/annotate/overlay.d.ts +17 -0
  12. package/dist/annotate/overlay.d.ts.map +1 -0
  13. package/dist/annotate/overlay.test.d.ts +2 -0
  14. package/dist/annotate/overlay.test.d.ts.map +1 -0
  15. package/dist/annotate/pin.d.ts +10 -0
  16. package/dist/annotate/pin.d.ts.map +1 -0
  17. package/dist/annotate/pin.test.d.ts +2 -0
  18. package/dist/annotate/pin.test.d.ts.map +1 -0
  19. package/dist/annotate/session.d.ts +20 -0
  20. package/dist/annotate/session.d.ts.map +1 -0
  21. package/dist/annotate/session.test.d.ts +2 -0
  22. package/dist/annotate/session.test.d.ts.map +1 -0
  23. package/dist/annotate/toolbar.d.ts +17 -0
  24. package/dist/annotate/toolbar.d.ts.map +1 -0
  25. package/dist/annotate/toolbar.test.d.ts +2 -0
  26. package/dist/annotate/toolbar.test.d.ts.map +1 -0
  27. package/dist/api/client.d.ts +10 -0
  28. package/dist/api/client.d.ts.map +1 -0
  29. package/dist/api/client.test.d.ts +2 -0
  30. package/dist/api/client.test.d.ts.map +1 -0
  31. package/dist/context/collector.d.ts +10 -0
  32. package/dist/context/collector.d.ts.map +1 -0
  33. package/dist/context/collector.test.d.ts +2 -0
  34. package/dist/context/collector.test.d.ts.map +1 -0
  35. package/dist/context/console.d.ts +10 -0
  36. package/dist/context/console.d.ts.map +1 -0
  37. package/dist/context/console.test.d.ts +2 -0
  38. package/dist/context/console.test.d.ts.map +1 -0
  39. package/dist/context/error-source.d.ts +21 -0
  40. package/dist/context/error-source.d.ts.map +1 -0
  41. package/dist/context/error-source.test.d.ts +2 -0
  42. package/dist/context/error-source.test.d.ts.map +1 -0
  43. package/dist/context/frustration.d.ts +27 -0
  44. package/dist/context/frustration.d.ts.map +1 -0
  45. package/dist/context/frustration.test.d.ts +2 -0
  46. package/dist/context/frustration.test.d.ts.map +1 -0
  47. package/dist/global.d.ts +3 -0
  48. package/dist/global.d.ts.map +1 -0
  49. package/dist/i18n/en.d.ts +47 -0
  50. package/dist/i18n/en.d.ts.map +1 -0
  51. package/dist/i18n/index.d.ts +4 -0
  52. package/dist/i18n/index.d.ts.map +1 -0
  53. package/dist/i18n/index.test.d.ts +2 -0
  54. package/dist/i18n/index.test.d.ts.map +1 -0
  55. package/dist/i18n/zh-CN.d.ts +47 -0
  56. package/dist/i18n/zh-CN.d.ts.map +1 -0
  57. package/dist/index.d.mts +8 -0
  58. package/dist/index.d.ts +8 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/makethisbetter.cjs.js +84 -0
  61. package/dist/makethisbetter.cjs.js.map +1 -0
  62. package/dist/makethisbetter.esm.js +1433 -0
  63. package/dist/makethisbetter.esm.js.map +1 -0
  64. package/dist/makethisbetter.js +84 -0
  65. package/dist/makethisbetter.js.map +1 -0
  66. package/dist/popup/comment.d.ts +31 -0
  67. package/dist/popup/comment.d.ts.map +1 -0
  68. package/dist/popup/comment.test.d.ts +2 -0
  69. package/dist/popup/comment.test.d.ts.map +1 -0
  70. package/dist/popup/success.d.ts +8 -0
  71. package/dist/popup/success.d.ts.map +1 -0
  72. package/dist/popup/success.test.d.ts +2 -0
  73. package/dist/popup/success.test.d.ts.map +1 -0
  74. package/dist/record/control-bar.d.ts +12 -0
  75. package/dist/record/control-bar.d.ts.map +1 -0
  76. package/dist/record/control-bar.test.d.ts +2 -0
  77. package/dist/record/control-bar.test.d.ts.map +1 -0
  78. package/dist/record/session.d.ts +26 -0
  79. package/dist/record/session.d.ts.map +1 -0
  80. package/dist/record/session.test.d.ts +2 -0
  81. package/dist/record/session.test.d.ts.map +1 -0
  82. package/dist/screenshot/capture.d.ts +3 -0
  83. package/dist/screenshot/capture.d.ts.map +1 -0
  84. package/dist/screenshot/capture.test.d.ts +2 -0
  85. package/dist/screenshot/capture.test.d.ts.map +1 -0
  86. package/dist/styles.d.ts +9 -0
  87. package/dist/styles.d.ts.map +1 -0
  88. package/dist/types.d.ts +45 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/widget/controller.d.ts +37 -0
  91. package/dist/widget/controller.d.ts.map +1 -0
  92. package/dist/widget/controller.test.d.ts +2 -0
  93. package/dist/widget/controller.test.d.ts.map +1 -0
  94. package/dist/widget/frustration-prompt.d.ts +11 -0
  95. package/dist/widget/frustration-prompt.d.ts.map +1 -0
  96. package/dist/widget/frustration-prompt.test.d.ts +2 -0
  97. package/dist/widget/frustration-prompt.test.d.ts.map +1 -0
  98. package/dist/widget/payload.d.ts +5 -0
  99. package/dist/widget/payload.d.ts.map +1 -0
  100. package/dist/widget/payload.test.d.ts +2 -0
  101. package/dist/widget/payload.test.d.ts.map +1 -0
  102. package/dist/widget/pet.d.ts +12 -0
  103. package/dist/widget/pet.d.ts.map +1 -0
  104. package/dist/widget/pet.test.d.ts +2 -0
  105. package/dist/widget/pet.test.d.ts.map +1 -0
  106. package/dist/widget/shadow.d.ts +11 -0
  107. package/dist/widget/shadow.d.ts.map +1 -0
  108. package/dist/widget/shadow.test.d.ts +2 -0
  109. package/dist/widget/shadow.test.d.ts.map +1 -0
  110. package/dist/widget/tab.d.ts +10 -0
  111. package/dist/widget/tab.d.ts.map +1 -0
  112. package/dist/widget/tab.test.d.ts +2 -0
  113. package/dist/widget/tab.test.d.ts.map +1 -0
  114. package/package.json +64 -0
  115. package/src/annotate/draw.test.ts +168 -0
  116. package/src/annotate/draw.ts +108 -0
  117. package/src/annotate/highlighter.test.ts +47 -0
  118. package/src/annotate/highlighter.ts +84 -0
  119. package/src/annotate/overlay.test.ts +91 -0
  120. package/src/annotate/overlay.ts +44 -0
  121. package/src/annotate/pin.test.ts +87 -0
  122. package/src/annotate/pin.ts +28 -0
  123. package/src/annotate/session.test.ts +219 -0
  124. package/src/annotate/session.ts +104 -0
  125. package/src/annotate/toolbar.test.ts +132 -0
  126. package/src/annotate/toolbar.ts +81 -0
  127. package/src/api/client.test.ts +75 -0
  128. package/src/api/client.ts +77 -0
  129. package/src/context/collector.test.ts +14 -0
  130. package/src/context/collector.ts +41 -0
  131. package/src/context/console.test.ts +44 -0
  132. package/src/context/console.ts +34 -0
  133. package/src/context/error-source.test.ts +115 -0
  134. package/src/context/error-source.ts +62 -0
  135. package/src/context/frustration.test.ts +134 -0
  136. package/src/context/frustration.ts +145 -0
  137. package/src/global.ts +3 -0
  138. package/src/i18n/en.ts +47 -0
  139. package/src/i18n/index.test.ts +24 -0
  140. package/src/i18n/index.ts +16 -0
  141. package/src/i18n/zh-CN.ts +47 -0
  142. package/src/index.ts +32 -0
  143. package/src/popup/comment.test.ts +73 -0
  144. package/src/popup/comment.ts +177 -0
  145. package/src/popup/success.test.ts +75 -0
  146. package/src/popup/success.ts +33 -0
  147. package/src/record/control-bar.test.ts +110 -0
  148. package/src/record/control-bar.ts +46 -0
  149. package/src/record/session.test.ts +153 -0
  150. package/src/record/session.ts +139 -0
  151. package/src/screenshot/capture.test.ts +68 -0
  152. package/src/screenshot/capture.ts +96 -0
  153. package/src/styles/widget.css +847 -0
  154. package/src/styles.ts +9 -0
  155. package/src/types.ts +46 -0
  156. package/src/vite-env.d.ts +6 -0
  157. package/src/widget/controller.test.ts +62 -0
  158. package/src/widget/controller.ts +249 -0
  159. package/src/widget/frustration-prompt.test.ts +86 -0
  160. package/src/widget/frustration-prompt.ts +34 -0
  161. package/src/widget/payload.test.ts +167 -0
  162. package/src/widget/payload.ts +31 -0
  163. package/src/widget/pet.test.ts +63 -0
  164. package/src/widget/pet.ts +67 -0
  165. package/src/widget/shadow.test.ts +25 -0
  166. package/src/widget/shadow.ts +41 -0
  167. package/src/widget/tab.test.ts +92 -0
  168. package/src/widget/tab.ts +31 -0
package/src/styles.ts ADDED
@@ -0,0 +1,9 @@
1
+ export const BRAND_COLOR = '#059669'
2
+
3
+ export const DRAW_STROKE = {
4
+ color: BRAND_COLOR,
5
+ width: '3.5',
6
+ linecap: 'round',
7
+ linejoin: 'round',
8
+ filter: 'drop-shadow(0 1px 2px rgba(5,150,105,0.35))',
9
+ } as const
package/src/types.ts ADDED
@@ -0,0 +1,46 @@
1
+ export interface MakeThisBetterConfig {
2
+ projectKey: string
3
+ entryMode?: 'button' | 'pet'
4
+ locale?: string
5
+ position?: 'left' | 'right'
6
+ theme?: 'light' | 'dark' | 'auto'
7
+ apiUrl?: string
8
+ frustrationDetection?: boolean
9
+ user?: {
10
+ id?: string
11
+ email?: string
12
+ name?: string
13
+ }
14
+ }
15
+
16
+ export interface Annotation {
17
+ type: 'pin' | 'draw'
18
+ x: number
19
+ y: number
20
+ targetSelector?: string
21
+ targetText?: string
22
+ targetName?: string
23
+ drawPath?: string
24
+ }
25
+
26
+ export interface FeedbackPayload {
27
+ description: string
28
+ page_url: string
29
+ user_agent: string
30
+ browser: string
31
+ os: string
32
+ screen_width: number
33
+ screen_height: number
34
+ console_errors: string[]
35
+ annotations: Annotation[]
36
+ target_element?: {
37
+ selector: string
38
+ text: string
39
+ name: string
40
+ }
41
+ user_id?: string
42
+ user_email?: string
43
+ user_name?: string
44
+ recording_events?: Record<string, unknown>[]
45
+ recording_duration?: number
46
+ }
@@ -0,0 +1,6 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.css?inline' {
4
+ const css: string
5
+ export default css
6
+ }
@@ -0,0 +1,62 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { MakeThisBetter } from '../index'
3
+
4
+ vi.mock('html-to-image', () => ({
5
+ toPng: vi.fn(async () => 'data:image/png;base64,AA=='),
6
+ }))
7
+
8
+ describe('MakeThisBetter widget flow', () => {
9
+ afterEach(() => {
10
+ MakeThisBetter.destroy()
11
+ vi.restoreAllMocks()
12
+ document.body.innerHTML = ''
13
+ })
14
+
15
+ it('submits feedback from a pin annotation', async () => {
16
+ const target = document.createElement('button')
17
+ target.id = 'export-btn'
18
+ target.textContent = 'Export PDF'
19
+ document.body.appendChild(target)
20
+
21
+ document.elementFromPoint = vi.fn(() => target)
22
+
23
+ let capturedBody: FormData | null = null
24
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
25
+ capturedBody = init?.body instanceof FormData ? init.body : null
26
+ return new Response(JSON.stringify({ id: 'fb_1' }), { status: 201 })
27
+ })
28
+
29
+ MakeThisBetter.init({
30
+ projectKey: 'proj_test',
31
+ apiUrl: 'https://api.example.com/api/v1',
32
+ user: { id: 'user_1', email: 'user@example.com', name: 'User One' },
33
+ })
34
+
35
+ const shadow = document.getElementById('mtb-widget-host')!.shadowRoot!
36
+ shadow.querySelector<HTMLButtonElement>('.mtb-tab')!.click()
37
+
38
+ const overlay = shadow.querySelector<HTMLDivElement>('.mtb-overlay')!
39
+ overlay.dispatchEvent(new MouseEvent('mousedown', { clientX: 20, clientY: 30, bubbles: true }))
40
+ overlay.dispatchEvent(new MouseEvent('mouseup', { clientX: 20, clientY: 30, bubbles: true }))
41
+
42
+ const textarea = shadow.querySelector<HTMLTextAreaElement>('.mtb-textarea')!
43
+ textarea.value = 'Export is broken'
44
+ textarea.dispatchEvent(new Event('input'))
45
+ shadow.querySelector<HTMLButtonElement>('.mtb-submit-btn')!.click()
46
+
47
+ await vi.waitFor(() => {
48
+ expect(fetchSpy).toHaveBeenCalled()
49
+ })
50
+
51
+ const formData = capturedBody as unknown as FormData
52
+ expect(formData.get('feedback[description]')).toBe('Export is broken')
53
+ expect(formData.get('feedback[user_id]')).toBe('user_1')
54
+ expect(formData.get('feedback[screenshot]')).toBeInstanceOf(File)
55
+ expect(formData.get('feedback[target_element]')).toBe(JSON.stringify({
56
+ selector: '#export-btn',
57
+ text: 'Export PDF',
58
+ name: 'Export PDF',
59
+ }))
60
+ expect(shadow.querySelector('.mtb-success-title')?.textContent).toBe('Thanks!')
61
+ })
62
+ })
@@ -0,0 +1,249 @@
1
+ import { ShadowContainer } from './shadow'
2
+ import { FeedbackTab } from './tab'
3
+ import { PetEntry } from './pet'
4
+ import { FrustrationPromptCard } from './frustration-prompt'
5
+ import { AnnotationToolbar } from '../annotate/toolbar'
6
+ import type { ToolbarMode } from '../annotate/toolbar'
7
+ import { AnnotationSession } from '../annotate/session'
8
+ import { CommentPopup } from '../popup/comment'
9
+ import { SuccessCard } from '../popup/success'
10
+ import { captureScreenshot } from '../screenshot/capture'
11
+ import { ConsoleErrorCollector } from '../context/console'
12
+ import { FrustrationDetector } from '../context/frustration'
13
+ import { collectPageContext } from '../context/collector'
14
+ import { ApiClient } from '../api/client'
15
+ import { buildPayload } from './payload'
16
+ import { getMessages } from '../i18n'
17
+ import { RecordSession } from '../record/session'
18
+ import type { RecordingResult } from '../record/session'
19
+ import { RecordControlBar } from '../record/control-bar'
20
+ import type { MakeThisBetterConfig, Annotation } from '../types'
21
+
22
+ type Mode = 'idle' | 'annotating' | 'recording' | 'popup'
23
+
24
+ export class WidgetController {
25
+ private config: MakeThisBetterConfig
26
+ private shadow!: ShadowContainer
27
+ private tab: FeedbackTab | null = null
28
+ private pet: PetEntry | null = null
29
+ private toolbar: AnnotationToolbar | null = null
30
+ private session: AnnotationSession | null = null
31
+ private popup: CommentPopup | null = null
32
+ private success: SuccessCard | null = null
33
+ private frustrationPrompt: FrustrationPromptCard | null = null
34
+ private apiClient: ApiClient
35
+ private consoleCollector: ConsoleErrorCollector
36
+ private frustrationDetector: FrustrationDetector | null = null
37
+ private messages: ReturnType<typeof getMessages>
38
+ private mode: Mode = 'idle'
39
+ private currentAnnotation: Annotation | null = null
40
+ private recordSession: RecordSession | null = null
41
+ private recordBar: RecordControlBar | null = null
42
+ private currentRecording: RecordingResult | null = null
43
+
44
+ constructor(config: MakeThisBetterConfig) {
45
+ this.config = config
46
+ this.messages = getMessages(config.locale ?? 'en')
47
+ this.apiClient = new ApiClient(config.projectKey, config.apiUrl)
48
+ this.consoleCollector = new ConsoleErrorCollector()
49
+ this.consoleCollector.start()
50
+ this.buildUI()
51
+
52
+ if (config.frustrationDetection !== false) {
53
+ this.frustrationDetector = new FrustrationDetector(() => this.handleFrustration())
54
+ this.frustrationDetector.start()
55
+ }
56
+ }
57
+
58
+ private buildUI(): void {
59
+ this.shadow = new ShadowContainer(this.config.theme ?? 'auto')
60
+
61
+ if ((this.config.entryMode ?? 'button') === 'pet') {
62
+ this.pet = new PetEntry(this.shadow, this.messages, () => this.handleTabClick())
63
+ } else {
64
+ this.tab = new FeedbackTab(
65
+ this.shadow,
66
+ this.messages,
67
+ this.config.position ?? 'right',
68
+ () => this.handleTabClick(),
69
+ )
70
+ }
71
+ }
72
+
73
+ private handleTabClick(): void {
74
+ if (this.mode === 'idle') {
75
+ this.dismissFrustrationPrompt()
76
+ this.enterAnnotationMode()
77
+ } else {
78
+ this.exitAll()
79
+ }
80
+ }
81
+
82
+ private handleFrustration(): void {
83
+ if (this.mode !== 'idle' || this.frustrationPrompt) return
84
+
85
+ this.frustrationPrompt = new FrustrationPromptCard(this.shadow, this.messages, {
86
+ onTell: () => {
87
+ this.dismissFrustrationPrompt()
88
+ this.enterAnnotationMode()
89
+ },
90
+ onDismiss: () => this.dismissFrustrationPrompt(),
91
+ })
92
+ }
93
+
94
+ private dismissFrustrationPrompt(): void {
95
+ this.frustrationPrompt?.destroy()
96
+ this.frustrationPrompt = null
97
+ }
98
+
99
+ private enterAnnotationMode(): void {
100
+ this.mode = 'annotating'
101
+ this.tab?.setActive(true)
102
+ this.pet?.setActive(true)
103
+
104
+ this.toolbar = new AnnotationToolbar(
105
+ this.shadow,
106
+ this.messages,
107
+ () => this.exitAll(),
108
+ (mode: ToolbarMode) => this.handleToolbarModeChange(mode),
109
+ )
110
+ this.session = new AnnotationSession(this.shadow, this.messages, (annotation, targetName) => {
111
+ this.currentAnnotation = annotation
112
+ this.showPopup(annotation.x, annotation.y, targetName)
113
+ })
114
+ }
115
+
116
+ private handleToolbarModeChange(mode: ToolbarMode): void {
117
+ if (mode === 'record') {
118
+ this.enterRecordingMode()
119
+ } else {
120
+ this.exitRecordingMode()
121
+ if (!this.session) {
122
+ this.session = new AnnotationSession(this.shadow, this.messages, (annotation, targetName) => {
123
+ this.currentAnnotation = annotation
124
+ this.showPopup(annotation.x, annotation.y, targetName)
125
+ })
126
+ }
127
+ this.mode = 'annotating'
128
+ }
129
+ }
130
+
131
+ private async enterRecordingMode(): Promise<void> {
132
+ this.mode = 'recording'
133
+ this.session?.destroy()
134
+ this.session = null
135
+
136
+ this.recordSession = new RecordSession(this.shadow, () => this.stopRecording())
137
+ this.recordBar = new RecordControlBar(
138
+ this.shadow,
139
+ this.messages,
140
+ () => this.recordSession?.getDuration() ?? 0,
141
+ () => this.stopRecording(),
142
+ )
143
+
144
+ await this.recordSession.start()
145
+ }
146
+
147
+ private stopRecording(): void {
148
+ if (!this.recordSession) return
149
+ this.currentRecording = this.recordSession.stop()
150
+ this.exitRecordingMode()
151
+ this.showPopup(
152
+ window.innerWidth / 2,
153
+ window.innerHeight / 2,
154
+ this.messages.record.timer_label,
155
+ )
156
+ }
157
+
158
+ private exitRecordingMode(): void {
159
+ this.recordSession?.destroy()
160
+ this.recordSession = null
161
+ this.recordBar?.destroy()
162
+ this.recordBar = null
163
+ }
164
+
165
+ private showPopup(x: number, y: number, targetName: string): void {
166
+ this.mode = 'popup'
167
+ this.session?.dismissInteraction()
168
+
169
+ this.popup = new CommentPopup(this.shadow, {
170
+ targetName,
171
+ x,
172
+ y,
173
+ messages: this.messages,
174
+ onSubmit: (description) => this.handleSubmit(description),
175
+ onClose: () => this.exitAll(),
176
+ })
177
+ }
178
+
179
+ private async handleSubmit(description: string): Promise<void> {
180
+ if (!this.popup) return
181
+ this.popup.setLoading(true)
182
+ const annotation = this.currentAnnotation
183
+ const recording = this.currentRecording ?? undefined
184
+
185
+ const [screenshot, pageContext] = await Promise.all([
186
+ captureScreenshot(annotation ? [annotation] : []),
187
+ Promise.resolve(collectPageContext()),
188
+ ])
189
+
190
+ const payload = buildPayload(
191
+ description,
192
+ pageContext,
193
+ this.consoleCollector.getErrors(),
194
+ annotation,
195
+ this.config.user,
196
+ recording,
197
+ )
198
+
199
+ try {
200
+ await this.apiClient.submitFeedback(payload, screenshot)
201
+ this.popup?.destroy()
202
+ this.popup = null
203
+ this.session?.destroy()
204
+ this.session = null
205
+ this.currentRecording = null
206
+ this.showSuccess()
207
+ } catch {
208
+ this.popup?.setLoading(false)
209
+ this.popup?.setError(this.messages.error.submit)
210
+ }
211
+ }
212
+
213
+ private showSuccess(): void {
214
+ this.mode = 'idle'
215
+ this.tab?.setActive(false)
216
+ this.pet?.setActive(false)
217
+ this.success = new SuccessCard(this.shadow, this.messages, () => {
218
+ this.success?.destroy()
219
+ this.success = null
220
+ })
221
+ }
222
+
223
+ private exitAll(): void {
224
+ this.mode = 'idle'
225
+ this.tab?.setActive(false)
226
+ this.pet?.setActive(false)
227
+ this.toolbar?.destroy()
228
+ this.toolbar = null
229
+ this.session?.destroy()
230
+ this.session = null
231
+ this.popup?.destroy()
232
+ this.popup = null
233
+ this.currentAnnotation = null
234
+ this.currentRecording = null
235
+ this.exitRecordingMode()
236
+ }
237
+
238
+ destroy(): void {
239
+ this.exitAll()
240
+ this.consoleCollector.stop()
241
+ this.frustrationDetector?.stop()
242
+ this.dismissFrustrationPrompt()
243
+ this.success?.destroy()
244
+ this.tab?.destroy()
245
+ this.pet?.destroy()
246
+ this.exitRecordingMode()
247
+ this.shadow.destroy()
248
+ }
249
+ }
@@ -0,0 +1,86 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { FrustrationPromptCard } from './frustration-prompt'
3
+ import { ShadowContainer } from './shadow'
4
+ import { getMessages } from '../i18n'
5
+
6
+ function setupPrompt() {
7
+ const shadow = new ShadowContainer()
8
+ const onTell = vi.fn()
9
+ const onDismiss = vi.fn()
10
+ const messages = getMessages('en')
11
+ const card = new FrustrationPromptCard(shadow, messages, { onTell, onDismiss })
12
+
13
+ return { shadow, card, onTell, onDismiss, messages }
14
+ }
15
+
16
+ describe('FrustrationPromptCard', () => {
17
+ beforeEach(() => {
18
+ vi.useFakeTimers()
19
+ })
20
+
21
+ afterEach(() => {
22
+ vi.useRealTimers()
23
+ })
24
+
25
+ it('renders prompt text and action buttons', () => {
26
+ const { shadow, card, messages } = setupPrompt()
27
+
28
+ expect(shadow.root.querySelector('.mtb-frustration-text')?.textContent).toBe(messages.frustration.prompt)
29
+ expect(shadow.root.querySelector('.mtb-frustration-tell')?.textContent).toBe(messages.frustration.action)
30
+ expect(shadow.root.querySelector('.mtb-frustration-dismiss')?.textContent).toBe(messages.frustration.dismiss)
31
+
32
+ card.destroy()
33
+ shadow.destroy()
34
+ })
35
+
36
+ it('renders warning icon', () => {
37
+ const { shadow, card } = setupPrompt()
38
+
39
+ const icon = shadow.root.querySelector('.mtb-frustration-icon')
40
+ expect(icon).toBeTruthy()
41
+
42
+ card.destroy()
43
+ shadow.destroy()
44
+ })
45
+
46
+ it('calls onTell when tell button is clicked', () => {
47
+ const { shadow, card, onTell } = setupPrompt()
48
+
49
+ shadow.root.querySelector<HTMLButtonElement>('.mtb-frustration-tell')!.click()
50
+ expect(onTell).toHaveBeenCalledOnce()
51
+
52
+ card.destroy()
53
+ shadow.destroy()
54
+ })
55
+
56
+ it('calls onDismiss when dismiss button is clicked', () => {
57
+ const { shadow, card, onDismiss } = setupPrompt()
58
+
59
+ shadow.root.querySelector<HTMLButtonElement>('.mtb-frustration-dismiss')!.click()
60
+ expect(onDismiss).toHaveBeenCalledOnce()
61
+
62
+ card.destroy()
63
+ shadow.destroy()
64
+ })
65
+
66
+ it('auto-dismisses after 8 seconds', () => {
67
+ const { shadow, card, onDismiss } = setupPrompt()
68
+
69
+ expect(onDismiss).not.toHaveBeenCalled()
70
+ vi.advanceTimersByTime(8000)
71
+ expect(onDismiss).toHaveBeenCalled()
72
+
73
+ card.destroy()
74
+ shadow.destroy()
75
+ })
76
+
77
+ it('destroy removes element from shadow root', () => {
78
+ const { shadow, card } = setupPrompt()
79
+
80
+ expect(shadow.root.querySelector('.mtb-frustration-prompt')).toBeTruthy()
81
+ card.destroy()
82
+ expect(shadow.root.querySelector('.mtb-frustration-prompt')).toBeNull()
83
+
84
+ shadow.destroy()
85
+ })
86
+ })
@@ -0,0 +1,34 @@
1
+ import type { ShadowContainer } from './shadow'
2
+ import type { I18nMessages } from '../i18n'
3
+
4
+ const FRUSTRATION_DISMISS_MS = 8000
5
+
6
+ export class FrustrationPromptCard {
7
+ private el: HTMLDivElement
8
+
9
+ constructor(
10
+ shadow: ShadowContainer,
11
+ messages: I18nMessages,
12
+ handlers: { onTell: () => void; onDismiss: () => void },
13
+ ) {
14
+ this.el = shadow.el<HTMLDivElement>('div', 'mtb-frustration-prompt')
15
+ this.el.innerHTML = `
16
+ <div class="mtb-frustration-icon">&#9888;&#65039;</div>
17
+ <span class="mtb-frustration-text">${messages.frustration.prompt}</span>
18
+ <div class="mtb-frustration-actions">
19
+ <button class="mtb-frustration-tell">${messages.frustration.action}</button>
20
+ <button class="mtb-frustration-dismiss">${messages.frustration.dismiss}</button>
21
+ </div>
22
+ `
23
+
24
+ this.el.querySelector('.mtb-frustration-tell')!.addEventListener('click', handlers.onTell)
25
+ this.el.querySelector('.mtb-frustration-dismiss')!.addEventListener('click', handlers.onDismiss)
26
+
27
+ shadow.append(this.el)
28
+ setTimeout(handlers.onDismiss, FRUSTRATION_DISMISS_MS)
29
+ }
30
+
31
+ destroy(): void {
32
+ this.el.remove()
33
+ }
34
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildPayload } from './payload'
3
+ import type { PageContext } from '../context/collector'
4
+ import type { Annotation, MakeThisBetterConfig } from '../types'
5
+ import type { RecordingResult } from '../record/session'
6
+
7
+ const pageContext: PageContext = {
8
+ page_url: 'https://example.com/dashboard',
9
+ user_agent: 'Mozilla/5.0',
10
+ browser: 'Chrome 120',
11
+ os: 'macOS',
12
+ screen_width: 1920,
13
+ screen_height: 1080,
14
+ }
15
+
16
+ describe('buildPayload', () => {
17
+ it('returns base payload without annotation, user, or recording', () => {
18
+ const result = buildPayload('Button is broken', pageContext, ['TypeError: x'], null, undefined)
19
+
20
+ expect(result.description).toBe('Button is broken')
21
+ expect(result.page_url).toBe('https://example.com/dashboard')
22
+ expect(result.user_agent).toBe('Mozilla/5.0')
23
+ expect(result.browser).toBe('Chrome 120')
24
+ expect(result.os).toBe('macOS')
25
+ expect(result.screen_width).toBe(1920)
26
+ expect(result.screen_height).toBe(1080)
27
+ expect(result.console_errors).toEqual(['TypeError: x'])
28
+ expect(result.annotations).toEqual([])
29
+ expect(result.target_element).toBeUndefined()
30
+ expect(result.user_id).toBeUndefined()
31
+ expect(result.user_email).toBeUndefined()
32
+ expect(result.user_name).toBeUndefined()
33
+ expect(result.recording_events).toBeUndefined()
34
+ expect(result.recording_duration).toBeUndefined()
35
+ })
36
+
37
+ it('includes annotation and target_element when annotation has targetSelector', () => {
38
+ const annotation: Annotation = {
39
+ type: 'pin',
40
+ x: 200,
41
+ y: 300,
42
+ targetSelector: '#export-btn',
43
+ targetText: 'Export PDF',
44
+ targetName: 'Export Button',
45
+ }
46
+
47
+ const result = buildPayload('Export fails', pageContext, [], annotation, undefined)
48
+
49
+ expect(result.annotations).toEqual([annotation])
50
+ expect(result.target_element).toEqual({
51
+ selector: '#export-btn',
52
+ text: 'Export PDF',
53
+ name: 'Export Button',
54
+ })
55
+ })
56
+
57
+ it('omits target_element when annotation has no targetSelector', () => {
58
+ const annotation: Annotation = {
59
+ type: 'draw',
60
+ x: 100,
61
+ y: 150,
62
+ drawPath: 'M0 0 L10 10',
63
+ }
64
+
65
+ const result = buildPayload('UI glitch', pageContext, [], annotation, undefined)
66
+
67
+ expect(result.annotations).toEqual([annotation])
68
+ expect(result.target_element).toBeUndefined()
69
+ })
70
+
71
+ it('defaults targetText and targetName to empty string when missing', () => {
72
+ const annotation: Annotation = {
73
+ type: 'pin',
74
+ x: 50,
75
+ y: 60,
76
+ targetSelector: '.header',
77
+ }
78
+
79
+ const result = buildPayload('Header issue', pageContext, [], annotation, undefined)
80
+
81
+ expect(result.target_element).toEqual({
82
+ selector: '.header',
83
+ text: '',
84
+ name: '',
85
+ })
86
+ })
87
+
88
+ it('includes user fields when user is provided', () => {
89
+ const user: MakeThisBetterConfig['user'] = {
90
+ id: 'usr_123',
91
+ email: 'alice@example.com',
92
+ name: 'Alice',
93
+ }
94
+
95
+ const result = buildPayload('Feedback', pageContext, [], null, user)
96
+
97
+ expect(result.user_id).toBe('usr_123')
98
+ expect(result.user_email).toBe('alice@example.com')
99
+ expect(result.user_name).toBe('Alice')
100
+ })
101
+
102
+ it('includes partial user fields', () => {
103
+ const user: MakeThisBetterConfig['user'] = {
104
+ email: 'bob@example.com',
105
+ }
106
+
107
+ const result = buildPayload('Feedback', pageContext, [], null, user)
108
+
109
+ expect(result.user_id).toBeUndefined()
110
+ expect(result.user_email).toBe('bob@example.com')
111
+ expect(result.user_name).toBeUndefined()
112
+ })
113
+
114
+ it('includes recording data when recording is provided', () => {
115
+ const recording: RecordingResult = {
116
+ events: [{ type: 3, data: { source: 2 } }],
117
+ duration: 12500,
118
+ }
119
+
120
+ const result = buildPayload('Screen issue', pageContext, [], null, undefined, recording)
121
+
122
+ expect(result.recording_events).toEqual([{ type: 3, data: { source: 2 } }])
123
+ expect(result.recording_duration).toBe(12500)
124
+ })
125
+
126
+ it('passes through empty console_errors array', () => {
127
+ const result = buildPayload('All good', pageContext, [], null, undefined)
128
+
129
+ expect(result.console_errors).toEqual([])
130
+ })
131
+
132
+ it('combines all optional fields together', () => {
133
+ const annotation: Annotation = {
134
+ type: 'pin',
135
+ x: 10,
136
+ y: 20,
137
+ targetSelector: '#save',
138
+ targetText: 'Save',
139
+ targetName: 'Save Button',
140
+ }
141
+ const user: MakeThisBetterConfig['user'] = {
142
+ id: 'usr_456',
143
+ email: 'carol@example.com',
144
+ name: 'Carol',
145
+ }
146
+ const recording: RecordingResult = {
147
+ events: [{ type: 2 }],
148
+ duration: 5000,
149
+ }
150
+
151
+ const result = buildPayload(
152
+ 'Save broken',
153
+ pageContext,
154
+ ['Error: 500'],
155
+ annotation,
156
+ user,
157
+ recording,
158
+ )
159
+
160
+ expect(result.description).toBe('Save broken')
161
+ expect(result.annotations).toHaveLength(1)
162
+ expect(result.target_element?.selector).toBe('#save')
163
+ expect(result.user_id).toBe('usr_456')
164
+ expect(result.recording_duration).toBe(5000)
165
+ expect(result.console_errors).toEqual(['Error: 500'])
166
+ })
167
+ })
@@ -0,0 +1,31 @@
1
+ import type { PageContext } from '../context/collector'
2
+ import type { Annotation, FeedbackPayload, MakeThisBetterConfig } from '../types'
3
+ import type { RecordingResult } from '../record/session'
4
+
5
+ export function buildPayload(
6
+ description: string,
7
+ pageContext: PageContext,
8
+ consoleErrors: string[],
9
+ annotation: Annotation | null,
10
+ user: MakeThisBetterConfig['user'],
11
+ recording?: RecordingResult,
12
+ ): FeedbackPayload {
13
+ return {
14
+ description,
15
+ ...pageContext,
16
+ console_errors: consoleErrors,
17
+ annotations: annotation ? [annotation] : [],
18
+ target_element: annotation?.targetSelector
19
+ ? {
20
+ selector: annotation.targetSelector,
21
+ text: annotation.targetText ?? '',
22
+ name: annotation.targetName ?? '',
23
+ }
24
+ : undefined,
25
+ user_id: user?.id,
26
+ user_email: user?.email,
27
+ user_name: user?.name,
28
+ recording_events: recording?.events,
29
+ recording_duration: recording?.duration,
30
+ }
31
+ }