Feedback page

This commit is contained in:
2026-05-27 18:30:05 +03:00
parent fe34408b13
commit ea4747ec16
11 changed files with 321 additions and 45 deletions

View File

@@ -241,3 +241,10 @@ export async function deleteUser(username: string): Promise<void> {
},
});
}
export async function submitFeedback(message: string): Promise<void> {
await fetchApi<{ message: string }>("/feedback", {
method: "POST",
body: JSON.stringify({ message }),
});
}

View File

@@ -6,7 +6,6 @@
}
#root {
user-select: none;
}
main {

View File

@@ -4,6 +4,7 @@ import { useRecoilValue, useSetRecoilState } from "recoil";
import Nav from "~/components/Nav";
import Home from "~/routes/index";
import About from "~/routes/about";
import Feedback from "~/routes/feedback";
import Login from "~/routes/login";
import Management from "~/routes/management";
import NotFound from "~/routes/[...404]";
@@ -53,6 +54,7 @@ function AppShell() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/feedback" element={<Feedback />} />
<Route path="/login" element={<Login />} />
<Route
path="/management"

View File

@@ -11,6 +11,8 @@ export default function Nav() {
const { language, setLanguage } = useLanguage();
const [session, setSession] = useRecoilState(sessionAtom);
const isFeedbackPage = location.pathname === "/feedback";
const signOut = () => {
setSession(null);
localStorage.removeItem("session-username");
@@ -28,68 +30,84 @@ export default function Nav() {
}`;
return (
<nav className="fixed top-0 left-0 z-50 w-full bg-[#70421E] shadow-sm">
<nav className={`fixed top-0 left-0 z-50 w-full shadow-sm ${isFeedbackPage ? "bg-[#2D6A4F]" : "bg-[#70421E]"}`}>
<div className="flex flex-col sm:flex-row sm:items-center px-4 py-2 text-sm font-medium">
{/* Links — bottom on mobile (order-2), left on desktop (sm:order-1) */}
<div className="order-2 sm:order-1 flex items-center justify-center sm:justify-start">
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
{hasAnyRole(session, [APP_ROLES.admin]) ? (
<Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
) : null}
</div>
{/* Links — hidden on feedback page */}
{!isFeedbackPage && (
<div className="order-2 sm:order-1 flex items-center justify-center sm:justify-start">
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
{hasAnyRole(session, [APP_ROLES.admin]) ? (
<Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
) : null}
</div>
)}
{/* Centered title — shown only on feedback page */}
{isFeedbackPage && (
<div className="order-2 sm:order-1 flex-1 flex items-center justify-center sm:absolute sm:left-1/2 sm:-translate-x-1/2">
<span className="text-[#D8F3DC] font-semibold tracking-wide">{t("nav.feedbackTitle")}</span>
</div>
)}
{/* Controls — top on mobile (order-1), right on desktop (sm:order-2 sm:ml-auto) */}
<div className="order-1 sm:order-2 sm:ml-auto flex items-center justify-center gap-2">
<b className="hidden sm:block text-[#F5D1A9]">{session?.displayName ?? ""}</b>
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
{!isFeedbackPage && (
<b className="hidden sm:block text-[#F5D1A9]">{session?.displayName ?? ""}</b>
)}
<div className={`flex items-center gap-1 rounded-md border p-1 ${isFeedbackPage ? "border-[#52B788] bg-[#52B788]/30" : "border-[#8E4F24] bg-[#8E4F24]/45"}`}>
<button
type="button"
onClick={() => setLanguage("fi")}
className={`rounded px-2 py-1 text-xs ${language === "fi"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
className={`rounded px-2 py-1 text-xs ${
language === "fi"
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.fi")}
</button>
<button
type="button"
onClick={() => setLanguage("en")}
className={`rounded px-2 py-1 text-xs ${language === "en"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
className={`rounded px-2 py-1 text-xs ${
language === "en"
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.en")}
</button>
<button
type="button"
onClick={() => setLanguage("sk")}
className={`rounded px-2 py-1 text-xs ${language === "sk"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
className={`rounded px-2 py-1 text-xs ${
language === "sk"
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.sk")}
</button>
</div>
{session ? (
<button
type="button"
onClick={signOut}
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.signOut")}
</button>
) : (
<Link
to="/login"
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.login")}
</Link>
{!isFeedbackPage && (
session ? (
<button
type="button"
onClick={signOut}
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.signOut")}
</button>
) : (
<Link
to="/login"
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.login")}
</Link>
)
)}
</div>
</div>

View File

@@ -14,7 +14,8 @@ const translations = {
"nav.language.fi": "FI",
"nav.language.en": "EN",
"nav.language.sk": "SK",
"meta.description": "React + Recoil example",
"nav.feedback": "Feedback",
"nav.feedbackTitle": "Livonsaari volunteering project",
"home.title": "Home",
"home.heading": "Klapi",
"home.subheading": "Livonsaaren Tietokonepaja's administration console",
@@ -93,6 +94,18 @@ const translations = {
"error.title": "Error",
"errors.requiredUsernamePassword": "Username and password are required",
"errors.invalidUsernameOrPassword": "Invalid username or password",
"feedback.title": "Feedback",
"feedback.heading": "Send feedback",
"feedback.description":
"Have something to tell to Livonsaari volunteering project anonymously? Let us know in the message below.",
"feedback.label": "Your message",
"feedback.placeholder": "Write your feedback here...",
"feedback.submit": "Send",
"feedback.sending": "Sending...",
"feedback.success": "Thank you! Your feedback has been sent.",
"feedback.error": "Something went wrong. Please try again.",
"feedback.tooLong": "Message is too long (max 5000 characters).",
"feedback.required": "Please write something before submitting.",
},
fi: {
"nav.home": "Etusivu",
@@ -103,7 +116,8 @@ const translations = {
"nav.language.fi": "FI",
"nav.language.en": "EN",
"nav.language.sk": "SK",
"meta.description": "React + Recoil -esimerkki",
"nav.feedback": "Palaute",
"nav.feedbackTitle": "Livonsaaren vapaaehtoisprojekti",
"home.title": "Etusivu",
"home.heading": "Klapi",
"home.subheading": "Livonsaaren Tietokonepajan hallintakonsoli",
@@ -183,6 +197,18 @@ const translations = {
"errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan",
"errors.invalidUsernameOrPassword":
"Virheellinen käyttäjätunnus tai salasana",
"feedback.title": "Palaute",
"feedback.heading": "Lähetä palautetta",
"feedback.description":
"Haluatko kertoa jotain Livonsaaren vapaaehtoisprojektin vetäjille nimettömästi? Jätä meille viesti.",
"feedback.label": "Viestisi",
"feedback.placeholder": "Kirjoita palautteesi tähän...",
"feedback.submit": "Lähetä",
"feedback.sending": "Lähetetään...",
"feedback.success": "Kiitos! Palautteesi on lähetetty.",
"feedback.error": "Jotain meni pieleen. Yritä uudelleen.",
"feedback.tooLong": "Viesti on liian pitkä (max 5000 merkkiä).",
"feedback.required": "Kirjoita jotain ennen lähettämistä.",
},
sk: {
"nav.home": "Domov",
@@ -193,7 +219,8 @@ const translations = {
"nav.language.fi": "FI",
"nav.language.en": "EN",
"nav.language.sk": "SK",
"meta.description": "Ukážka React + Recoil",
"nav.feedback": "Spätná väzba",
"nav.feedbackTitle": "Dobrovoľnícky projekt Livonsaari",
"home.title": "Domov",
"home.heading": "Klapi",
"home.subheading": "Administrátorská konzola Livonsaaren Tietokonepaja",
@@ -274,6 +301,18 @@ const translations = {
"errors.requiredUsernamePassword": "Používateľské meno a heslo sú povinné",
"errors.invalidUsernameOrPassword":
"Neplatné používateľské meno alebo heslo",
"feedback.title": "Spätná väzba",
"feedback.heading": "Odoslať spätnú väzbu",
"feedback.description":
"Chcete niečo anonymne odkázať dobrovoľníckemu projektu Livonsaari? Zanechajte nám správu.",
"feedback.label": "Vaša správa",
"feedback.placeholder": "Tu napíšte svoju spätnú väzbu...",
"feedback.submit": "Odoslať",
"feedback.sending": "Odosiela sa...",
"feedback.success": "Ďakujeme! Vaša spätná väzba bola odoslaná.",
"feedback.error": "Niečo sa pokazilo. Skúste to znova.",
"feedback.tooLong": "Správa je príliš dlhá (max 5000 znakov).",
"feedback.required": "Pred odoslaním niečo napíšte.",
},
} as const;

View File

@@ -0,0 +1,97 @@
import { FormEvent, useEffect, useState } from "react";
import { submitFeedback } from "~/api";
import { useT } from "~/i18n";
export default function Feedback() {
const t = useT();
const [message, setMessage] = useState("");
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
const [validationError, setValidationError] = useState("");
useEffect(() => {
document.title = t("feedback.title");
}, [t]);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setValidationError("");
if (!message.trim()) {
setValidationError(t("feedback.required"));
return;
}
if (message.trim().length > 5000) {
setValidationError(t("feedback.tooLong"));
return;
}
setStatus("sending");
try {
await submitFeedback(message.trim());
setStatus("success");
setMessage("");
} catch {
setStatus("error");
}
};
return (
<main className="!bg-[#D8EDDA] !text-[#1A3A2A] px-4 py-8">
<div className="mx-auto max-w-lg space-y-6">
<div className="space-y-2 text-center">
<h1 className="!text-[#1B4332] text-2xl font-bold">{t("feedback.heading")}</h1>
<p className="text-sm text-[#2D6A4F]/80">{t("feedback.description")}</p>
</div>
{status === "success" ? (
<div className="rounded-md border border-[#74C69D] bg-[#EBF7EF] p-4 text-center text-sm font-medium text-[#1B4332]">
{t("feedback.success")}
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label
htmlFor="feedback-message"
className="block text-sm font-medium text-[#1B4332]"
>
{t("feedback.label")}
</label>
<textarea
id="feedback-message"
autoFocus
value={message}
onChange={(e) => {
setMessage(e.target.value);
setValidationError("");
if (status === "error") setStatus("idle");
}}
placeholder={t("feedback.placeholder")}
rows={6}
maxLength={5000}
className="w-full rounded-md border border-[#74C69D] bg-[#EBF7EF] px-3 py-2 text-sm text-[#1A3A2A] placeholder:text-[#74C69D] focus:border-[#2D6A4F] focus:outline-none focus:ring-1 focus:ring-[#2D6A4F] disabled:opacity-60"
disabled={status === "sending"}
/>
{validationError && (
<p className="text-xs text-red-600">{validationError}</p>
)}
{status === "error" && (
<p className="text-xs text-red-600">{t("feedback.error")}</p>
)}
<p className="text-right text-xs text-[#2D6A4F]/50">{message.length} / 5000</p>
</div>
<button
type="submit"
disabled={status === "sending"}
className="w-full rounded-md bg-[#2D6A4F] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#1B4332] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] focus-visible:ring-offset-2 disabled:opacity-60"
>
{status === "sending" ? t("feedback.sending") : t("feedback.submit")}
</button>
</form>
)}
</div>
</main>
);
}