Add player bug reports and profile identity features

This commit is contained in:
2026-10-08 18:27:20 +05:00
parent 36e8d11eb4
commit 0b08672f4a
51 changed files with 3938 additions and 45 deletions

465
components/Support.tsx Normal file
View File

@@ -0,0 +1,465 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Bug, ImagePlus, Send, Sparkles } from "lucide-react";
import { api } from "@/lib/client-api";
import {
bugReportSchema,
bugMessageSchema,
screenshotLimit,
type BugReportView,
} from "@/shared/bug-reports";
import { useI18n } from "./I18nProvider";
import { Username } from "./Username";
type Inbox = {
ready: boolean;
signedIn: boolean;
displayName: string | null;
nameGlow: boolean;
reports: {
id: string;
title: string;
state: string;
created_at: string;
linear_identifier: string | null;
}[];
};
export function SupportInbox({ pagePath }: { pagePath: string }) {
const { t, formatDate } = useI18n(),
router = useRouter();
const [inbox, setInbox] = useState<Inbox | null>(null),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
const [title, setTitle] = useState(""),
[description, setDescription] = useState(""),
[name, setName] = useState(""),
[file, setFile] = useState<File | null>(null);
const nonce = useRef<string | null>(null);
useEffect(() => {
let stopped = false;
api<Inbox>("bug-reports")
.then((value) => {
if (!stopped) setInbox(value);
})
.catch((failure) => {
if (!stopped) setError(failure.message);
});
return () => {
stopped = true;
};
}, []);
async function submit() {
if (busy) return;
const clientId = (nonce.current ??= crypto.randomUUID());
const body = {
clientId,
title,
description,
pagePath,
displayName: inbox?.signedIn ? undefined : name,
screenshot: !!file,
};
if (!bugReportSchema.safeParse(body).success) {
setError(
"Add a short title, at least 15 characters describing the bug, and your player name.",
);
return;
}
if (
file &&
(!["image/png", "image/jpeg", "image/webp"].includes(file.type) ||
file.size > screenshotLimit)
) {
setError("Choose a PNG, JPG, or WebP screenshot smaller than 5 MB");
return;
}
setBusy(true);
setError("");
try {
const report = await api<{
id: string;
state: string;
upload: string | null;
}>("bug-reports", body);
if (report.upload && file) {
const response = await fetch(report.upload, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
signal: AbortSignal.timeout(60_000),
});
if (!response.ok)
throw new Error("Screenshot upload failed. Please try again.");
}
await api(`bug-reports/${report.id}/submit`, {});
router.push(`/support/${report.id}`);
} catch (failure) {
setError((failure as Error).message);
setBusy(false);
}
}
return (
<div className="support-layout">
<section className="support-form">
<span className="eyebrow">
<Bug size={16} />
{t("PLAYER SUPPORT")}
</span>
<h1>{t("Found a bug?")}</h1>
<p className="lead">{t("Tell us what happened. We’ll reply here.")}</p>
{!inbox?.signedIn && (
<p className="muted">
{t(
"Guest reports stay in this browser. Sign in to keep your conversation in your account.",
)}{" "}
<Link href="/log-in?next=%2Fsupport">{t("Log in")}</Link>
</p>
)}
{error && (
<p className="error-message" role="alert">
{t(error)}
</p>
)}
{inbox && !inbox.ready && (
<p role="status">
{t(
"Bug reporting is temporarily unavailable. Please try again later.",
)}
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{!inbox?.signedIn && (
<>
<label htmlFor="report-name">{t("Your player name")}</label>
<input
id="report-name"
value={name}
onChange={(event) => {
nonce.current = null;
setName(event.target.value);
}}
required
minLength={2}
maxLength={32}
autoComplete="nickname"
disabled={busy}
/>
</>
)}
<label htmlFor="bug-title">{t("Bug title")}</label>
<input
id="bug-title"
value={title}
onChange={(event) => {
nonce.current = null;
setTitle(event.target.value);
}}
minLength={5}
maxLength={120}
required
disabled={busy}
/>
<label htmlFor="bug-description">{t("What happened?")}</label>
<textarea
id="bug-description"
value={description}
onChange={(event) => {
nonce.current = null;
setDescription(event.target.value);
}}
minLength={15}
maxLength={6000}
required
rows={7}
placeholder={t(
"What did you do, what did you expect, and what happened instead?",
)}
disabled={busy}
/>
<label htmlFor="bug-screenshot">
<ImagePlus size={16} /> {t("Screenshot (optional)")}
</label>
<input
id="bug-screenshot"
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={busy}
onChange={(event) => {
nonce.current = null;
setFile(event.target.files?.[0] ?? null);
}}
/>
<small>
{t("PNG, JPG, or WebP, up to 5 MB. Shared privately with support.")}
</small>
<button className="button primary" disabled={busy || !inbox?.ready}>
<Send size={17} />
{busy ? t("Sending…") : t("Send bug report")}
</button>
</form>
</section>
<section className="support-inbox">
<h2>{t("Your bug reports")}</h2>
{!inbox ? (
<p className="muted">{t("Loading…")}</p>
) : inbox.reports.length === 0 ? (
<p className="muted">
{t("Your reports and support replies will appear here.")}
</p>
) : (
<ul>
{inbox.reports.map((report) => (
<li key={report.id}>
<Link href={`/support/${report.id}`}>
<strong>{report.title}</strong>
<small>
{formatDate(report.created_at)} ·{" "}
{report.linear_identifier ??
t(
report.state === "draft"
? "Draft"
: "Waiting for support",
)}
</small>
</Link>
</li>
))}
</ul>
)}
</section>
</div>
);
}
export function SupportConversation({ id }: { id: string }) {
const { t, formatDate } = useI18n();
const [report, setReport] = useState<BugReportView | null>(null),
[error, setError] = useState(""),
[text, setText] = useState(""),
[busy, setBusy] = useState(false);
const pending = useRef<{ clientId: string; text: string } | null>(null),
version = useRef(0),
acting = useRef(false);
const load = useCallback(async () => {
if (acting.current) return;
const current = ++version.current;
try {
const value = await api<BugReportView>(`bug-reports/${id}`);
if (current === version.current) {
setReport(value);
setError("");
}
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
}
}, [id]);
useEffect(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
await load();
if (!stopped) timer = setTimeout(poll, document.hidden ? 30_000 : 10_000);
};
void poll();
return () => {
stopped = true;
version.current++;
clearTimeout(timer);
};
}, [load]);
async function action(kind: string, body: object) {
if (acting.current) return;
acting.current = true;
setBusy(true);
setError("");
const current = ++version.current;
try {
const value = await api<BugReportView>(`bug-reports/${id}/${kind}`, body);
if (current === version.current) {
setReport(value);
if (kind === "messages") {
setText("");
pending.current = null;
}
}
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
} finally {
acting.current = false;
setBusy(false);
}
}
async function send() {
const draft = text.trim();
const body =
pending.current?.text === draft
? pending.current
: { text: draft, clientId: crypto.randomUUID() };
if (!bugMessageSchema.safeParse(body).success) {
setError("Use 1–6000 characters without hidden controls");
return;
}
pending.current = body;
await action("messages", body);
}
return (
<div className="support-conversation">
<Link className="text-link" href="/support">
{t("← Your bug reports")}
</Link>
{error && (
<p className="error-message" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</p>
)}
{!report ? (
<p>{t("Loading…")}</p>
) : (
<>
<span className="eyebrow">
{t("PLAYER SUPPORT")} ·{" "}
{report.linear_identifier ?? t("Waiting for support")}
</span>
<h1>{report.title}</h1>
{report.state === "pending" && (
<p role="status">
{t("Your report is saved. We’re connecting it to support.")}
</p>
)}
{report.state === "draft" && (
<>
<p>
{t(
"This report is not sent yet. Return to the form or finish sending it.",
)}
</p>
<button
className="button primary"
disabled={busy}
onClick={() => void action("submit", {})}
>
{t("Finish sending report")}
</button>
</>
)}
{report.canClaim && (
<button
className="button"
disabled={busy}
onClick={() => void action("claim", {})}
>
{t("Keep this report in my account")}
</button>
)}
{report.glow_requested && (
<p className="support-reward">
<Sparkles size={18} />
{t("Support awarded you a username glow.")}{" "}
{!report.canClaim && (
<Link
href={
report.isGuest
? `/log-in?next=${encodeURIComponent(`/support/${id}`)}`
: "/settings"
}
>
{report.isGuest ? t("Log in") : t("Account settings")}
</Link>
)}
{report.isGuest && !report.canClaim && (
<small>
{t(
"For guest reports, sign in and link this report to receive your reward.",
)}
</small>
)}
</p>
)}
{report.screenshot && (
<a href={report.screenshot} target="_blank" rel="noreferrer">
<img
className="support-screenshot"
src={report.screenshot}
alt={t("Attached bug screenshot")}
/>
</a>
)}
<div
className="support-messages"
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label={t("Support conversation")}
>
{report.messages.map((message) => (
<article
key={message.id}
className={`support-message support-message-${message.kind}`}
>
<header>
<strong>
{message.kind === "user" ? (
<Username
name={message.author_name}
appearance={report.authorAppearance}
/>
) : (
t("Support")
)}
</strong>
<small>{formatDate(message.created_at)}</small>
</header>
<p>
{message.kind === "system" ? t(message.body) : message.body}
</p>
{message.kind === "user" && (
<small>
{message.delivered
? t("Delivered to support")
: t("Saved · waiting to send")}
</small>
)}
</article>
))}
</div>
{report.state !== "draft" && (
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<label htmlFor="support-message">{t("Message support")}</label>
<textarea
id="support-message"
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={6000}
rows={3}
required
disabled={busy}
/>
<button
className="button primary"
disabled={busy || !text.trim()}
>
<Send size={16} />
{t("Send message")}
</button>
</form>
)}
</>
)}
</div>
);
}