@@ -4,7 +4,7 @@ import { submitUserFeedback } from '@jetstream/shared/data';
44import { InputReadFileContent } from '@jetstream/types' ;
55import { fromAppState } from '@jetstream/ui/app-state' ;
66import { useAtomValue } from 'jotai' ;
7- import { useState } from 'react' ;
7+ import { useEffect , useRef , useState } from 'react' ;
88import Checkbox from '../form/checkbox/Checkbox' ;
99import FileSelector from '../form/file-selector/FileSelector' ;
1010import { RadioButton } from '../form/radio/RadioButton' ;
@@ -16,8 +16,13 @@ import ScopedNotification from '../scoped-notification/ScopedNotification';
1616import { fireToast } from '../toast/AppToast' ;
1717import Icon from '../widgets/Icon' ;
1818import Spinner from '../widgets/Spinner' ;
19+ import Tooltip from '../widgets/Tooltip' ;
1920
2021export type FeedbackType = 'bug' | 'feature' | 'other' | 'testimonial' ;
22+ export type FeedbackPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' ;
23+
24+ const STORAGE_KEY = 'jetstream-feedback-widget-position' ;
25+ const HIDDEN_STORAGE_KEY = 'jetstream-feedback-widget-hidden' ;
2126
2227const allowFromClipboardAcceptType = / ^ i m a g e \/ ( p n g | j p g | j p e g | g i f ) $ / ;
2328
@@ -34,6 +39,19 @@ const getFeedbackHelpText = (type: FeedbackType): string => {
3439 }
3540} ;
3641
42+ const getPositionStyles = ( position : FeedbackPosition ) => {
43+ switch ( position ) {
44+ case 'bottom-right' :
45+ return { bottom : '1.5rem' , right : '1.5rem' } ;
46+ case 'bottom-left' :
47+ return { bottom : '1.5rem' , left : '1.5rem' } ;
48+ case 'top-right' :
49+ return { top : '1.5rem' , right : '1.5rem' } ;
50+ case 'top-left' :
51+ return { top : '1.5rem' , left : '1.5rem' } ;
52+ }
53+ } ;
54+
3755export const UserFeedbackWidget = ( ) => {
3856 const [ isOpen , setIsOpen ] = useState ( false ) ;
3957 const [ feedbackType , setFeedbackType ] = useState < FeedbackType > ( 'bug' ) ;
@@ -42,8 +60,48 @@ export const UserFeedbackWidget = () => {
4260 const [ canFeatureTestimonial , setCanFeatureTestimonial ] = useState ( false ) ;
4361 const [ isSubmitting , setIsSubmitting ] = useState ( false ) ;
4462 const [ submitError , setSubmitError ] = useState < string | null > ( null ) ;
63+ const [ position , setPosition ] = useState < FeedbackPosition > ( 'bottom-right' ) ;
64+ const [ isHidden , setIsHidden ] = useState ( false ) ;
65+ const [ showContextMenu , setShowContextMenu ] = useState ( false ) ;
66+ const buttonRef = useRef < HTMLButtonElement > ( null ) ;
67+ const contextMenuRef = useRef < HTMLDivElement > ( null ) ;
4568 const { version : clientVersion } = useAtomValue ( fromAppState . appInfoState ) ;
4669
70+ // Load position from localStorage on mount
71+ useEffect ( ( ) => {
72+ const savedPosition = localStorage . getItem ( STORAGE_KEY ) ;
73+ if ( savedPosition && [ 'bottom-right' , 'bottom-left' , 'top-right' , 'top-left' ] . includes ( savedPosition ) ) {
74+ setPosition ( savedPosition as FeedbackPosition ) ;
75+ }
76+ } , [ ] ) ;
77+
78+ // Load hidden state from sessionStorage on mount
79+ useEffect ( ( ) => {
80+ const isHiddenFromStorage = sessionStorage . getItem ( HIDDEN_STORAGE_KEY ) === 'true' ;
81+ if ( isHiddenFromStorage ) {
82+ setIsHidden ( true ) ;
83+ }
84+ } , [ ] ) ;
85+
86+ // Close context menu when clicking outside
87+ useEffect ( ( ) => {
88+ const handleClickOutside = ( event : MouseEvent ) => {
89+ if (
90+ contextMenuRef . current &&
91+ ! contextMenuRef . current . contains ( event . target as Node ) &&
92+ buttonRef . current &&
93+ ! buttonRef . current . contains ( event . target as Node )
94+ ) {
95+ setShowContextMenu ( false ) ;
96+ }
97+ } ;
98+
99+ if ( showContextMenu ) {
100+ document . addEventListener ( 'mousedown' , handleClickOutside ) ;
101+ return ( ) => document . removeEventListener ( 'mousedown' , handleClickOutside ) ;
102+ }
103+ } , [ showContextMenu ] ) ;
104+
47105 const handleOpen = ( ) => {
48106 setIsOpen ( true ) ;
49107 setSubmitError ( null ) ;
@@ -108,19 +166,50 @@ export const UserFeedbackWidget = () => {
108166 setScreenshots ( ( prev ) => prev . filter ( ( _ , i ) => i !== index ) ) ;
109167 } ;
110168
169+ const handlePositionChange = ( newPosition : FeedbackPosition ) => {
170+ setPosition ( newPosition ) ;
171+ localStorage . setItem ( STORAGE_KEY , newPosition ) ;
172+ setShowContextMenu ( false ) ;
173+ } ;
174+
175+ const handleHideForSession = ( ) => {
176+ setIsHidden ( true ) ;
177+ sessionStorage . setItem ( HIDDEN_STORAGE_KEY , 'true' ) ;
178+ setShowContextMenu ( false ) ;
179+ fireToast ( {
180+ message : 'Feedback button hidden for this session and will be visible again when you open Jetstream in a new tab.' ,
181+ type : 'info' ,
182+ } ) ;
183+ } ;
184+
185+ const handleContextMenu = ( event : React . MouseEvent ) => {
186+ event . preventDefault ( ) ;
187+ setShowContextMenu ( true ) ;
188+ } ;
189+
111190 const canSubmit = message . trim ( ) . length > 0 ;
191+ const positionStyles = getPositionStyles ( position ) ;
192+
193+ if ( isHidden ) {
194+ return null ;
195+ }
112196
113197 return (
114198 < >
115199 { /* Floating feedback button */ }
200+
116201 < button
202+ ref = { buttonRef }
203+ aria-label = "Send Feedback"
117204 className = "slds-button slds-button_icon slds-button_icon-container slds-button_icon-border-filled"
118- title = "Send Feedback"
119205 onClick = { handleOpen }
206+ onContextMenu = { handleContextMenu }
120207 css = { css `
121208 position : fixed;
122- bottom : 1.5rem ;
123- right : 1.5rem ;
209+ ${ positionStyles . top ? `top: ${ positionStyles . top } ` : '' } ;
210+ ${ positionStyles . bottom ? `bottom: ${ positionStyles . bottom } ` : '' } ;
211+ ${ positionStyles . left ? `left: ${ positionStyles . left } ` : '' } ;
212+ ${ positionStyles . right ? `right: ${ positionStyles . right } ` : '' } ;
124213 z- index: 9000;
125214 width: 3.5rem;
126215 height: 3.5rem;
@@ -129,7 +218,7 @@ export const UserFeedbackWidget = () => {
129218 background- color : # 0176d3;
130219 bor der- color : # 0176d3;
131220 transition: all 0.2s ease;
132- opacity : 0.75 ;
221+ opacity: 0.6 ;
133222
134223 & : hover {
135224 background-color : # 014486 ;
@@ -142,10 +231,121 @@ export const UserFeedbackWidget = () => {
142231 }
143232 ` }
144233 >
145- < Icon type = "standard" icon = "feedback" className = "" omitContainer />
146- < span className = "slds-assistive-text" > Send Feedback</ span >
234+ < Tooltip
235+ content = { isOpen || showContextMenu ? undefined : 'Send us your feedback. Right click for positioning options.' }
236+ openDelay = { 300 }
237+ >
238+ < Icon type = "standard" icon = "feedback" className = "" omitContainer />
239+ < span className = "slds-assistive-text" > Send Feedback</ span >
240+ </ Tooltip >
147241 </ button >
148242
243+ { /* Context menu for positioning */ }
244+ { showContextMenu && (
245+ < div
246+ ref = { contextMenuRef }
247+ css = { css `
248+ position : fixed;
249+ ${ positionStyles . top ? `top: calc(${ positionStyles . top } + 4rem)` : '' } ;
250+ ${ positionStyles . bottom ? `bottom: calc(${ positionStyles . bottom } + 4rem)` : '' } ;
251+ ${ positionStyles . left ? `left: ${ positionStyles . left } ` : '' } ;
252+ ${ positionStyles . right ? `right: ${ positionStyles . right } ` : '' } ;
253+ z- index: 5001;
254+ background: white;
255+ bor der- radius: 0.25rem;
256+ box- shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
257+ min- width: 200px;
258+ padding: 0.5rem 0;
259+ ` }
260+ >
261+ < div
262+ css = { css `
263+ padding: 0.5rem 1rem;
264+ font- size: 0.75rem;
265+ font- weight: 600;
266+ color : # 3e3e3c;
267+ text- transfor m: uppercase;
268+ letter- spacing: 0.025em;
269+ ` }
270+ >
271+ Button Position
272+ </ div >
273+ { ( [ 'bottom-right' , 'bottom-left' , 'top-right' , 'top-left' ] as FeedbackPosition [ ] ) . map ( ( pos ) => (
274+ < button
275+ key = { pos }
276+ onClick = { ( ) => handlePositionChange ( pos ) }
277+ css = { css `
278+ dis play: flex;
279+ align- items: center;
280+ width: 100%;
281+ padding: 0.5rem 1rem;
282+ bor der: none;
283+ background: ${ pos === position ? '#f3f2f2' : 'transparent' } ;
284+ text- align: left;
285+ cursor : pointer;
286+ font- size: 0.875rem;
287+ color : # 181818;
288+ transition: background- color 0.1s ease;
289+
290+ & : hover {
291+ background-color : # f3f2f2 ;
292+ }
293+ ` }
294+ >
295+ { pos === position && (
296+ < Icon
297+ type = "utility"
298+ icon = "check"
299+ className = "slds-icon slds-icon-text-success slds-icon_xx-small"
300+ containerClassname = "slds-m-right_x-small"
301+ />
302+ ) }
303+ < span style = { { marginLeft : pos === position ? '0' : '1.25rem' } } >
304+ { pos
305+ . split ( '-' )
306+ . map ( ( word ) => word . charAt ( 0 ) . toUpperCase ( ) + word . slice ( 1 ) )
307+ . join ( ' ' ) }
308+ </ span >
309+ </ button >
310+ ) ) }
311+ < div
312+ css = { css `
313+ height : 1px ;
314+ background-color : # dddbda ;
315+ margin : 0.5rem 0 ;
316+ ` }
317+ />
318+ < button
319+ onClick = { handleHideForSession }
320+ css = { css `
321+ display : flex;
322+ align-items : center;
323+ width : 100% ;
324+ padding : 0.5rem 1rem ;
325+ border : none;
326+ background : transparent;
327+ text-align : left;
328+ cursor : pointer;
329+ font-size : 0.875rem ;
330+ color : # 181818 ;
331+ transition : background-color 0.1s ease;
332+
333+ & : hover {
334+ background-color : # f3f2f2 ;
335+ }
336+ ` }
337+ >
338+ < Icon
339+ type = "utility"
340+ icon = "hide"
341+ className = "slds-icon slds-icon-text-default slds-icon_xx-small"
342+ containerClassname = "slds-m-right_x-small"
343+ />
344+ Hide for session
345+ </ button >
346+ </ div >
347+ ) }
348+
149349 { /* Feedback modal */ }
150350 { isOpen && (
151351 < Modal
0 commit comments