1+ import React , { useState , useEffect } from 'react' ;
2+ import styles from './styles.module.css' ;
3+ import { analytics } from '@site/src/utils/analytics' ;
4+
5+ interface NPSWidgetProps {
6+ siteId ?: string ;
7+ showAfterSeconds ?: number ;
8+ scrollThreshold ?: number ;
9+ pageViewsBeforeShow ?: number ;
10+ timeOnPageBeforeShow ?: number ;
11+ }
12+
13+ interface NPSData {
14+ score : number ;
15+ feedback : string ;
16+ url : string ;
17+ timestamp : number ;
18+ userAgent : string ;
19+ }
20+
21+ // Research sources for timing best practices:
22+ // - https://www.asknicely.com/blog/timing-is-everything-whens-the-best-time-to-ask-for-customer-feedback
23+ // - https://survicate.com/blog/nps-best-practices/
24+ // - https://delighted.com/blog/when-to-send-your-nps-survey
25+
26+ export default function NPSWidget ( {
27+ siteId = 'aztec-docs' ,
28+ showAfterSeconds = 180 , // 3 minutes total session time (production default)
29+ scrollThreshold = 50 , // Show when 50% through content (production default)
30+ pageViewsBeforeShow = 2 , // Show after 2nd page view (production default)
31+ timeOnPageBeforeShow = 120 // 2 minutes actively on current page (production default)
32+ } : NPSWidgetProps ) {
33+ const [ score , setScore ] = useState < number | null > ( null ) ;
34+ const [ feedback , setFeedback ] = useState ( '' ) ;
35+ const [ isSubmitted , setIsSubmitted ] = useState ( false ) ;
36+ const [ isVisible , setIsVisible ] = useState ( false ) ;
37+ const [ isDismissed , setIsDismissed ] = useState ( false ) ;
38+ const [ isAnimatingIn , setIsAnimatingIn ] = useState ( false ) ;
39+
40+ // Force show NPS for debugging (listen for custom event)
41+ useEffect ( ( ) => {
42+ const handleForceNPS = ( ) => {
43+ console . log ( '🔧 Force showing NPS widget via event' ) ;
44+ setIsVisible ( true ) ;
45+ setTimeout ( ( ) => setIsAnimatingIn ( true ) , 50 ) ;
46+
47+ // Track as debug event
48+ analytics . trackNPSWidgetEvent ( 'shown' , {
49+ debug : true ,
50+ forced : true ,
51+ timestamp : Date . now ( )
52+ } ) ;
53+ } ;
54+
55+ window . addEventListener ( 'forceShowNPS' , handleForceNPS ) ;
56+
57+ return ( ) => {
58+ window . removeEventListener ( 'forceShowNPS' , handleForceNPS ) ;
59+ } ;
60+ } , [ ] ) ;
61+
62+ // Check if user has already interacted with NPS
63+ useEffect ( ( ) => {
64+ const storageKey = `nps-${ siteId } ` ;
65+ const lastResponse = localStorage . getItem ( storageKey ) ;
66+
67+ if ( lastResponse ) {
68+ const responseData = JSON . parse ( lastResponse ) ;
69+ const daysSinceResponse = ( Date . now ( ) - responseData . timestamp ) / ( 1000 * 60 * 60 * 24 ) ;
70+
71+ // Show again after 90 days
72+ if ( daysSinceResponse < 90 ) {
73+ return ;
74+ }
75+ }
76+
77+ // Check if user dismissed recently (don't show for 7 days)
78+ const dismissedKey = `nps-dismissed-${ siteId } ` ;
79+ const lastDismissed = localStorage . getItem ( dismissedKey ) ;
80+ if ( lastDismissed ) {
81+ const daysSinceDismissed = ( Date . now ( ) - parseInt ( lastDismissed ) ) / ( 1000 * 60 * 60 * 24 ) ;
82+ if ( daysSinceDismissed < 7 ) {
83+ return ;
84+ }
85+ }
86+
87+ // Track page views
88+ const pageViewsKey = `nps-pageviews-${ siteId } ` ;
89+ const currentPageViews = parseInt ( localStorage . getItem ( pageViewsKey ) || '0' ) ;
90+ const newPageViews = currentPageViews + 1 ;
91+ localStorage . setItem ( pageViewsKey , newPageViews . toString ( ) ) ;
92+
93+ // Don't show if not enough page views yet
94+ if ( newPageViews < pageViewsBeforeShow ) {
95+ return ;
96+ }
97+
98+ // Tracking variables for multiple conditions
99+ let timeoutId : NodeJS . Timeout ;
100+ let timeOnPageId : NodeJS . Timeout ;
101+ let startTime = Date . now ( ) ;
102+ let hasShown = false ;
103+ let timeConditionMet = false ;
104+ let scrollConditionMet = false ;
105+ let timeOnPageConditionMet = false ;
106+
107+ const checkAllConditions = ( ) => {
108+ // Require BOTH scroll engagement AND time investment
109+ if ( scrollConditionMet && ( timeConditionMet || timeOnPageConditionMet ) ) {
110+ showWidget ( ) ;
111+ }
112+ } ;
113+
114+ const showWidget = ( ) => {
115+ if ( hasShown ) return ;
116+ hasShown = true ;
117+ setIsVisible ( true ) ;
118+
119+ // Track widget shown event
120+ analytics . trackNPSWidgetEvent ( 'shown' , {
121+ pageViews : newPageViews ,
122+ timeOnSite : Math . round ( ( Date . now ( ) - startTime ) / 1000 ) ,
123+ scrollPercentage : Math . round ( ( window . scrollY / ( document . body . scrollHeight - window . innerHeight ) ) * 100 )
124+ } ) ;
125+
126+ // Add animation delay
127+ setTimeout ( ( ) => {
128+ setIsAnimatingIn ( true ) ;
129+ } , 50 ) ;
130+ } ;
131+
132+ const handleScroll = ( ) => {
133+ const scrolled = ( window . scrollY / ( document . body . scrollHeight - window . innerHeight ) ) * 100 ;
134+ if ( scrolled > scrollThreshold && ! scrollConditionMet ) {
135+ scrollConditionMet = true ;
136+ checkAllConditions ( ) ;
137+ }
138+ } ;
139+
140+ const handleVisibilityChange = ( ) => {
141+ if ( document . hidden ) {
142+ // User switched tabs/minimized - pause timer
143+ startTime = Date . now ( ) ;
144+ }
145+ } ;
146+
147+ // Condition 1: After specified time of total session
148+ timeoutId = setTimeout ( ( ) => {
149+ timeConditionMet = true ;
150+ checkAllConditions ( ) ;
151+ } , showAfterSeconds * 1000 ) ;
152+
153+ // Condition 2: After time actively on current page
154+ timeOnPageId = setTimeout ( ( ) => {
155+ if ( ! document . hidden && ( Date . now ( ) - startTime ) >= timeOnPageBeforeShow * 1000 ) {
156+ timeOnPageConditionMet = true ;
157+ checkAllConditions ( ) ;
158+ }
159+ } , timeOnPageBeforeShow * 1000 ) ;
160+
161+ // Always listen for scroll
162+ window . addEventListener ( 'scroll' , handleScroll ) ;
163+ document . addEventListener ( 'visibilitychange' , handleVisibilityChange ) ;
164+
165+ return ( ) => {
166+ clearTimeout ( timeoutId ) ;
167+ clearTimeout ( timeOnPageId ) ;
168+ window . removeEventListener ( 'scroll' , handleScroll ) ;
169+ document . removeEventListener ( 'visibilitychange' , handleVisibilityChange ) ;
170+ } ;
171+ } , [ siteId , showAfterSeconds , scrollThreshold , pageViewsBeforeShow , timeOnPageBeforeShow ] ) ;
172+
173+ const handleScoreClick = ( selectedScore : number ) => {
174+ setScore ( selectedScore ) ;
175+ } ;
176+
177+ const handleSubmit = ( ) => {
178+ if ( score === null ) return ;
179+
180+ const npsData : NPSData = {
181+ score,
182+ feedback,
183+ url : window . location . href ,
184+ timestamp : Date . now ( ) ,
185+ userAgent : navigator . userAgent ,
186+ } ;
187+
188+ // Store response to prevent showing again
189+ localStorage . setItem ( `nps-${ siteId } ` , JSON . stringify ( npsData ) ) ;
190+
191+ // Send to analytics (replace with your preferred service)
192+ sendNPSData ( npsData ) ;
193+
194+ setIsSubmitted ( true ) ;
195+
196+ // Hide the widget after 4 seconds with animation
197+ setTimeout ( ( ) => {
198+ setIsAnimatingIn ( false ) ;
199+ setTimeout ( ( ) => {
200+ setIsVisible ( false ) ;
201+ } , 300 ) ; // Wait for exit animation
202+ } , 4000 ) ;
203+ } ;
204+
205+ const handleClose = ( ) => {
206+ // Track dismissal
207+ analytics . trackNPSWidgetEvent ( 'dismissed' , {
208+ hadScore : score !== null ,
209+ hadFeedback : feedback . length > 0
210+ } ) ;
211+
212+ // Store dismissal to prevent showing for a week
213+ localStorage . setItem ( `nps-dismissed-${ siteId } ` , Date . now ( ) . toString ( ) ) ;
214+ setIsDismissed ( true ) ;
215+
216+ // Animate out
217+ setIsAnimatingIn ( false ) ;
218+ setTimeout ( ( ) => {
219+ setIsVisible ( false ) ;
220+ } , 300 ) ;
221+ } ;
222+
223+ // Send NPS data using improved analytics
224+ const sendNPSData = ( data : NPSData ) => {
225+ analytics . trackNPSResponse ( data ) ;
226+ } ;
227+
228+ if ( ! isVisible || isDismissed ) return null ;
229+
230+ return (
231+ < div className = { `${ styles . npsWidget } ${ isAnimatingIn ? styles . visible : styles . hidden } ` } >
232+ < div className = { styles . npsWidgetContent } >
233+ < button className = { styles . npsCloseBtn } onClick = { handleClose } > ×</ button >
234+
235+ { ! isSubmitted ? (
236+ < div >
237+ < h4 > How likely are you to recommend this documentation to a friend or colleague?</ h4 >
238+
239+ < div className = { styles . npsScale } >
240+ < div className = { styles . npsScaleLabels } >
241+ < span className = { styles . npsScaleLabel } > Not at all likely</ span >
242+ < span className = { styles . npsScaleLabel } > Extremely likely</ span >
243+ </ div >
244+ < div className = { styles . npsScores } >
245+ { [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 ] . map ( ( num ) => (
246+ < button
247+ key = { num }
248+ className = { `${ styles . npsScoreBtn } ${ score === num ? styles . selected : '' } ` }
249+ onClick = { ( ) => handleScoreClick ( num ) }
250+ >
251+ { num }
252+ </ button >
253+ ) ) }
254+ </ div >
255+ </ div >
256+
257+ { score !== null && (
258+ < div className = { styles . npsFeedbackSection } >
259+ < label htmlFor = "nps-feedback" >
260+ What's the main reason for your score?
261+ </ label >
262+ < textarea
263+ id = "nps-feedback"
264+ value = { feedback }
265+ onChange = { ( e ) => setFeedback ( e . target . value ) }
266+ placeholder = "Optional: Help us understand your rating..."
267+ rows = { 3 }
268+ />
269+ < button className = { styles . npsSubmitBtn } onClick = { handleSubmit } >
270+ Submit
271+ </ button >
272+ </ div >
273+ ) }
274+ </ div >
275+ ) : (
276+ < div className = { styles . npsThankYou } >
277+ < h4 > Thank you for your feedback!</ h4 >
278+ < p > Your input helps us improve our documentation.</ p >
279+ </ div >
280+ ) }
281+ </ div >
282+ </ div >
283+ ) ;
284+ }
0 commit comments