feat: add feedback form, remove: talkback form

This commit is contained in:
Erza Bytyci
2026-05-19 10:02:53 +00:00
parent c411a7fd48
commit 31ab34de60
8 changed files with 167 additions and 232 deletions
+59
View File
@@ -0,0 +1,59 @@
import { z } from "zod";
import { withAuth } from "@/backend/middleware/withAuth";
import sendEmail from "@/backend/mailService";
const contactSchema = z.object({
message: z
.string()
.trim()
.min(1, "Nachricht ist erforderlich.")
.max(2000, "Nachricht ist zu lang."),
});
export const POST = withAuth(
async (req) => {
try {
const body = await req.json();
const result = contactSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ message: result.error.issues[0]?.message || "Ungültige Eingabe." },
{ status: 400 }
);
}
const { message } = result.data;
const contactEmail = process.env.CONTACT_EMAIL;
if (!contactEmail) {
return Response.json(
{ message: "Kontakt-E-Mail ist nicht konfiguriert." },
{ status: 500 }
);
}
await sendEmail({
to: contactEmail,
subject: "Kontaktieren Sie uns",
text: message,
});
return Response.json({ message: "Nachricht erfolgreich gesendet." });
} catch (error) {
return Response.json(
{
message:
error instanceof Error
? error.message
: "Nachricht konnte nicht gesendet werden.",
},
{ status: 500 }
);
}
},
{
requireAuth: true,
requireAdmin: false,
}
);
-61
View File
@@ -1,61 +0,0 @@
import { z } from "zod";
import { withAuth } from "@/backend/middleware/withAuth";
import sendEmail from "@/backend/mailService";
const talkbackSchema = z.object({
email: z
.string()
.trim()
.email("Bitte geben Sie eine gültige E-Mail-Adresse des Empfängers ein."),
subject: z
.string()
.trim()
.min(1, "Betreff ist erforderlich.")
.max(200, "Betreff ist zu lang."),
message: z
.string()
.trim()
.min(1, "Nachricht ist erforderlich.")
.max(2000, "Nachricht ist zu lang."),
});
export const POST = withAuth(
async (req) => {
try {
const body = await req.json();
const result = talkbackSchema.safeParse(body);
if (!result.success) {
return Response.json(
{
message: result.error.issues[0]?.message || "Ungültige Eingabe.",
},
{ status: 400 }
);
}
const { email, subject, message } = result.data;
await sendEmail({
to: email,
subject,
text: message,
});
return Response.json({
message: "Nachricht erfolgreich gesendet.",
});
} catch {
return Response.json(
{
message: "Nachricht konnte nicht gesendet werden.",
},
{ status: 500 }
);
}
},
{
requireAuth: true,
requireAdmin: true,
}
);
@@ -12,7 +12,7 @@
border-radius: var(--radius-2xl);
padding: var(--u-xl);
box-shadow: 0 10px 24px rgb(0 0 0 / 10%);
width: 570px;
width: fit-content;
}
.auth-box {
+96
View File
@@ -0,0 +1,96 @@
"use client";
import "./contact.css";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { withBasePath } from "@/shared/utils/basePath";
import { useUser } from "@/context/user.context";
export default function ContactPage() {
const {
auth: { isLoggedIn, isAuthResolved },
} = useUser();
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [isSending, setIsSending] = useState(false);
// Show nothing while session is being resolved, or if not logged in.
// Middleware already redirects unauthenticated requests to /auth/login.
if (!isAuthResolved || !isLoggedIn) return null;
const handleSubmit = async (event) => {
event.preventDefault();
setError("");
const trimmedMessage = message.trim();
if (!trimmedMessage) {
setError("Nachricht ist erforderlich.");
return;
}
try {
setIsSending(true);
const response = await fetch(withBasePath("/api/contact"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: trimmedMessage }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(
data.message || "Nachricht konnte nicht gesendet werden."
);
}
setMessage("");
alert("Nachricht erfolgreich gesendet.");
} catch (requestError) {
setError(requestError.message || "Etwas ist schief gelaufen.");
} finally {
setIsSending(false);
}
};
return (
<section className="talkback-container">
<div className="talkback-box">
<h1 className="auth-title">Kontaktieren Sie unser Team</h1>
{error && <p className="error">{error}</p>}
<form className="talkback-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="contact-message">
Nachricht
</label>
<textarea
className="form-input talkback-message-input"
id="contact-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
required
rows={8}
/>
</div>
<Button
className="form-button"
type="submit"
size="md"
width="full"
disabled={isSending}
>
{isSending ? "Senden..." : "Nachricht senden"}
</Button>
</form>
</div>
</section>
);
}
-9
View File
@@ -1,9 +0,0 @@
import "../auth/auth.css";
export default function AuthLayout({ children }) {
return (
<div className="auth-container">
<div className="auth-box">{children}</div>
</div>
);
}
-156
View File
@@ -1,156 +0,0 @@
"use client";
import "./talkback.css";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { withBasePath } from "@/shared/utils/basePath";
import { useIsAdmin } from "@/context/user.context";
export default function TalkbackPage() {
const isAdmin = useIsAdmin();
const router = useRouter();
useEffect(() => {
if (isAdmin === false) {
router.replace(withBasePath("/"));
}
}, [isAdmin, router]);
if (isAdmin !== true) {
return null;
}
const defaultSubject = "Rückmeldung von GRADESA 2.0";
const [email, setEmail] = useState("");
const [subject, setSubject] = useState(defaultSubject);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [isSending, setIsSending] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
setError("");
const trimmedEmail = email.trim();
const trimmedSubject = subject.trim() || defaultSubject;
const trimmedMessage = message.trim();
if (!trimmedEmail) {
setError("Die E-Mail-Adresse des Empfängers ist erforderlich.");
return;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(trimmedEmail)) {
setError(
"Bitte geben Sie eine gültige E-Mail-Adresse des Empfängers ein."
);
return;
}
if (!trimmedMessage) {
setError("Nachricht ist erforderlich.");
return;
}
try {
setIsSending(true);
const response = await fetch(withBasePath("/api/talkback"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: trimmedEmail,
subject: trimmedSubject,
message: trimmedMessage,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(
data.message || "Nachricht konnte nicht gesendet werden."
);
}
setMessage("");
alert("Feedback erfolgreich gesendet.");
} catch (requestError) {
setError(requestError.message || "Etwas ist schief gelaufen.");
} finally {
setIsSending(false);
}
};
return (
<section className="talkback-container">
<div className="talkback-box">
<h1 className="auth-title">Brauchen Sie Hilfe?</h1>
{error && <p className="error">{error}</p>}
<form className="talkback-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="talkback-email">
E-Mail-Adresse des Empfängers
</label>
<input
className="form-input"
id="talkback-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="talkback-subject">
Betreff
</label>
<input
className="form-input"
id="talkback-subject"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="talkback-message">
Nachricht
</label>
<textarea
className="form-input talkback-message-input"
id="talkback-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
required
rows={8}
/>
</div>
<Button
className="form-button"
type="submit"
size="md"
width="full"
disabled={isSending}
>
{isSending ? "Senden..." : "Nachricht senden"}
</Button>
</form>
</div>
</section>
);
}
+2 -2
View File
@@ -115,8 +115,8 @@ function StudentSideBar() {
<Link className={styles.sidebarLink} href="/glossary">
Glossar
</Link>
<Link className={styles.sidebarLink} href="/talkback">
Brauchen Sie Hilfe?
<Link className={styles.sidebarLink} href="/contact">
Kontaktieren Sie unser Team
</Link>
<SidebarGroup
title="Links zu anderen Webseiten"
+9 -3
View File
@@ -8,7 +8,7 @@ import { AUTH_COOKIE_NAME } from "@/shared/const";
* to the login page when attempting to access these paths).
* @type {string[]}
*/
const authRequired = ["/grammar/exercises", "/edit_info"];
const authRequired = ["/grammar/exercises", "/edit_info", "/contact"];
// Restricted paths logged-in users cannot access
/**
@@ -34,13 +34,19 @@ export async function middleware(request) {
/\/$/,
""
);
const pathnameWithoutBasePath = pathname.startsWith(basePath)
? pathname.slice(basePath.length) || "/"
: pathname;
const sessionCookie = request.cookies.get(AUTH_COOKIE_NAME);
const hasCookie = !!sessionCookie && sessionCookie.value !== "";
// Redirect logged-out users trying to access protected paths
if (!hasCookie && authRequired.some((path) => pathname.startsWith(path))) {
if (
!hasCookie &&
authRequired.some((path) => pathnameWithoutBasePath.startsWith(path))
) {
const loginUrl = new URL(`${basePath}/auth/login`, request.url);
loginUrl.searchParams.set("redirect", pathname); // Add the original path as a query parameter
loginUrl.searchParams.set("redirect", pathnameWithoutBasePath); // Add the original path as a query parameter
return NextResponse.redirect(loginUrl);
}
// Otherwise, allow the request to proceed and prevent caching of the response