Files
MineGeo/components/Support.tsx

510 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}