510 lines
16 KiB
TypeScript
510 lines
16 KiB
TypeScript
"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}
|
||
{report.state === "done" && <> · {t("Done")}</>}
|
||
</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 === "done" && (
|
||
<div role="status">
|
||
<p>
|
||
<strong>{t("Done")}</strong> ·{" "}
|
||
{t(
|
||
"This report is closed and will be deleted from MineGeo and Linear after three days.",
|
||
)}
|
||
</p>
|
||
{report.delete_after && (
|
||
<p>
|
||
{t("Scheduled deletion")}: {formatDate(report.delete_after)}
|
||
</p>
|
||
)}
|
||
{!report.linear_done_synced && (
|
||
<p>{t("Syncing Done status with Linear…")}</p>
|
||
)}
|
||
{report.cleanup_error && (
|
||
<p className="error-message">
|
||
{t(
|
||
"Support cleanup paused. The support team may need to update Linear permissions.",
|
||
)}{" "}
|
||
<button
|
||
className="text-link"
|
||
disabled={busy}
|
||
onClick={() => void action("retry-cleanup", {})}
|
||
>
|
||
{t("Retry")}
|
||
</button>
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
{report.state === "open" && (
|
||
<button
|
||
className="button"
|
||
disabled={busy}
|
||
onClick={() => void action("close", {})}
|
||
>
|
||
{t("Close report")}
|
||
</button>
|
||
)}
|
||
{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" && report.state !== "done" && (
|
||
<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>
|
||
);
|
||
}
|