diff --git a/lang/de.slf b/lang/de.slf index ae9ca3b792a74fa31329a482cf8b983911acbfc1..824154b299f56df4b77d49f1357a6e59d14feef1 100644 --- a/lang/de.slf +++ b/lang/de.slf @@ -180,6 +180,26 @@ ui.video_player.login_moodle.not_in_course = "Du bist kein Teilnehmer des Moodle ui.video_player.login_moodle.refresh_course = "Kurse aktualisieren" ui.video_player.student_council_only = "Nur für Fachschaftler verfügbar." +ui.feedback.title = "Feedback" +ui.feedback.sent_successfully = "Nachricht wurde erfolgreich gesendet. Vielen Dank!" +ui.feedback.info_text = """ + Du möchtest uns etwas mitteilen? Du hast Verbesserungsvorschläge? Du hast einen Fehler gefunden? + <br/> + Dann kannst du uns hier eine Nachricht schreiben. Wir freuen uns über jegliches Feedback. Positiv als auch negativ! + """ +ui.feedback.message = "Nachricht" +ui.feedback.email = "E-Mail (optional)" +ui.feedback.email.regex_invalid_message = "Dies ist keine gültige E-Mail. Die E-Mail darf keine Leerzeichen enthalten" +ui.feedback.consent = """ + Ich stimme zu, dass meine Nachricht (und E-Mail, falls angegeben) dauerhaft gespeichert + wird und von der VideoAG verwendet werden darf um Ihre Arbeit zu verbessern. Wenn ich + meine E-Mail angegeben habe, wird diese exklusiv genutzt damit die VideoAG mich + bezüglich meiner Nachricht kontaktieren kann. + """ +ui.feedback.consent.required = "Dies ist notwendig" +ui.feedback.send = "Abschicken" +ui.feedback.error.unable_to_send = "Nachricht konnte nicht gesendet werden" + ui.footer.imprint = "Impressum" ui.footer.twitter = "X (urspr. Twitter)" ui.footer.advertisement = "Diese Website wird betrieben von ehrenamtlichen Studierenden der [Fachschaft I/1](https://www.fsmpi.rwth-aachen.de/)." diff --git a/lang/en.slf b/lang/en.slf index 82c7c7ca40ee1bd11bc00cbb1772b9cf87280e43..37685fc5acb7997d16c10659af40f8ea7af72c34 100644 --- a/lang/en.slf +++ b/lang/en.slf @@ -184,6 +184,25 @@ ui.video_player.login_moodle.not_in_course = "You are not enrolled in the Moodle ui.video_player.login_moodle.refresh_course = "Refresh course" ui.video_player.student_council_only = "Only available to student council members" +ui.feedback.title = "Feedback" +ui.feedback.sent_successfully = "Message was sent successfully. Thank you!" +ui.feedback.info_text = """ + You would like to tell us something? You have a suggestion? You found a mistake? + <br/> + Then you can send us a message here. We look forward to any feedback. Positive as well as negative! + """ +ui.feedback.message = "Message" +ui.feedback.email = "E-Mail (optional)" +ui.feedback.email.regex_invalid_message = "This is not a valid E-Mail. The E-Mail may not contain any whitespace" +ui.feedback.consent = """ + I agree that my message (and E-Mail, if provided) is stored permanently and may be used by the VideoAG to improve + their work. If I have provided my E-Mail, it will be used exclusively so that the VideoAG can contact me regarding my + message. + """ +ui.feedback.consent.required = "This is required" +ui.feedback.send = "Send" +ui.feedback.error.unable_to_send = "Unable to send message" + ui.footer.imprint = "Imprint" ui.footer.twitter = "X (formerly Twitter)" ui.footer.advertisement = "This website is operated by volunteer students of the [student council I/1](https://www.fsmpi.rwth-aachen.de/)." diff --git a/src/api/Backend.tsx b/src/api/Backend.tsx index ab6e759c3e0404951899e1ac9f37a0a68362d4a9..16133b7fd3f4c75ebc1305a06dfb1f31a6331d56 100644 --- a/src/api/Backend.tsx +++ b/src/api/Backend.tsx @@ -17,6 +17,9 @@ import type { AuthenticationStartOAuthResponse, AuthenticationStatusRequest, AuthenticationStatusResponse, + PutFeedbackNewRequest, + GetFeedbackRequest, + GetFeedbackResponse, GetConfigurationResponse, GetNewConfigurationResponse, CreateConfiguredObjectRequest, @@ -236,6 +239,32 @@ export class BackendImpl { ); } + // PUT /feedback/new + putNewFeedback(req: PutFeedbackNewRequest): Promise<{}> { + return this.processResponse<{}>( + this.fetch(`${this.baseUrl()}/feedback/new`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(req), + }), + ); + } + + // GET /feedback + getFeedback(req: GetFeedbackRequest): Promise<GetFeedbackResponse> { + let query_params = []; + for (const [key, value] of Object.entries(req)) { + if (value !== undefined) { + query_params.push(`${key}=${value}`); + } + } + return this.processResponse<GetFeedbackResponse>( + this.fetch(`${this.baseUrl()}/feedback?${query_params.join("&")}`), + ); + } + // GET /object_management/{object_type}/{object_id}/configuration getOMConfiguration(object_type: string, object_id: int): Promise<GetConfigurationResponse> { return this.processResponse<GetConfigurationResponse>( diff --git a/src/api/DummyBackend.tsx b/src/api/DummyBackend.tsx index 8e0a887901b5c7670f6deebc568d89c672e87750..da09f5c1bf9219fdb1c2b64b5edb93d3cf9ba7ba 100644 --- a/src/api/DummyBackend.tsx +++ b/src/api/DummyBackend.tsx @@ -7,6 +7,9 @@ import type { AuthenticationStartOAuthResponse, AuthenticationStatusRequest, AuthenticationStatusResponse, + PutFeedbackNewRequest, + GetFeedbackRequest, + GetFeedbackResponse, ChapterSuggestionRequest, CreateConfiguredObjectRequest, CreateConfiguredObjectResponse, @@ -319,6 +322,14 @@ export class DummyBackend { return Promise.resolve(this.authenticationStatusResponse); } + putNewFeedback(req: PutFeedbackNewRequest): Promise<{}> { + return Promise.reject("Not implemented"); + } + + getFeedback(req: GetFeedbackRequest): Promise<GetFeedbackResponse> { + return Promise.reject("Not implemented"); + } + logout(): Promise<void> { // Return a promise resolving to the mock response return Promise.resolve(); diff --git a/src/api/api_v1_types.ts b/src/api/api_v1_types.ts index d3f41c2b8d2113504d92544cac7af6a00778c0b5..4738ad318ef429cb4a1721e0dc90d11c64be357d 100644 --- a/src/api/api_v1_types.ts +++ b/src/api/api_v1_types.ts @@ -120,6 +120,13 @@ export interface media_quality { priority: int; } +export interface feedback_entry { + id: int; + time_created: datetime; + email: string; + text: string; +} + export interface api_error { error_code: string; message: string; @@ -345,6 +352,26 @@ export interface AuthenticationStatusResponse { user_info?: user_info; } +/// PUT /feedback/new +// Request Interface +export interface PutFeedbackNewRequest { + email?: string; + text: string; +} +// Response Interface: Not needed as there is no response + +/// GET /feedback +// Request Interface +export interface GetFeedbackRequest { + entries_per_page?: int; + page?: int; +} +// Response Interface +export interface GetFeedbackResponse { + page_count: int; + page: feedback_entry[]; +} + /// GET /object_management/{object_type}/{object_id}/configuration // Request Interface export interface GetConfigurationRequest { diff --git a/src/components/DefaultLayout.tsx b/src/components/DefaultLayout.tsx index 1aaff9354d6048c32925446cf09065036a1bae64..0d69326b78068d0ceb9120e1f98fc1dd7581beca 100644 --- a/src/components/DefaultLayout.tsx +++ b/src/components/DefaultLayout.tsx @@ -255,6 +255,11 @@ function UserField({ isUnavailable }: { isUnavailable: boolean }) { Jobs </Dropdown.Item> </li> + <li> + <Dropdown.Item as={Link} href="/internal/feedback"> + Feedback + </Dropdown.Item> + </li> <li className="dropdown-divider" /> <li> <Dropdown.Item as={Link} href="/internal/user"> @@ -388,6 +393,13 @@ function NavBar({ status }: { status?: GetStatusResponse }) { url="/faq" className="flex-grow-1 text-center" /> + <NavBarIcon + iconlib="bootstrap" + icon="envelope-fill" + activeIcon="envelope" + url="/feedback" + className="flex-grow-1 text-center" + /> <a className={ "nav-link p-2 rounded flex-grow-1 text-center d-flex align-items-center justify-content-center" @@ -460,6 +472,17 @@ function NavBar({ status }: { status?: GetStatusResponse }) { FAQ </NavBarIcon> </li> + <li className="nav-item"> + <NavBarIcon + iconlib="bootstrap" + icon="envelope-fill" + activeIcon="envelope" + url="/feedback" + className="mx-1" + > + Feedback + </NavBarIcon> + </li> <a className={"nav-link p-2 rounded mx-1"} href={"https://video.fsmpi.rwth-aachen.de"} diff --git a/src/pages/feedback.tsx b/src/pages/feedback.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fc8eb3e8ba40b43a96b9e3f9d09a30bfe7e2569c --- /dev/null +++ b/src/pages/feedback.tsx @@ -0,0 +1,143 @@ +import type React from "react"; +import { useState } from "react"; +import Link from "next/link"; +import { useBackendContext } from "@/components/BackendProvider"; +import { useLanguage } from "@/components/LanguageProvider"; +import { showError } from "@/misc/ErrorHandlers"; +import { StringEditor, BooleanEditor } from "@/components/TypeEditor"; + +export default function Feedback() { + const api = useBackendContext(); + const { language } = useLanguage(); + const [textData, setTextData] = useState<[string, boolean]>(["", false]); + const [emailData, setEmailData] = useState<[string | undefined, boolean]>([undefined, true]); + const [dataConsent, setDataConsent] = useState<boolean>(false); + const [hideErrors, setHideErrors] = useState<boolean>(true); + + const [isSending, setIsSending] = useState<boolean>(false); + const [showSuccessfulMessageSent, setShowSuccessfulMessageSent] = useState<boolean>(false); + + const sendMessage = () => { + setHideErrors(false); + if (!dataConsent || !textData[1] || !emailData[1]) return; + setIsSending(true); + + api.putNewFeedback({ + email: emailData[0], + text: textData[0], + }) + .then(() => { + setIsSending(false); + setShowSuccessfulMessageSent(true); + }) + .catch((err) => { + setIsSending(false); + showError(err, language.get("ui.feedback.error.unable_to_send")); + }); + }; + + if (showSuccessfulMessageSent) { + return ( + <> + <h2>{language.get("ui.feedback.title")}</h2> + <div className="alert alert-success"> + {language.get("ui.feedback.sent_successfully")} + </div> + <Link href="/" className="btn btn-primary"> + <span className="bi bi-chevron-right" /> + Zur Startseite + </Link> + </> + ); + } + + return ( + <> + <h2>{language.get("ui.feedback.title")}</h2> + <div>{language.getStyled("ui.feedback.info_text", true)}</div> + <table className="table table-borderless w-100"> + <thead> + <tr> + <th className="m-1" /> + <th className="w-100" /> + </tr> + </thead> + <tbody> + <tr> + <td>{language.get("ui.feedback.message")}</td> + <td> + <StringEditor + value={textData[0]} + updateValue={(newValue, isValid, affectNow) => + setTextData([newValue, isValid]) + } + autoFocus={true} + hideErrors={hideErrors} + minLength={1} + maxLength={16384} + /> + </td> + </tr> + <tr> + <td>{language.get("ui.feedback.email")}</td> + <td> + <StringEditor + value={emailData[0]} + updateValue={(newValue, isValid, affectNow) => + setEmailData([newValue, isValid]) + } + hideErrors={hideErrors} + regex={/^\S+@\S+\.\S+$/} + regexInvalidMessage={language.get( + "ui.feedback.email.regex_invalid_message", + )} + allowUndefined={true} + maxLength={128} + /> + </td> + </tr> + <tr> + <td></td> + <td> + <div className="float-start"> + <BooleanEditor + value={dataConsent} + updateValue={(newValue, isValid, affectNow) => + setDataConsent(newValue) + } + hideErrors={hideErrors} + /> + </div> + <div className="ms-4"> + {language.getStyled("ui.feedback.consent", true)} + </div> + {!hideErrors && !dataConsent && ( + <div className="ms-4 d-inline invalid-feedback"> + {language.get("ui.feedback.consent.required")} + </div> + )} + </td> + </tr> + <tr> + <td></td> + <td> + <button + className="btn btn-primary" + onClick={sendMessage} + disabled={ + isSending || + (!hideErrors && (!dataConsent || !textData[1] || !emailData[1])) + } + > + {language.get("ui.feedback.send")} + </button> + {isSending && ( + <div className="spinner-border ms-2 align-middle" role="status" /> + )} + </td> + </tr> + </tbody> + </table> + </> + ); +} diff --git a/src/pages/internal/feedback.tsx b/src/pages/internal/feedback.tsx new file mode 100644 index 0000000000000000000000000000000000000000..98c4e6d3f2f2da17c401fd6cd65cb1c83698ea00 --- /dev/null +++ b/src/pages/internal/feedback.tsx @@ -0,0 +1,97 @@ +import { useBackendContext } from "@/components/BackendProvider"; +import ModeratorBarrier from "@/components/ModeratorBarrier"; +import { ReloadBoundary } from "@/components/ReloadBoundary"; +import { useFilteredDataView, PagingNavigation } from "@/components/FilteredData"; +import { DateTime } from "luxon"; +import { ErrorPage } from "@/misc/ErrorHandlers"; + +import type { GetFeedbackResponse, int } from "@/api/api_v1_types"; + +function FeedbackList({ + feedback, + setFilter, +}: { + feedback: GetFeedbackResponse; + setFilter: ( + key: string, + new_value: any | undefined, + isValid: boolean, + updateNow: boolean, + ) => void; +}) { + return ( + <> + <div className="table-responsive"> + <table className="table table-hover" style={{ tableLayout: "fixed" }}> + <thead> + <tr> + <th scope="col" style={{ width: "11em" }}> + Zeit + </th> + <th scope="col" style={{ width: "15em" }}> + Email + </th> + <th scope="col">Text</th> + </tr> + </thead> + <tbody> + {(feedback?.page ?? []).map((i) => { + const formattedDate = DateTime.fromISO(i.time_created).toFormat( + "yyyy-MM-dd HH:mm:ss", + ); + return ( + <tr key={i.id}> + <td>{formattedDate}</td> + <td>{i.email ?? ""}</td> + <td>{i.text}</td> + </tr> + ); + })} + </tbody> + </table> + </div> + </> + ); +} + +function FeedbackImpl() { + const api = useBackendContext(); + const { filters, setFilter, reloadData, isLoading, data, error } = useFilteredDataView( + (filters) => + api.getFeedback({ + entries_per_page: filters.get("entries_per_page") as int | undefined, + page: filters.get("page") as int | undefined, + }), + ); + return ( + <div className="card"> + <div className="card-header d-flex">Feedback</div> + <div className="card-body"> + <p>Hier werden alle gesendeten Feedbacks angezeigt</p> + <ReloadBoundary reloadFunc={reloadData}> + <div className="d-flex"> + <PagingNavigation + setFilter={setFilter} + filters={filters} + pageCount={data?.page_count} + /> + {isLoading && <div className="spinner-border ms-3 mt-1" />} + </div> + {error ? ( + <ErrorPage error={error} objectName="Feedback" showButtons={false} /> + ) : ( + data && <FeedbackList feedback={data} setFilter={setFilter} /> + )} + </ReloadBoundary> + </div> + </div> + ); +} + +export default function Feedback() { + return ( + <ModeratorBarrier> + <FeedbackImpl /> + </ModeratorBarrier> + ); +}