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
@@ -0,0 +1,132 @@
1
+ import { afterEach, describe, it, expect, vi } from 'vitest'
2
+ import { ShadowContainer } from '../widget/shadow'
3
+ import { AnnotationToolbar } from './toolbar'
4
+ import { getMessages } from '../i18n'
5
+
6
+ describe('AnnotationToolbar', () => {
7
+ let shadow: ShadowContainer
8
+ let toolbar: AnnotationToolbar
9
+ const messages = getMessages('en')
10
+
11
+ function setup(opts?: { onModeChange?: (mode: 'markup' | 'record') => void }) {
12
+ shadow = new ShadowContainer()
13
+ toolbar = new AnnotationToolbar(shadow, messages, vi.fn(), opts?.onModeChange)
14
+ }
15
+
16
+ afterEach(() => {
17
+ document.body.innerHTML = ''
18
+ })
19
+
20
+ describe('constructor', () => {
21
+ it('creates a toolbar element in the shadow root', () => {
22
+ setup()
23
+ expect(shadow.root.querySelector('.mtb-toolbar')).toBeInstanceOf(HTMLDivElement)
24
+ toolbar.destroy()
25
+ })
26
+
27
+ it('renders markup and record buttons', () => {
28
+ setup()
29
+ expect(shadow.root.querySelector('.mtb-toolbar-mode-markup')).toBeInstanceOf(HTMLButtonElement)
30
+ expect(shadow.root.querySelector('.mtb-toolbar-mode-record')).toBeInstanceOf(HTMLButtonElement)
31
+ toolbar.destroy()
32
+ })
33
+
34
+ it('renders the hint text', () => {
35
+ setup()
36
+ const hint = shadow.root.querySelector('.mtb-toolbar-hint')
37
+ expect(hint!.textContent).toBe(messages.toolbar.hint)
38
+ toolbar.destroy()
39
+ })
40
+
41
+ it('renders an exit button', () => {
42
+ setup()
43
+ expect(shadow.root.querySelector('.mtb-exit-btn')).toBeInstanceOf(HTMLButtonElement)
44
+ toolbar.destroy()
45
+ })
46
+
47
+ it('starts in markup mode with markup button active', () => {
48
+ setup()
49
+ expect(toolbar.getMode()).toBe('markup')
50
+ const markupBtn = shadow.root.querySelector('.mtb-toolbar-mode-markup')!
51
+ expect(markupBtn.classList.contains('active')).toBe(true)
52
+ toolbar.destroy()
53
+ })
54
+ })
55
+
56
+ describe('setMode', () => {
57
+ it('switches to record mode and updates active class', () => {
58
+ setup()
59
+ toolbar.setMode('record')
60
+ expect(toolbar.getMode()).toBe('record')
61
+
62
+ const markupBtn = shadow.root.querySelector('.mtb-toolbar-mode-markup')!
63
+ const recordBtn = shadow.root.querySelector('.mtb-toolbar-mode-record')!
64
+ expect(markupBtn.classList.contains('active')).toBe(false)
65
+ expect(recordBtn.classList.contains('active')).toBe(true)
66
+
67
+ const hint = shadow.root.querySelector('.mtb-toolbar-hint')!
68
+ expect(hint.textContent).toBe(messages.toolbar.hintRecord)
69
+ toolbar.destroy()
70
+ })
71
+
72
+ it('switches back to markup mode', () => {
73
+ setup()
74
+ toolbar.setMode('record')
75
+ toolbar.setMode('markup')
76
+ expect(toolbar.getMode()).toBe('markup')
77
+
78
+ const hint = shadow.root.querySelector('.mtb-toolbar-hint')!
79
+ expect(hint.textContent).toBe(messages.toolbar.hint)
80
+ toolbar.destroy()
81
+ })
82
+ })
83
+
84
+ describe('button clicks', () => {
85
+ it('calls onModeChange when markup button is clicked', () => {
86
+ const onModeChange = vi.fn()
87
+ setup({ onModeChange })
88
+ const markupBtn = shadow.root.querySelector('.mtb-toolbar-mode-markup')!
89
+ markupBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
90
+ expect(onModeChange).toHaveBeenCalledWith('markup')
91
+ toolbar.destroy()
92
+ })
93
+
94
+ it('calls onModeChange when record button is clicked', () => {
95
+ const onModeChange = vi.fn()
96
+ setup({ onModeChange })
97
+ const recordBtn = shadow.root.querySelector('.mtb-toolbar-mode-record')!
98
+ recordBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
99
+ expect(onModeChange).toHaveBeenCalledWith('record')
100
+ toolbar.destroy()
101
+ })
102
+
103
+ it('calls onExit when exit button is clicked', () => {
104
+ shadow = new ShadowContainer()
105
+ const onExit = vi.fn()
106
+ toolbar = new AnnotationToolbar(shadow, messages, onExit)
107
+
108
+ const exitBtn = shadow.root.querySelector('.mtb-exit-btn')!
109
+ exitBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
110
+ expect(onExit).toHaveBeenCalledOnce()
111
+ toolbar.destroy()
112
+ })
113
+
114
+ it('works without onModeChange callback', () => {
115
+ setup()
116
+ const recordBtn = shadow.root.querySelector('.mtb-toolbar-mode-record')!
117
+ expect(() => {
118
+ recordBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
119
+ }).not.toThrow()
120
+ expect(toolbar.getMode()).toBe('record')
121
+ toolbar.destroy()
122
+ })
123
+ })
124
+
125
+ describe('destroy', () => {
126
+ it('removes the toolbar element from the shadow root', () => {
127
+ setup()
128
+ toolbar.destroy()
129
+ expect(shadow.root.querySelector('.mtb-toolbar')).toBeNull()
130
+ })
131
+ })
132
+ })
@@ -0,0 +1,81 @@
1
+ import type { ShadowContainer } from '../widget/shadow'
2
+ import type { I18nMessages } from '../i18n'
3
+
4
+ export type ToolbarMode = 'markup' | 'record'
5
+
6
+ export class AnnotationToolbar {
7
+ private el: HTMLDivElement
8
+ private hintEl!: HTMLSpanElement
9
+ private markupBtn!: HTMLButtonElement
10
+ private recordBtn!: HTMLButtonElement
11
+ private messages: I18nMessages
12
+ private currentMode: ToolbarMode = 'markup'
13
+
14
+ constructor(
15
+ shadow: ShadowContainer,
16
+ messages: I18nMessages,
17
+ onExit: () => void,
18
+ onModeChange?: (mode: ToolbarMode) => void,
19
+ ) {
20
+ this.messages = messages
21
+
22
+ this.el = shadow.el<HTMLDivElement>('div', 'mtb-toolbar')
23
+ this.el.innerHTML = this.buildHTML(messages)
24
+ shadow.append(this.el)
25
+
26
+ this.hintEl = this.el.querySelector('.mtb-toolbar-hint')!
27
+ this.markupBtn = this.el.querySelector('.mtb-toolbar-mode-markup')!
28
+ this.recordBtn = this.el.querySelector('.mtb-toolbar-mode-record')!
29
+
30
+ this.markupBtn.addEventListener('click', () => {
31
+ this.setMode('markup')
32
+ onModeChange?.('markup')
33
+ })
34
+ this.recordBtn.addEventListener('click', () => {
35
+ this.setMode('record')
36
+ onModeChange?.('record')
37
+ })
38
+ this.el.querySelector('.mtb-exit-btn')!.addEventListener('click', onExit)
39
+ }
40
+
41
+ private buildHTML(m: I18nMessages): string {
42
+ return `
43
+ <div class="mtb-toolbar-modes">
44
+ <button class="mtb-toolbar-mode-btn mtb-toolbar-mode-markup active">
45
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="3" x2="12" y2="7"/><line x1="12" y1="17" x2="12" y2="21"/><line x1="3" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="21" y2="12"/></svg>${m.toolbar.markup}
46
+ </button>
47
+ <button class="mtb-toolbar-mode-btn mtb-toolbar-mode-record">
48
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/></svg>${m.toolbar.record}
49
+ </button>
50
+ </div>
51
+ <span class="mtb-toolbar-hint">${m.toolbar.hint}</span>
52
+ <span class="mtb-toolbar-sep"></span>
53
+ <button class="mtb-exit-btn">
54
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
55
+ <path d="M18 6L6 18M6 6l12 12"/>
56
+ </svg>${m.toolbar.exit}
57
+ </button>
58
+ `
59
+ }
60
+
61
+ setMode(mode: ToolbarMode): void {
62
+ this.currentMode = mode
63
+ if (mode === 'markup') {
64
+ this.markupBtn.classList.add('active')
65
+ this.recordBtn.classList.remove('active')
66
+ this.hintEl.textContent = this.messages.toolbar.hint
67
+ } else {
68
+ this.markupBtn.classList.remove('active')
69
+ this.recordBtn.classList.add('active')
70
+ this.hintEl.textContent = this.messages.toolbar.hintRecord
71
+ }
72
+ }
73
+
74
+ getMode(): ToolbarMode {
75
+ return this.currentMode
76
+ }
77
+
78
+ destroy(): void {
79
+ this.el.remove()
80
+ }
81
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { ApiClient } from './client'
3
+ import type { FeedbackPayload } from '../types'
4
+
5
+ const mockPayload: FeedbackPayload = {
6
+ description: 'Test feedback',
7
+ page_url: 'https://example.com',
8
+ user_agent: 'Mozilla/5.0',
9
+ browser: 'Chrome 130',
10
+ os: 'macOS',
11
+ screen_width: 1440,
12
+ screen_height: 900,
13
+ console_errors: [],
14
+ annotations: [],
15
+ }
16
+
17
+ describe('ApiClient', () => {
18
+ afterEach(() => {
19
+ vi.restoreAllMocks()
20
+ })
21
+
22
+ it('sends POST to correct URL with project key header', async () => {
23
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
24
+ new Response(null, { status: 201 }),
25
+ )
26
+
27
+ const client = new ApiClient('proj_test123', 'https://api.example.com')
28
+ await client.submitFeedback(mockPayload, null)
29
+
30
+ expect(fetchSpy).toHaveBeenCalledWith(
31
+ 'https://api.example.com/widget/feedbacks',
32
+ expect.objectContaining({
33
+ method: 'POST',
34
+ headers: { 'X-Project-Key': 'proj_test123' },
35
+ }),
36
+ )
37
+ })
38
+
39
+ it('includes description in FormData', async () => {
40
+ let capturedBody: FormData | null = null
41
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
42
+ capturedBody = init?.body instanceof FormData ? init.body : null
43
+ return new Response(null, { status: 201 })
44
+ })
45
+
46
+ const client = new ApiClient('key', 'https://api.example.com')
47
+ await client.submitFeedback(mockPayload, null)
48
+
49
+ expect(capturedBody).not.toBeNull()
50
+ const formData = capturedBody as unknown as FormData
51
+ expect(formData.get('feedback[description]')).toBe('Test feedback')
52
+ })
53
+
54
+ it('uploads screenshot when present', async () => {
55
+ let capturedBody: FormData | null = null
56
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
57
+ capturedBody = init?.body instanceof FormData ? init.body : null
58
+ return new Response(null, { status: 201 })
59
+ })
60
+
61
+ const screenshot = new Blob(['png'], { type: 'image/png' })
62
+ const client = new ApiClient('key', 'https://api.example.com')
63
+ await client.submitFeedback(mockPayload, screenshot)
64
+
65
+ const formData = capturedBody as unknown as FormData
66
+ expect(formData.get('feedback[screenshot]')).toBeInstanceOf(File)
67
+ })
68
+
69
+ it('throws after retries exhausted', async () => {
70
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'))
71
+
72
+ const client = new ApiClient('key', 'https://api.example.com', 0)
73
+ await expect(client.submitFeedback(mockPayload, null)).rejects.toThrow()
74
+ })
75
+ })
@@ -0,0 +1,77 @@
1
+ import type { FeedbackPayload } from '../types'
2
+
3
+ const DEFAULT_API_URL = 'https://makethisbetter.dev/api/v1'
4
+
5
+ export class ApiClient {
6
+ private apiUrl: string
7
+ private projectKey: string
8
+ private retryDelayMs: number
9
+
10
+ constructor(projectKey: string, apiUrl?: string, retryDelayMs = 1000) {
11
+ this.projectKey = projectKey
12
+ this.apiUrl = (apiUrl ?? DEFAULT_API_URL).replace(/\/$/, '')
13
+ this.retryDelayMs = retryDelayMs
14
+ }
15
+
16
+ async submitFeedback(payload: FeedbackPayload, screenshot: Blob | null): Promise<void> {
17
+ const form = new FormData()
18
+ form.append('feedback[description]', payload.description)
19
+ form.append('feedback[page_url]', payload.page_url)
20
+ form.append('feedback[user_agent]', payload.user_agent)
21
+ form.append('feedback[browser]', payload.browser)
22
+ form.append('feedback[os]', payload.os)
23
+ form.append('feedback[screen_width]', String(payload.screen_width))
24
+ form.append('feedback[screen_height]', String(payload.screen_height))
25
+ form.append('feedback[console_errors]', JSON.stringify(payload.console_errors))
26
+ form.append('feedback[annotations]', JSON.stringify(payload.annotations))
27
+
28
+ if (payload.target_element) {
29
+ form.append('feedback[target_element]', JSON.stringify(payload.target_element))
30
+ }
31
+ if (payload.user_id) form.append('feedback[user_id]', payload.user_id)
32
+ if (payload.user_email) form.append('feedback[user_email]', payload.user_email)
33
+ if (payload.user_name) form.append('feedback[user_name]', payload.user_name)
34
+
35
+ if (payload.recording_events && payload.recording_events.length > 0) {
36
+ const json = JSON.stringify(payload.recording_events)
37
+ const blob = new Blob([json], { type: 'application/json' })
38
+ form.append('feedback[recording]', blob, 'recording.json')
39
+ form.append('feedback[recording_duration]', String(payload.recording_duration ?? 0))
40
+ }
41
+
42
+ if (screenshot) {
43
+ form.append('feedback[screenshot]', screenshot, 'screenshot.png')
44
+ }
45
+
46
+ await this.fetchWithRetry(`${this.apiUrl}/widget/feedbacks`, {
47
+ method: 'POST',
48
+ headers: { 'X-Project-Key': this.projectKey },
49
+ body: form,
50
+ })
51
+ }
52
+
53
+ private async fetchWithRetry(url: string, init: RequestInit, retries = 1): Promise<Response> {
54
+ let res: Response
55
+ try {
56
+ res = await fetch(url, init)
57
+ } catch (err) {
58
+ if (retries > 0) {
59
+ await delay(this.retryDelayMs)
60
+ return this.fetchWithRetry(url, init, retries - 1)
61
+ }
62
+ throw err
63
+ }
64
+
65
+ if (res.ok) return res
66
+ if (res.status >= 500 && retries > 0) {
67
+ await delay(this.retryDelayMs)
68
+ return this.fetchWithRetry(url, init, retries - 1)
69
+ }
70
+ throw new Error(`HTTP ${res.status}`)
71
+ }
72
+ }
73
+
74
+ function delay(ms: number): Promise<void> {
75
+ if (ms <= 0) return Promise.resolve()
76
+ return new Promise(resolve => setTimeout(resolve, ms))
77
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { collectPageContext } from './collector'
3
+
4
+ describe('collectPageContext', () => {
5
+ it('returns required fields', () => {
6
+ const ctx = collectPageContext()
7
+ expect(typeof ctx.page_url).toBe('string')
8
+ expect(typeof ctx.user_agent).toBe('string')
9
+ expect(typeof ctx.browser).toBe('string')
10
+ expect(typeof ctx.os).toBe('string')
11
+ expect(typeof ctx.screen_width).toBe('number')
12
+ expect(typeof ctx.screen_height).toBe('number')
13
+ })
14
+ })
@@ -0,0 +1,41 @@
1
+ export interface PageContext {
2
+ page_url: string
3
+ user_agent: string
4
+ browser: string
5
+ os: string
6
+ screen_width: number
7
+ screen_height: number
8
+ }
9
+
10
+ export function collectPageContext(): PageContext {
11
+ return {
12
+ page_url: window.location.href,
13
+ user_agent: navigator.userAgent,
14
+ browser: detectBrowser(),
15
+ os: detectOS(),
16
+ screen_width: window.screen.width,
17
+ screen_height: window.screen.height,
18
+ }
19
+ }
20
+
21
+ function detectBrowser(): string {
22
+ const ua = navigator.userAgent
23
+ if (/Edg\//.test(ua)) return `Edge ${ua.match(/Edg\/(\S+)/)?.[1] ?? ''}`
24
+ if (/OPR\//.test(ua)) return `Opera ${ua.match(/OPR\/(\S+)/)?.[1] ?? ''}`
25
+ if (/Chrome\//.test(ua)) return `Chrome ${ua.match(/Chrome\/(\S+)/)?.[1] ?? ''}`
26
+ if (/Safari\//.test(ua) && /Version\//.test(ua)) return `Safari ${ua.match(/Version\/(\S+)/)?.[1] ?? ''}`
27
+ if (/Firefox\//.test(ua)) return `Firefox ${ua.match(/Firefox\/(\S+)/)?.[1] ?? ''}`
28
+ return 'Unknown'
29
+ }
30
+
31
+ function detectOS(): string {
32
+ const ua = navigator.userAgent
33
+ if (/Windows NT 10/.test(ua)) return 'Windows 10/11'
34
+ if (/Windows NT/.test(ua)) return 'Windows'
35
+ if (/Mac OS X 10_15|macOS 1[56789]/.test(ua)) return 'macOS'
36
+ if (/Mac OS X/.test(ua)) return 'macOS'
37
+ if (/Android/.test(ua)) return `Android ${ua.match(/Android (\d+)/)?.[1] ?? ''}`
38
+ if (/iPhone|iPad|iPod/.test(ua)) return 'iOS'
39
+ if (/Linux/.test(ua)) return 'Linux'
40
+ return 'Unknown'
41
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { ConsoleErrorCollector } from './console'
3
+
4
+ describe('ConsoleErrorCollector', () => {
5
+ let collector: ConsoleErrorCollector
6
+
7
+ afterEach(() => {
8
+ collector?.stop()
9
+ })
10
+
11
+ it('starts empty', () => {
12
+ collector = new ConsoleErrorCollector()
13
+ collector.start()
14
+ expect(collector.getErrors()).toHaveLength(0)
15
+ })
16
+
17
+ it('captures window.onerror events', () => {
18
+ collector = new ConsoleErrorCollector()
19
+ collector.start()
20
+
21
+ window.onerror?.('TypeError: test error', 'export.js', 42, 1, new Error('test error'))
22
+
23
+ expect(collector.getErrors()).toHaveLength(1)
24
+ expect(collector.getErrors()[0]).toContain('TypeError: test error')
25
+ })
26
+
27
+ it('deduplicates identical errors', () => {
28
+ collector = new ConsoleErrorCollector()
29
+ collector.start()
30
+
31
+ window.onerror?.('Same error', 'file.js', 1, 1, undefined)
32
+ window.onerror?.('Same error', 'file.js', 1, 1, undefined)
33
+
34
+ expect(collector.getErrors()).toHaveLength(1)
35
+ })
36
+
37
+ it('getErrors returns a copy', () => {
38
+ collector = new ConsoleErrorCollector()
39
+ collector.start()
40
+ const a = collector.getErrors()
41
+ const b = collector.getErrors()
42
+ expect(a).not.toBe(b)
43
+ })
44
+ })
@@ -0,0 +1,34 @@
1
+ import { errorSource, type ErrorSourceEvent } from './error-source'
2
+
3
+ const MAX_CONSOLE_ERRORS = 20
4
+
5
+ export class ConsoleErrorCollector {
6
+ private errors: string[] = []
7
+ private unsubscribe: (() => void) | null = null
8
+
9
+ start(): void {
10
+ if (this.unsubscribe) return
11
+ this.unsubscribe = errorSource.subscribe((e) => this.record(e))
12
+ }
13
+
14
+ private record(e: ErrorSourceEvent): void {
15
+ const location = e.line != null ? ` (${e.line}:${e.col ?? 0})` : ''
16
+ this.addError(`${e.message}${location}`)
17
+ }
18
+
19
+ private addError(msg: string): void {
20
+ if (this.errors.length >= MAX_CONSOLE_ERRORS) return
21
+ if (!this.errors.includes(msg)) {
22
+ this.errors.push(msg)
23
+ }
24
+ }
25
+
26
+ getErrors(): string[] {
27
+ return [...this.errors]
28
+ }
29
+
30
+ stop(): void {
31
+ this.unsubscribe?.()
32
+ this.unsubscribe = null
33
+ }
34
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { errorSource, type ErrorSourceEvent } from './error-source'
3
+
4
+ describe('ErrorSource', () => {
5
+ let unsubs: (() => void)[]
6
+
7
+ beforeEach(() => {
8
+ unsubs = []
9
+ })
10
+
11
+ afterEach(() => {
12
+ unsubs.forEach((fn) => fn())
13
+ })
14
+
15
+ it('subscribe returns an unsubscribe function', () => {
16
+ const unsub = errorSource.subscribe(() => {})
17
+ unsubs.push(unsub)
18
+ expect(typeof unsub).toBe('function')
19
+ })
20
+
21
+ it('delivers window.onerror events to a subscriber', () => {
22
+ const received: ErrorSourceEvent[] = []
23
+ const unsub = errorSource.subscribe((e) => received.push(e))
24
+ unsubs.push(unsub)
25
+
26
+ window.onerror?.('Test error', 'test.js', 10, 5, new Error('boom'))
27
+
28
+ expect(received).toHaveLength(1)
29
+ expect(received[0].message).toBe('Test error')
30
+ expect(received[0].source).toBe('test.js')
31
+ expect(received[0].line).toBe(10)
32
+ expect(received[0].col).toBe(5)
33
+ })
34
+
35
+ it('delivers events to multiple subscribers', () => {
36
+ const a: ErrorSourceEvent[] = []
37
+ const b: ErrorSourceEvent[] = []
38
+ const unsubA = errorSource.subscribe((e) => a.push(e))
39
+ const unsubB = errorSource.subscribe((e) => b.push(e))
40
+ unsubs.push(unsubA, unsubB)
41
+
42
+ window.onerror?.('multi', 'file.js', 1, 1, undefined)
43
+
44
+ expect(a).toHaveLength(1)
45
+ expect(b).toHaveLength(1)
46
+ expect(a[0].message).toBe('multi')
47
+ expect(b[0].message).toBe('multi')
48
+ })
49
+
50
+ it('unsubscribing stops delivery to that listener', () => {
51
+ const received: ErrorSourceEvent[] = []
52
+ const unsub = errorSource.subscribe((e) => received.push(e))
53
+
54
+ window.onerror?.('before unsub', 'file.js', 1, 1, undefined)
55
+ expect(received).toHaveLength(1)
56
+
57
+ unsub()
58
+
59
+ window.onerror?.('after unsub', 'file.js', 2, 1, undefined)
60
+ expect(received).toHaveLength(1)
61
+ })
62
+
63
+ it('auto-unhooks window.onerror when last subscriber leaves', () => {
64
+ const onErrorBefore = window.onerror
65
+
66
+ const unsub1 = errorSource.subscribe(() => {})
67
+ const unsub2 = errorSource.subscribe(() => {})
68
+
69
+ expect(window.onerror).not.toBe(onErrorBefore)
70
+
71
+ unsub1()
72
+ expect(window.onerror).not.toBe(onErrorBefore)
73
+
74
+ unsub2()
75
+ expect(window.onerror).toBe(onErrorBefore)
76
+ })
77
+
78
+ it('captures unhandled promise rejections', () => {
79
+ const received: ErrorSourceEvent[] = []
80
+ const unsub = errorSource.subscribe((e) => received.push(e))
81
+ unsubs.push(unsub)
82
+
83
+ const event = new Event('unhandledrejection') as PromiseRejectionEvent
84
+ Object.defineProperty(event, 'reason', { value: new Error('rejected') })
85
+ window.dispatchEvent(event)
86
+
87
+ expect(received).toHaveLength(1)
88
+ expect(received[0].message).toBe('Unhandled rejection: rejected')
89
+ })
90
+
91
+ it('formats non-Error rejection reasons as strings', () => {
92
+ const received: ErrorSourceEvent[] = []
93
+ const unsub = errorSource.subscribe((e) => received.push(e))
94
+ unsubs.push(unsub)
95
+
96
+ const event = new Event('unhandledrejection') as PromiseRejectionEvent
97
+ Object.defineProperty(event, 'reason', { value: 'string reason' })
98
+ window.dispatchEvent(event)
99
+
100
+ expect(received).toHaveLength(1)
101
+ expect(received[0].message).toBe('Unhandled rejection: string reason')
102
+ })
103
+
104
+ it('chains to original window.onerror handler', () => {
105
+ const originalCalled = vi.fn()
106
+ window.onerror = originalCalled
107
+
108
+ const unsub = errorSource.subscribe(() => {})
109
+ unsubs.push(unsub)
110
+
111
+ window.onerror?.('chained', 'file.js', 1, 1, undefined)
112
+
113
+ expect(originalCalled).toHaveBeenCalledOnce()
114
+ })
115
+ })
@@ -0,0 +1,62 @@
1
+ export interface ErrorSourceEvent {
2
+ message: string
3
+ source?: string
4
+ line?: number
5
+ col?: number
6
+ error?: unknown
7
+ }
8
+
9
+ type ErrorListener = (event: ErrorSourceEvent) => void
10
+
11
+ class ErrorSource {
12
+ private listeners = new Set<ErrorListener>()
13
+ private originalOnError: OnErrorEventHandler | null = null
14
+ private rejectionHandler: ((e: PromiseRejectionEvent) => void) | null = null
15
+ private hooked = false
16
+
17
+ subscribe(listener: ErrorListener): () => void {
18
+ this.listeners.add(listener)
19
+ this.hook()
20
+ return () => {
21
+ this.listeners.delete(listener)
22
+ if (this.listeners.size === 0) this.unhook()
23
+ }
24
+ }
25
+
26
+ private hook(): void {
27
+ if (this.hooked) return
28
+ this.hooked = true
29
+
30
+ this.originalOnError = window.onerror
31
+ window.onerror = (msg, source, line, col, error) => {
32
+ this.emit({ message: String(msg), source, line, col, error })
33
+ if (this.originalOnError) {
34
+ return this.originalOnError.call(window, msg, source, line, col, error)
35
+ }
36
+ return false
37
+ }
38
+
39
+ this.rejectionHandler = (e: PromiseRejectionEvent) => {
40
+ const reason = e.reason instanceof Error ? e.reason.message : String(e.reason)
41
+ this.emit({ message: `Unhandled rejection: ${reason}` })
42
+ }
43
+ window.addEventListener('unhandledrejection', this.rejectionHandler)
44
+ }
45
+
46
+ private unhook(): void {
47
+ if (!this.hooked) return
48
+ this.hooked = false
49
+ window.onerror = this.originalOnError
50
+ this.originalOnError = null
51
+ if (this.rejectionHandler) {
52
+ window.removeEventListener('unhandledrejection', this.rejectionHandler)
53
+ this.rejectionHandler = null
54
+ }
55
+ }
56
+
57
+ private emit(event: ErrorSourceEvent): void {
58
+ for (const listener of this.listeners) listener(event)
59
+ }
60
+ }
61
+
62
+ export const errorSource = new ErrorSource()