Skip to content

Commit dfe3c77

Browse files
committed
refactor(feedback-modal): streamline feedback modal components and enhance localization
1 parent a185863 commit dfe3c77

23 files changed

+1446
-1237
lines changed

SortVision/src/components/feedback/FeedbackModal.jsx

Lines changed: 111 additions & 1221 deletions
Large diffs are not rendered by default.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* Assembles the payload sent to submitFeedback (telemetry + form fields).
3+
* Side-effect free aside from reading window/document/localStorage.
4+
*/
5+
6+
function getDeviceInfo() {
7+
const ua = navigator.userAgent;
8+
const isMobile =
9+
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
10+
const isTablet = /iPad/i.test(ua) || (isMobile && window.innerWidth > 768);
11+
12+
return {
13+
deviceType: isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop',
14+
isMobile,
15+
isTablet,
16+
platform: navigator.platform || 'Unknown',
17+
vendor: navigator.vendor || 'Unknown',
18+
cookieEnabled: navigator.cookieEnabled,
19+
onlineStatus: navigator.onLine,
20+
doNotTrack: navigator.doNotTrack || 'Not set',
21+
};
22+
}
23+
24+
function getNetworkInfo() {
25+
const connection =
26+
navigator.connection ||
27+
navigator.mozConnection ||
28+
navigator.webkitConnection;
29+
return connection
30+
? {
31+
effectiveType: connection.effectiveType || 'Unknown',
32+
downlink: connection.downlink || 'Unknown',
33+
rtt: connection.rtt || 'Unknown',
34+
saveData: connection.saveData || false,
35+
}
36+
: {
37+
effectiveType: 'Unknown',
38+
downlink: 'Unknown',
39+
rtt: 'Unknown',
40+
saveData: false,
41+
};
42+
}
43+
44+
function getPerformanceInfo() {
45+
if (performance && performance.timing) {
46+
const timing = performance.timing;
47+
return {
48+
domContentLoaded:
49+
timing.domContentLoadedEventEnd - timing.navigationStart,
50+
pageLoad: timing.loadEventEnd - timing.navigationStart,
51+
dnsLookup: timing.domainLookupEnd - timing.domainLookupStart,
52+
tcpConnect: timing.connectEnd - timing.connectStart,
53+
serverResponse: timing.responseEnd - timing.requestStart,
54+
};
55+
}
56+
return null;
57+
}
58+
59+
function getBrowserCapabilities() {
60+
return {
61+
localStorage: typeof Storage !== 'undefined',
62+
sessionStorage: typeof Storage !== 'undefined',
63+
webGL: !!window.WebGLRenderingContext,
64+
touchSupport: 'ontouchstart' in window,
65+
geolocation: 'geolocation' in navigator,
66+
webWorkers: typeof Worker !== 'undefined',
67+
websockets: 'WebSocket' in window,
68+
indexedDB: 'indexedDB' in window,
69+
serviceWorker: 'serviceWorker' in navigator,
70+
pushNotifications: 'PushManager' in window,
71+
};
72+
}
73+
74+
function getPageContext() {
75+
return {
76+
url: window.location.href,
77+
pathname: window.location.pathname,
78+
search: window.location.search,
79+
hash: window.location.hash,
80+
referrer: document.referrer || 'Direct access',
81+
title: document.title,
82+
scrollPosition: {
83+
x: window.pageXOffset || window.scrollX,
84+
y: window.pageYOffset || window.scrollY,
85+
},
86+
documentHeight: Math.max(
87+
document.body.scrollHeight,
88+
document.body.offsetHeight,
89+
document.documentElement.clientHeight,
90+
document.documentElement.scrollHeight,
91+
document.documentElement.offsetHeight
92+
),
93+
};
94+
}
95+
96+
function getMemoryInfo() {
97+
if (performance && performance.memory) {
98+
return {
99+
usedJSHeapSize: Math.round(performance.memory.usedJSHeapSize / 1048576),
100+
totalJSHeapSize: Math.round(performance.memory.totalJSHeapSize / 1048576),
101+
jsHeapSizeLimit: Math.round(performance.memory.jsHeapSizeLimit / 1048576),
102+
};
103+
}
104+
return null;
105+
}
106+
107+
function getErrorHistory() {
108+
const errors = [];
109+
try {
110+
const storedErrors = localStorage.getItem('sortvision_error_log');
111+
if (storedErrors) {
112+
errors.push(...JSON.parse(storedErrors));
113+
}
114+
} catch {
115+
// ignore
116+
}
117+
return errors.slice(-5);
118+
}
119+
120+
function getFeatureUsage() {
121+
try {
122+
const usage = localStorage.getItem('sortvision_feature_usage');
123+
return usage ? JSON.parse(usage) : null;
124+
} catch {
125+
return null;
126+
}
127+
}
128+
129+
function getAccessibilityInfo() {
130+
return {
131+
reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
132+
highContrast: window.matchMedia('(prefers-contrast: high)').matches,
133+
darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
134+
forcedColors: window.matchMedia('(forced-colors: active)').matches,
135+
};
136+
}
137+
138+
export function buildEnhancedFeedbackPayload({
139+
formData,
140+
locationData,
141+
sessionId,
142+
timeSpentOnSite,
143+
persistentSessionStart,
144+
appLocale,
145+
}) {
146+
return {
147+
...formData,
148+
locationData,
149+
sessionData: {
150+
sessionId,
151+
timeSpentOnSite,
152+
sessionStartTime: new Date(persistentSessionStart).toISOString(),
153+
submissionTime: new Date().toISOString(),
154+
userAgent: navigator.userAgent,
155+
screenResolution: `${screen.width}x${screen.height}`,
156+
viewportSize: `${window.innerWidth}x${window.innerHeight}`,
157+
language: navigator.language,
158+
appLocale,
159+
languages: navigator.languages || [navigator.language],
160+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
161+
colorDepth: screen.colorDepth,
162+
pixelRatio: window.devicePixelRatio || 1,
163+
},
164+
deviceInfo: getDeviceInfo(),
165+
networkInfo: getNetworkInfo(),
166+
performanceInfo: getPerformanceInfo(),
167+
browserCapabilities: getBrowserCapabilities(),
168+
pageContext: getPageContext(),
169+
memoryInfo: getMemoryInfo(),
170+
errorHistory: getErrorHistory(),
171+
featureUsage: getFeatureUsage(),
172+
accessibilityInfo: getAccessibilityInfo(),
173+
};
174+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useEffect } from 'react';
2+
3+
export function useFeedbackModalChrome(isOpen, onClose) {
4+
useEffect(() => {
5+
const onKeyDown = e => {
6+
if (e.key === 'Escape' && isOpen) onClose();
7+
};
8+
document.addEventListener('keydown', onKeyDown);
9+
return () => document.removeEventListener('keydown', onKeyDown);
10+
}, [isOpen, onClose]);
11+
12+
useEffect(() => {
13+
if (isOpen) {
14+
document.body.style.overflow = 'hidden';
15+
} else {
16+
document.body.style.overflow = 'unset';
17+
}
18+
return () => {
19+
document.body.style.overflow = 'unset';
20+
};
21+
}, [isOpen]);
22+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useState, useEffect, useRef } from 'react';
2+
import { detectUserLocation, getSimplifiedRegion } from '../locationService';
3+
4+
/**
5+
* Runs one-shot geo probe when the modal opens and `locationData` is still null.
6+
*/
7+
export function useFeedbackModalLocation(
8+
isOpen,
9+
shouldLog,
10+
onApplyDetectedRegion
11+
) {
12+
const [detectedRegion, setDetectedRegion] = useState('');
13+
const [locationData, setLocationData] = useState(null);
14+
const [isDetectingLocation, setIsDetectingLocation] = useState(false);
15+
const applyRef = useRef(onApplyDetectedRegion);
16+
applyRef.current = onApplyDetectedRegion;
17+
18+
useEffect(() => {
19+
if (!isOpen || locationData) return;
20+
21+
let cancelled = false;
22+
23+
(async () => {
24+
setIsDetectingLocation(true);
25+
try {
26+
if (shouldLog) {
27+
console.log('[SortVision] feedback: starting location detection');
28+
}
29+
const location = await detectUserLocation();
30+
31+
const enhancedLocationData = {
32+
...location,
33+
country:
34+
location.countryFromLocale && location.country === 'Unknown'
35+
? location.countryFromLocale
36+
: location.country,
37+
detectionDetails: {
38+
browser: location.browser,
39+
os: location.os,
40+
locale: location.locale,
41+
platform: location.platform,
42+
connectionType: location.connectionType,
43+
screenResolution: location.screenResolution,
44+
},
45+
};
46+
47+
if (shouldLog) {
48+
console.log(
49+
'[SortVision] feedback: location detected',
50+
enhancedLocationData
51+
);
52+
}
53+
54+
if (cancelled) return;
55+
56+
setLocationData(enhancedLocationData);
57+
const simplifiedRegion = getSimplifiedRegion(enhancedLocationData);
58+
setDetectedRegion(simplifiedRegion);
59+
applyRef.current(simplifiedRegion);
60+
} catch (error) {
61+
console.error(
62+
'[SortVision] feedback: location detection failed',
63+
error
64+
);
65+
if (cancelled) return;
66+
setDetectedRegion('Unknown');
67+
setLocationData({
68+
country: 'Detection failed',
69+
region: 'Unknown',
70+
city: 'Unknown',
71+
timezone: 'Unknown',
72+
detectionMethod: 'Failed',
73+
accuracy: 'none',
74+
});
75+
} finally {
76+
if (!cancelled) setIsDetectingLocation(false);
77+
}
78+
})();
79+
80+
return () => {
81+
cancelled = true;
82+
};
83+
}, [isOpen, locationData, shouldLog]);
84+
85+
return {
86+
detectedRegion,
87+
setDetectedRegion,
88+
locationData,
89+
setLocationData,
90+
isDetectingLocation,
91+
};
92+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
3+
const SESSION_KEY = 'sortvision_session_id';
4+
const SESSION_START_KEY = 'sortvision_session_start';
5+
6+
function readOrCreateSessionId() {
7+
let id = localStorage.getItem(SESSION_KEY);
8+
if (!id) {
9+
const timestamp = Date.now().toString(36).toUpperCase();
10+
const randomBytes = new Uint8Array(4);
11+
crypto.getRandomValues(randomBytes);
12+
const randomString = Array.from(randomBytes, byte => byte.toString(36))
13+
.join('')
14+
.toUpperCase();
15+
id = `sess_${timestamp}_${randomString}`;
16+
localStorage.setItem(SESSION_KEY, id);
17+
localStorage.setItem(SESSION_START_KEY, Date.now().toString());
18+
}
19+
return id;
20+
}
21+
22+
function readSessionStartMs() {
23+
const stored = localStorage.getItem(SESSION_START_KEY);
24+
return stored ? parseInt(stored, 10) : Date.now();
25+
}
26+
27+
export function useFeedbackModalSession(isOpen) {
28+
const [sessionId] = useState(readOrCreateSessionId);
29+
const [persistentSessionStart] = useState(readSessionStartMs);
30+
const [timeSpentOnSite, setTimeSpentOnSite] = useState(0);
31+
32+
useEffect(() => {
33+
if (!isOpen) return;
34+
35+
const tick = () => {
36+
setTimeSpentOnSite(
37+
Math.round((Date.now() - persistentSessionStart) / 1000)
38+
);
39+
};
40+
tick();
41+
const interval = setInterval(tick, 10000);
42+
return () => clearInterval(interval);
43+
}, [isOpen, persistentSessionStart]);
44+
45+
const formatTimeSpent = useCallback(seconds => {
46+
if (seconds < 60) return `${seconds}s`;
47+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
48+
const hours = Math.floor(seconds / 3600);
49+
const minutes = Math.floor((seconds % 3600) / 60);
50+
return `${hours}h ${minutes}m`;
51+
}, []);
52+
53+
return {
54+
sessionId,
55+
persistentSessionStart,
56+
timeSpentOnSite,
57+
formatTimeSpent,
58+
};
59+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from 'react';
2+
import { CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
3+
import { MessageSquare } from 'lucide-react';
4+
5+
export function FeedbackModalCardHeader({ t, isSubmitting }) {
6+
const titleParts = t('feedback.title').split(' ');
7+
8+
return (
9+
<CardHeader className="text-center pr-12">
10+
<div className="flex items-center justify-center gap-3 mb-4">
11+
<MessageSquare
12+
className={`h-7 w-7 transition-all duration-300 ${
13+
isSubmitting
14+
? 'text-amber-400 animate-spin'
15+
: 'text-emerald-400 animate-pulse'
16+
}`}
17+
style={{ animationDuration: isSubmitting ? '1s' : '2.5s' }}
18+
/>
19+
<CardTitle className="text-2xl font-bold font-mono text-white">
20+
<span className="text-emerald-400">{titleParts[0]}</span>
21+
<span className="text-purple-400">
22+
{titleParts[1] ? ` ${titleParts[1]}` : ''}
23+
</span>
24+
</CardTitle>
25+
</div>
26+
27+
{isSubmitting && (
28+
<div className="mb-4 animate-in slide-in-from-top-2 duration-300">
29+
<div className="w-full bg-slate-700 rounded-full h-1 overflow-hidden">
30+
<div className="h-full bg-gradient-to-r from-amber-400 via-emerald-400 to-amber-400 animate-pulse bg-size-200 animate-shimmer" />
31+
</div>
32+
<div
33+
className="text-xs text-amber-400 font-mono mt-2 animate-pulse"
34+
style={{ animationDuration: '2s' }}
35+
>
36+
{t('feedback.processing')}
37+
</div>
38+
</div>
39+
)}
40+
41+
<CardDescription className="text-slate-400 font-mono">
42+
<span className="text-amber-400">//</span> {t('feedback.description')}
43+
<br />
44+
<span className="text-amber-400">//</span> {t('feedback.description2')}
45+
</CardDescription>
46+
</CardHeader>
47+
);
48+
}

0 commit comments

Comments
 (0)