Skip to content

Commit a0cea01

Browse files
committed
WIP for feedback widget
1 parent 1b4e1e5 commit a0cea01

14 files changed

Lines changed: 480 additions & 13 deletions

File tree

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@surfnet/sds": "^0.0.159",
1818
"detect-browser": "^5.3.0",
1919
"dompurify": "^3.3.1",
20+
"html2canvas": "^1.4.1",
2021
"i18n-js": "^4.5.2",
2122
"isomorphic-dompurify": "^2.36.0",
2223
"js-cookie": "^3.0.5",

client/src/App.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import Feedback from "./pages/Feedback.jsx";
4040
import MyOrganization from "./pages/MyOrganization.jsx";
4141
import ApplicationOverview from "./pages/ApplicationOverview.jsx";
4242
import Profile from "./pages/Profile.jsx";
43+
import {UserFeedbackWidget} from "./components/UserFeedbackWidget.jsx";
4344

4445
const App = () => {
4546

@@ -124,6 +125,7 @@ const App = () => {
124125
<SharedMenu currentLocation={currentLocation}/>
125126
<div className="pages">
126127
<AuthorizedHeader setIsAuthenticated={setIsAuthenticated}/>
128+
<UserFeedbackWidget/>
127129
<Routes>
128130
<Route path="/" element={<Navigate replace to="/home"/>}/>
129131
<Route path="/landing" element={<Landing refreshUser={refreshUser}/>}/>
@@ -176,4 +178,4 @@ const App = () => {
176178
);
177179
}
178180

179-
export default App;
181+
export default App;

client/src/api/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export function feedback(message) {
9696
return postPutJson("/api/v1/users/feedback", {message: message}, "POST");
9797
}
9898

99+
export function sendFeedback(formData) {
100+
return postPutJson("/api/v1/feedback", formData, "POST");
101+
}
102+
99103
//Organizations
100104

101105
//attributePaths = {"organizationMemberships.user", "invitations.invitee", "joinRequests.user"}
@@ -361,4 +365,3 @@ export function loginAggregated(period, spEntityId) {
361365
export function uniqueLoginCount(from, to, spEntityId) {
362366
return fetchJson(`/api/v1/stats/uniqueLoginCount?from=${from}&to=${to}&spEntityId=${encodeURIComponent(spEntityId)}`)
363367
}
364-
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React, {useCallback, useRef, useState} from "react";
2+
import {createPortal} from "react-dom";
3+
import {Checkbox} from "@surfnet/sds";
4+
import {useLocation} from "react-router-dom";
5+
import DOMPurify from "dompurify";
6+
import html2canvas from "html2canvas";
7+
import I18n from "../locale/I18n.js";
8+
import {sendFeedback} from "../api/index.js";
9+
import {useAppStore} from "../stores/AppStore.js";
10+
import ConfirmationDialog from "./ConfirmationDialog.jsx";
11+
import "./UserFeedbackWidget.scss";
12+
13+
const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
14+
15+
export const UserFeedbackWidget = () => {
16+
const location = useLocation();
17+
const setFlash = useAppStore(state => state.setFlash);
18+
const [open, setOpen] = useState(false);
19+
const [message, setMessage] = useState("");
20+
const [includeScreenshot, setIncludeScreenshot] = useState(true);
21+
const [submitting, setSubmitting] = useState(false);
22+
const inputRef = useRef(null);
23+
24+
const closeModal = () => {
25+
setOpen(false);
26+
setMessage("");
27+
setIncludeScreenshot(true);
28+
};
29+
30+
const captureScreenshot = useCallback(async () => {
31+
document.body.classList.add("feedback-capture");
32+
try {
33+
const canvas = await html2canvas(document.body, {
34+
backgroundColor: null,
35+
useCORS: true,
36+
scale: 1,
37+
windowWidth: document.documentElement.clientWidth,
38+
windowHeight: document.documentElement.clientHeight
39+
});
40+
return canvas.toDataURL("image/png");
41+
} finally {
42+
document.body.classList.remove("feedback-capture");
43+
}
44+
}, []);
45+
46+
const handleSubmit = useCallback(async () => {
47+
if (!message.trim()) {
48+
return;
49+
}
50+
setSubmitting(true);
51+
try {
52+
const payload = {
53+
message,
54+
url: `${window.location.origin}${location.pathname}${location.search}${location.hash}`,
55+
includeScreenshot
56+
};
57+
58+
if (includeScreenshot) {
59+
const dataUrl = await captureScreenshot();
60+
const base64 = dataUrl.split(",")[1] || "";
61+
const estimatedBytes = Math.ceil((base64.length * 3) / 4);
62+
if (estimatedBytes > MAX_SCREENSHOT_BYTES) {
63+
setFlash(I18n.t("feedback.tooLarge"));
64+
} else if (base64.length > 0) {
65+
payload.screenshotBase64 = base64;
66+
payload.screenshotContentType = "image/png";
67+
}
68+
}
69+
70+
await sendFeedback(payload);
71+
setFlash(I18n.t("feedback.flash"));
72+
closeModal();
73+
} catch (error) {
74+
setFlash(I18n.t("forms.error"));
75+
} finally {
76+
setSubmitting(false);
77+
}
78+
}, [captureScreenshot, includeScreenshot, location.hash, location.pathname, location.search, message, setFlash]);
79+
80+
81+
const renderContent = () => (
82+
<div className="user-feedback-widget__modal">
83+
<p>{I18n.t("feedback.info")}</p>
84+
<div className="sds--text-area">
85+
<textarea
86+
name="feedback"
87+
id="feedback"
88+
value={message}
89+
rows="6"
90+
ref={inputRef}
91+
onChange={e => setMessage(e.target.value)}
92+
/>
93+
</div>
94+
<label className="user-feedback-widget__options">
95+
<Checkbox
96+
value={includeScreenshot}
97+
onChange={() => setIncludeScreenshot(!includeScreenshot)}
98+
/>
99+
<span>{I18n.t("feedback.includeScreenshot")}</span>
100+
</label>
101+
<section className="disclaimer">
102+
<span
103+
dangerouslySetInnerHTML={{
104+
__html: DOMPurify.sanitize(I18n.t("feedback.disclaimer"), {
105+
ADD_ATTR: ["target", "rel"]
106+
})
107+
}}
108+
/>
109+
</section>
110+
<section className="help">
111+
<h3
112+
className="title"
113+
dangerouslySetInnerHTML={{
114+
__html: DOMPurify.sanitize(I18n.t("feedback.help"))
115+
}}
116+
/>
117+
<span
118+
dangerouslySetInnerHTML={{
119+
__html: DOMPurify.sanitize(I18n.t("feedback.helpInfo"))
120+
}}
121+
/>
122+
</section>
123+
</div>
124+
);
125+
126+
return (
127+
<div className="user-feedback-widget">
128+
<button
129+
className="user-feedback-widget__trigger"
130+
onClick={() => {
131+
setOpen(true);
132+
setTimeout(() => inputRef.current?.focus(), 0);
133+
}}
134+
type="button"
135+
>
136+
{I18n.t("feedback.widgetLabel")}
137+
</button>
138+
{open && createPortal(
139+
<ConfirmationDialog
140+
cancel={closeModal}
141+
confirm={handleSubmit}
142+
confirmationHeader={I18n.t("feedback.title")}
143+
confirmationTxt={I18n.t("forms.submit")}
144+
disabledConfirm={submitting || !message.trim()}
145+
children={renderContent()}
146+
largeWidth={true}
147+
/>,
148+
document.body
149+
)}
150+
</div>
151+
);
152+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
.user-feedback-widget {
2+
position: fixed;
3+
right: 0;
4+
top: 50%;
5+
transform: translateY(-50%);
6+
z-index: 90;
7+
8+
&__trigger {
9+
background: var(--sl-color-green-200);
10+
color: #000;
11+
border: none;
12+
border-radius: 999px;
13+
padding: 10px 14px;
14+
font-size: 12px;
15+
font-weight: 600;
16+
cursor: pointer;
17+
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
18+
transition: transform 0.2s ease, box-shadow 0.2s ease;
19+
transform: rotate(-90deg);
20+
transform-origin: right center;
21+
}
22+
23+
&__trigger:hover {
24+
transform: rotate(-90deg) translateY(-2px);
25+
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.2);
26+
}
27+
28+
&__modal {
29+
display: flex;
30+
flex-direction: column;
31+
gap: 16px;
32+
}
33+
34+
&__options {
35+
display: flex;
36+
align-items: center;
37+
gap: 10px;
38+
font-size: 14px;
39+
color: #2a2a2a;
40+
}
41+
42+
.disclaimer,
43+
.help {
44+
font-size: 13px;
45+
color: #4b4b4b;
46+
}
47+
48+
.help .title {
49+
font-size: 14px;
50+
margin-bottom: 4px;
51+
}
52+
}
53+
54+
body.feedback-capture .user-feedback-widget,
55+
body.feedback-capture .sds--modal {
56+
visibility: hidden !important;
57+
}
58+
59+
@media (max-width: 768px) {
60+
.user-feedback-widget {
61+
right: 0;
62+
top: auto;
63+
bottom: 18px;
64+
transform: none;
65+
}
66+
}

client/src/locale/en.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,10 @@ const en = {
957957
help: "Need help?",
958958
helpInfo: "For questions or issues, please reach out to us at <a href='mailto:support@surf.nl'>support@surf.nl</a>",
959959
send: "Provide feedback",
960-
flash: "Your feedback has been sent. Thanks!"
960+
flash: "Your feedback has been sent. Thanks!",
961+
widgetLabel: "Feedback",
962+
includeScreenshot: "Include screenshot",
963+
tooLarge: "The screenshot is too large (max 5MB). It was not attached."
961964
},
962965
myOrganization: {
963966
title: "My organisation",
@@ -1142,4 +1145,3 @@ const en = {
11421145
}
11431146

11441147
export default en;
1145-

client/src/locale/nl.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -947,16 +947,19 @@ const nl = {
947947
copied: "Copied"
948948
},
949949
feedback: {
950-
title: "Provide Feedback",
951-
info: "Like what you see? Have a suggestion? Let us know what you think here",
950+
title: "Feedback geven",
951+
info: "Zie je iets wat beter kan? Laat ons weten wat je denkt.",
952952
disclaimer: "We will use this information to fix problems, improve our products and help you. " +
953953
"We may follow up with you regarding your feedback. " +
954954
"Please make sure the feedback does not contain any confidential, sensitive, or personal information. " +
955955
"For more information, please review our <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://edu.nl/fcgbd\">Privacy Notice</a>.",
956-
help: "Need help?",
957-
helpInfo: "For questions or issues, please reach out to us at <a href='mailto:support@surf.nl'>support@surf.nl</a>",
958-
send: "Provide feedback",
959-
flash: "Your feedback has been sent. Thanks!"
956+
help: "Hulp nodig?",
957+
helpInfo: "Voor vragen of problemen kun je ons bereiken via <a href='mailto:support@surf.nl'>support@surf.nl</a>",
958+
send: "Feedback geven",
959+
flash: "Je feedback is verstuurd. Bedankt!",
960+
widgetLabel: "Feedback",
961+
includeScreenshot: "Schermafbeelding toevoegen",
962+
tooLarge: "De schermafbeelding is te groot (max 5MB). Deze is niet toegevoegd."
960963
},
961964
myOrganization: {
962965
title: "My organisation",
@@ -1136,4 +1139,3 @@ const nl = {
11361139
}
11371140

11381141
export default nl;
1139-

client/yarn.lock

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,11 @@ balanced-match@^1.0.0:
12631263
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
12641264
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
12651265

1266+
base64-arraybuffer@^1.0.2:
1267+
version "1.0.2"
1268+
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
1269+
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
1270+
12661271
baseline-browser-mapping@^2.9.0:
12671272
version "2.9.19"
12681273
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488"
@@ -1439,6 +1444,13 @@ cross-spawn@^7.0.6:
14391444
shebang-command "^2.0.0"
14401445
which "^2.0.1"
14411446

1447+
css-line-break@^2.1.0:
1448+
version "2.1.0"
1449+
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
1450+
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
1451+
dependencies:
1452+
utrie "^1.0.2"
1453+
14421454
css-tree@^3.1.0:
14431455
version "3.1.0"
14441456
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-3.1.0.tgz#7aabc035f4e66b5c86f54570d55e05b1346eb0fd"
@@ -2174,6 +2186,14 @@ html-encoding-sniffer@^6.0.0:
21742186
dependencies:
21752187
"@exodus/bytes" "^1.6.0"
21762188

2189+
html2canvas@^1.4.1:
2190+
version "1.4.1"
2191+
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
2192+
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
2193+
dependencies:
2194+
css-line-break "^2.1.0"
2195+
text-segmentation "^1.0.3"
2196+
21772197
http-proxy-agent@^7.0.2:
21782198
version "7.0.2"
21792199
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e"
@@ -3370,6 +3390,13 @@ tabbable@^6.0.0:
33703390
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581"
33713391
integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==
33723392

3393+
text-segmentation@^1.0.3:
3394+
version "1.0.3"
3395+
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
3396+
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
3397+
dependencies:
3398+
utrie "^1.0.2"
3399+
33733400
tinybench@^2.9.0:
33743401
version "2.9.0"
33753402
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
@@ -3528,6 +3555,13 @@ use-isomorphic-layout-effect@^1.2.0:
35283555
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz#2f11a525628f56424521c748feabc2ffcc962fce"
35293556
integrity sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==
35303557

3558+
utrie@^1.0.2:
3559+
version "1.0.2"
3560+
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
3561+
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
3562+
dependencies:
3563+
base64-arraybuffer "^1.0.2"
3564+
35313565
vite-plugin-svgr@^4.5.0:
35323566
version "4.5.0"
35333567
resolved "https://registry.yarnpkg.com/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz#253e4c703d1f0b30935c285ca8621f4857952338"

0 commit comments

Comments
 (0)