Skip to content

Commit 3295cd5

Browse files
authored
feat: adds nps component (#16579)
This PR adds the most basic form of NPS. We still need to add the custom dimensions to Matomo, but I have tried it on a custom matomo (not aztec's) and it worked :) <img width="943" height="389" alt="image" src="https://github.com/user-attachments/assets/542d3611-3dd9-4199-a18c-7051c6615cfb" /> `forceNPS()` on console will pop-up NPS widget! P.S.: I'll remove the "forceNPS" after this is tested & approved :)
2 parents 9cdf72f + 0667a9c commit 3295cd5

File tree

7 files changed

+848
-43
lines changed

7 files changed

+848
-43
lines changed

docs/src/components/Matomo/matomo.jsx

Lines changed: 22 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -64,49 +64,36 @@ export default function useMatomo() {
6464
pushInstruction("rememberConsentGiven");
6565
localStorage.setItem("matomoConsent", true);
6666
setShowBanner(false);
67+
68+
// Sync any pending analytics events
69+
if (typeof window !== 'undefined' && window.analytics) {
70+
setTimeout(() => window.analytics.syncFallbackEvents(), 1000);
71+
}
6772
};
6873

6974
const optOut = () => {
7075
pushInstruction("forgetConsentGiven");
7176
localStorage.setItem("matomoConsent", false);
7277
setShowBanner(false);
7378
};
79+
80+
// Add global debug function for console access
81+
useEffect(() => {
82+
if (env !== "prod" && typeof window !== 'undefined') {
83+
window.forceNPS = () => {
84+
const event = new CustomEvent('forceShowNPS');
85+
window.dispatchEvent(event);
86+
console.log('🔧 Forcing NPS widget to show');
87+
};
88+
89+
// Clean up on unmount
90+
return () => {
91+
delete window.forceNPS;
92+
};
93+
}
94+
}, [env]);
7495

75-
const debug = () => {
76-
pushInstruction(function () {
77-
console.log(this.getRememberedConsent());
78-
console.log(localStorage.getItem("matomoConsent"));
79-
});
80-
};
81-
82-
const reset = () => {
83-
pushInstruction("forgetConsentGiven");
84-
localStorage.clear("matomoConsent");
85-
};
86-
87-
if (!showBanner && env === "dev") {
88-
return (
89-
<div id="optout-form">
90-
<div className="homepage_footer">
91-
<p>Debugging analytics</p>
92-
<div className="homepage_cta_footer_container">
93-
<button
94-
className="cta-button button button--secondary button--sm"
95-
onClick={debug}
96-
>
97-
Debug
98-
</button>
99-
<button
100-
className="cta-button button button--secondary button--sm"
101-
onClick={reset}
102-
>
103-
Reset
104-
</button>
105-
</div>
106-
</div>
107-
</div>
108-
);
109-
} else if (!showBanner) {
96+
if (!showBanner) {
11097
return null;
11198
}
11299

@@ -136,14 +123,6 @@ export default function useMatomo() {
136123
>
137124
I refuse cookies
138125
</button>
139-
{env === "dev" && (
140-
<button
141-
className="cta-button button button--secondary button--sm"
142-
onClick={debug}
143-
>
144-
Debug
145-
</button>
146-
)}
147126
</div>
148127
</div>
149128
</div>
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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

Comments
 (0)