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.
- package/LICENSE +62 -0
- package/README.md +113 -0
- package/dist/annotate/draw.d.ts +21 -0
- package/dist/annotate/draw.d.ts.map +1 -0
- package/dist/annotate/draw.test.d.ts +2 -0
- package/dist/annotate/draw.test.d.ts.map +1 -0
- package/dist/annotate/highlighter.d.ts +11 -0
- package/dist/annotate/highlighter.d.ts.map +1 -0
- package/dist/annotate/highlighter.test.d.ts +2 -0
- package/dist/annotate/highlighter.test.d.ts.map +1 -0
- package/dist/annotate/overlay.d.ts +17 -0
- package/dist/annotate/overlay.d.ts.map +1 -0
- package/dist/annotate/overlay.test.d.ts +2 -0
- package/dist/annotate/overlay.test.d.ts.map +1 -0
- package/dist/annotate/pin.d.ts +10 -0
- package/dist/annotate/pin.d.ts.map +1 -0
- package/dist/annotate/pin.test.d.ts +2 -0
- package/dist/annotate/pin.test.d.ts.map +1 -0
- package/dist/annotate/session.d.ts +20 -0
- package/dist/annotate/session.d.ts.map +1 -0
- package/dist/annotate/session.test.d.ts +2 -0
- package/dist/annotate/session.test.d.ts.map +1 -0
- package/dist/annotate/toolbar.d.ts +17 -0
- package/dist/annotate/toolbar.d.ts.map +1 -0
- package/dist/annotate/toolbar.test.d.ts +2 -0
- package/dist/annotate/toolbar.test.d.ts.map +1 -0
- package/dist/api/client.d.ts +10 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.test.d.ts +2 -0
- package/dist/api/client.test.d.ts.map +1 -0
- package/dist/context/collector.d.ts +10 -0
- package/dist/context/collector.d.ts.map +1 -0
- package/dist/context/collector.test.d.ts +2 -0
- package/dist/context/collector.test.d.ts.map +1 -0
- package/dist/context/console.d.ts +10 -0
- package/dist/context/console.d.ts.map +1 -0
- package/dist/context/console.test.d.ts +2 -0
- package/dist/context/console.test.d.ts.map +1 -0
- package/dist/context/error-source.d.ts +21 -0
- package/dist/context/error-source.d.ts.map +1 -0
- package/dist/context/error-source.test.d.ts +2 -0
- package/dist/context/error-source.test.d.ts.map +1 -0
- package/dist/context/frustration.d.ts +27 -0
- package/dist/context/frustration.d.ts.map +1 -0
- package/dist/context/frustration.test.d.ts +2 -0
- package/dist/context/frustration.test.d.ts.map +1 -0
- package/dist/global.d.ts +3 -0
- package/dist/global.d.ts.map +1 -0
- package/dist/i18n/en.d.ts +47 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/index.d.ts +4 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.test.d.ts +2 -0
- package/dist/i18n/index.test.d.ts.map +1 -0
- package/dist/i18n/zh-CN.d.ts +47 -0
- package/dist/i18n/zh-CN.d.ts.map +1 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/makethisbetter.cjs.js +84 -0
- package/dist/makethisbetter.cjs.js.map +1 -0
- package/dist/makethisbetter.esm.js +1433 -0
- package/dist/makethisbetter.esm.js.map +1 -0
- package/dist/makethisbetter.js +84 -0
- package/dist/makethisbetter.js.map +1 -0
- package/dist/popup/comment.d.ts +31 -0
- package/dist/popup/comment.d.ts.map +1 -0
- package/dist/popup/comment.test.d.ts +2 -0
- package/dist/popup/comment.test.d.ts.map +1 -0
- package/dist/popup/success.d.ts +8 -0
- package/dist/popup/success.d.ts.map +1 -0
- package/dist/popup/success.test.d.ts +2 -0
- package/dist/popup/success.test.d.ts.map +1 -0
- package/dist/record/control-bar.d.ts +12 -0
- package/dist/record/control-bar.d.ts.map +1 -0
- package/dist/record/control-bar.test.d.ts +2 -0
- package/dist/record/control-bar.test.d.ts.map +1 -0
- package/dist/record/session.d.ts +26 -0
- package/dist/record/session.d.ts.map +1 -0
- package/dist/record/session.test.d.ts +2 -0
- package/dist/record/session.test.d.ts.map +1 -0
- package/dist/screenshot/capture.d.ts +3 -0
- package/dist/screenshot/capture.d.ts.map +1 -0
- package/dist/screenshot/capture.test.d.ts +2 -0
- package/dist/screenshot/capture.test.d.ts.map +1 -0
- package/dist/styles.d.ts +9 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/widget/controller.d.ts +37 -0
- package/dist/widget/controller.d.ts.map +1 -0
- package/dist/widget/controller.test.d.ts +2 -0
- package/dist/widget/controller.test.d.ts.map +1 -0
- package/dist/widget/frustration-prompt.d.ts +11 -0
- package/dist/widget/frustration-prompt.d.ts.map +1 -0
- package/dist/widget/frustration-prompt.test.d.ts +2 -0
- package/dist/widget/frustration-prompt.test.d.ts.map +1 -0
- package/dist/widget/payload.d.ts +5 -0
- package/dist/widget/payload.d.ts.map +1 -0
- package/dist/widget/payload.test.d.ts +2 -0
- package/dist/widget/payload.test.d.ts.map +1 -0
- package/dist/widget/pet.d.ts +12 -0
- package/dist/widget/pet.d.ts.map +1 -0
- package/dist/widget/pet.test.d.ts +2 -0
- package/dist/widget/pet.test.d.ts.map +1 -0
- package/dist/widget/shadow.d.ts +11 -0
- package/dist/widget/shadow.d.ts.map +1 -0
- package/dist/widget/shadow.test.d.ts +2 -0
- package/dist/widget/shadow.test.d.ts.map +1 -0
- package/dist/widget/tab.d.ts +10 -0
- package/dist/widget/tab.d.ts.map +1 -0
- package/dist/widget/tab.test.d.ts +2 -0
- package/dist/widget/tab.test.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/annotate/draw.test.ts +168 -0
- package/src/annotate/draw.ts +108 -0
- package/src/annotate/highlighter.test.ts +47 -0
- package/src/annotate/highlighter.ts +84 -0
- package/src/annotate/overlay.test.ts +91 -0
- package/src/annotate/overlay.ts +44 -0
- package/src/annotate/pin.test.ts +87 -0
- package/src/annotate/pin.ts +28 -0
- package/src/annotate/session.test.ts +219 -0
- package/src/annotate/session.ts +104 -0
- package/src/annotate/toolbar.test.ts +132 -0
- package/src/annotate/toolbar.ts +81 -0
- package/src/api/client.test.ts +75 -0
- package/src/api/client.ts +77 -0
- package/src/context/collector.test.ts +14 -0
- package/src/context/collector.ts +41 -0
- package/src/context/console.test.ts +44 -0
- package/src/context/console.ts +34 -0
- package/src/context/error-source.test.ts +115 -0
- package/src/context/error-source.ts +62 -0
- package/src/context/frustration.test.ts +134 -0
- package/src/context/frustration.ts +145 -0
- package/src/global.ts +3 -0
- package/src/i18n/en.ts +47 -0
- package/src/i18n/index.test.ts +24 -0
- package/src/i18n/index.ts +16 -0
- package/src/i18n/zh-CN.ts +47 -0
- package/src/index.ts +32 -0
- package/src/popup/comment.test.ts +73 -0
- package/src/popup/comment.ts +177 -0
- package/src/popup/success.test.ts +75 -0
- package/src/popup/success.ts +33 -0
- package/src/record/control-bar.test.ts +110 -0
- package/src/record/control-bar.ts +46 -0
- package/src/record/session.test.ts +153 -0
- package/src/record/session.ts +139 -0
- package/src/screenshot/capture.test.ts +68 -0
- package/src/screenshot/capture.ts +96 -0
- package/src/styles/widget.css +847 -0
- package/src/styles.ts +9 -0
- package/src/types.ts +46 -0
- package/src/vite-env.d.ts +6 -0
- package/src/widget/controller.test.ts +62 -0
- package/src/widget/controller.ts +249 -0
- package/src/widget/frustration-prompt.test.ts +86 -0
- package/src/widget/frustration-prompt.ts +34 -0
- package/src/widget/payload.test.ts +167 -0
- package/src/widget/payload.ts +31 -0
- package/src/widget/pet.test.ts +63 -0
- package/src/widget/pet.ts +67 -0
- package/src/widget/shadow.test.ts +25 -0
- package/src/widget/shadow.ts +41 -0
- package/src/widget/tab.test.ts +92 -0
- package/src/widget/tab.ts +31 -0
package/src/styles.ts
ADDED
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,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">⚠️</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
|
+
}
|