Files
HealthLog/src/app/bugreport/page.tsx
T
Marc-André Bombeck 3b480b9885 perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
2026-06-11 02:19:27 +02:00

248 lines
8.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, CheckCircle2, Bug, GitPullRequest, Info } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
interface BugReportStatus {
configured: boolean;
enabled: boolean;
isAdmin: boolean;
}
const CATEGORIES = [
{ value: "BUG", labelKey: "bugreport.categoryBug" },
{ value: "FEATURE_REQUEST", labelKey: "bugreport.categoryFeature" },
{ value: "QUESTION", labelKey: "bugreport.categoryQuestion" },
{ value: "OTHER", labelKey: "bugreport.categoryOther" },
] as const;
type CategoryValue = (typeof CATEGORIES)[number]["value"];
export default function BugReportPage() {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const [category, setCategory] = useState<CategoryValue>("BUG");
const [subject, setSubject] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
// Status is now informational only — submission always works.
const { data: status } = useQuery({
queryKey: queryKeys.bugreportStatus(),
queryFn: async () => {
return apiGet<BugReportStatus>("/api/bugreport/status");
},
enabled: isAuthenticated,
});
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
setLoading(true);
try {
await apiPost("/api/feedback", {
category,
subject: subject || description.slice(0, 60),
description,
metadata: {
locale,
userAgent:
typeof navigator !== "undefined" ? navigator.userAgent : null,
url: typeof window !== "undefined" ? window.location.pathname : null,
},
});
setResult({ type: "success", message: t("bugreport.success") });
setSubject("");
setDescription("");
} catch (err) {
setResult({
type: "error",
message:
err instanceof ApiError
? err.message || t("bugreport.errorCreating")
: t("common.networkError"),
});
} finally {
setLoading(false);
}
}
if (!isAuthenticated) {
return (
<div className="w-full space-y-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("bugreport.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("bugreport.loginRequired")}
</p>
</div>
);
}
if (status && status.enabled === false) {
return (
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("bugreport.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("bugreport.subtitle")}
</p>
</div>
<div className="bg-card border-border border-l-warning flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-warning mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="text-foreground font-medium">
{t("bugreport.disabledTitle")}
</p>
<p className="text-muted-foreground mt-1">
{t("bugreport.disabledBody")}
</p>
</div>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("bugreport.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("bugreport.subtitle")}
</p>
</div>
{status?.configured && (
<div className="bg-card border-border border-l-info flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<GitPullRequest className="text-info mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("bugreport.githubEscalationNote")}
</p>
</div>
)}
{!status?.configured && (
<div className="bg-card border-border border-l-dose-accent flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-dose-accent mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("bugreport.internalOnlyNote")}
</p>
</div>
)}
<div className="bg-card border-border w-full rounded-xl border p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[200px_1fr]">
<div className="space-y-2">
<Label htmlFor="category">{t("bugreport.category")}</Label>
<Select
value={category}
onValueChange={(v) => setCategory(v as CategoryValue)}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((c) => (
<SelectItem key={c.value} value={c.value}>
{t(c.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="subject">{t("bugreport.bugTitle")}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t("bugreport.bugTitlePlaceholder")}
maxLength={200}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bug-desc">{t("bugreport.description")}</Label>
<Textarea
id="bug-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
minLength={10}
maxLength={5000}
rows={8}
/>
</div>
{result && (
<div
className={`rounded-lg p-3 text-sm ${
result.type === "success"
? "bg-success/10 text-success"
: "bg-destructive/10 text-destructive"
}`}
>
{result.type === "success" && (
<CheckCircle2 className="mr-2 inline h-4 w-4" />
)}
{result.message}
</div>
)}
<div className="flex items-center justify-end pt-2">
<Button
type="submit"
disabled={loading}
aria-busy={loading || undefined}
>
{/*
v1.4.33 IW9 — swap the icon on loading (Loader2 takes
the same 16x16 slot as the Bug icon) so the button
width is stable across the disabled flip. The previous
`{loading && <Loader2/>}<Bug/>` rendered both icons
during the in-flight request and grew the button by
~24 px — the CLS regression flagged in the v1.4.33
Lighthouse punch list (0.186 on
`div.space-y-2 > Button > Loader-Circle`).
*/}
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Bug className="h-4 w-4" />
)}
{t("bugreport.submit")}
</Button>
</div>
</form>
</div>
</div>
);
}