Add player bug reports and profile identity features
This commit is contained in:
465
components/Support.tsx
Normal file
465
components/Support.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user