Feedback page
This commit is contained in:
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
}
|
||||
|
||||
#root {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
main {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
97
ui/src/routes/feedback.tsx
Normal file
97
ui/src/routes/feedback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user