Add player bug reports and profile identity features
This commit is contained in:
@@ -7,13 +7,20 @@ import { UserRound, LockKeyhole, Mail } from "lucide-react";
|
||||
import { api } from "./StartGame";
|
||||
import { PasswordForm } from "./PasswordForm";
|
||||
import { displayNameSchema } from "@/shared/account";
|
||||
import {
|
||||
defaultGlowColor,
|
||||
type ProfileAppearance,
|
||||
} from "@/shared/profile-appearance";
|
||||
import { Username } from "./Username";
|
||||
export function AccountSettings({
|
||||
email,
|
||||
displayName,
|
||||
appearance,
|
||||
passwordUpdated = false,
|
||||
}: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
appearance: ProfileAppearance;
|
||||
passwordUpdated?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -25,6 +32,25 @@ export function AccountSettings({
|
||||
[recoveryError, setRecoveryError] = useState(""),
|
||||
[sending, setSending] = useState(false);
|
||||
const router = useRouter();
|
||||
const [color, setColor] = useState(appearance.glow_color ?? defaultGlowColor);
|
||||
const [colorBusy, setColorBusy] = useState(false),
|
||||
[colorError, setColorError] = useState(""),
|
||||
[colorSaved, setColorSaved] = useState(false);
|
||||
async function saveColor(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setColorBusy(true);
|
||||
setColorError("");
|
||||
setColorSaved(false);
|
||||
try {
|
||||
await api("profile", { glowColor: color }, "PATCH");
|
||||
setColorSaved(true);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setColorError((error as Error).message);
|
||||
} finally {
|
||||
setColorBusy(false);
|
||||
}
|
||||
}
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
@@ -119,6 +145,49 @@ export function AccountSettings({
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section className="settings-card glow-settings">
|
||||
<h2>{t("Username glow")}</h2>
|
||||
<p className="muted">
|
||||
{appearance.name_glow
|
||||
? t("Choose the color of your glow.")
|
||||
: t("Support can award a glow for a confirmed bug report.")}
|
||||
</p>
|
||||
<div className="username-preview">
|
||||
<Username
|
||||
name={name}
|
||||
appearance={{ ...appearance, glow_color: color }}
|
||||
/>
|
||||
</div>
|
||||
{appearance.name_glow && (
|
||||
<form onSubmit={saveColor}>
|
||||
<label className="field">
|
||||
{t("Glow color")}
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(event) => {
|
||||
setColor(event.target.value);
|
||||
setColorSaved(false);
|
||||
}}
|
||||
disabled={colorBusy}
|
||||
/>
|
||||
</label>
|
||||
{colorError && (
|
||||
<p role="alert" className="error-message">
|
||||
{t(colorError)}
|
||||
</p>
|
||||
)}
|
||||
{colorSaved && (
|
||||
<p role="status" className="success-message">
|
||||
{t("Glow color saved.")}
|
||||
</p>
|
||||
)}
|
||||
<button className="button primary" disabled={colorBusy}>
|
||||
{colorBusy ? t("Saving…") : t("Save glow color")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
<section className="settings-card">
|
||||
<div className="settings-heading">
|
||||
<LockKeyhole size={22} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
|
||||
import { Username } from "./Username";
|
||||
import {
|
||||
type LeaderboardData,
|
||||
type LeaderboardEntry,
|
||||
@@ -105,7 +106,7 @@ export function LeaderboardTable({
|
||||
<div className="leaderboard-player">
|
||||
<PlayerAvatar id={entry.player_id} />
|
||||
<span className="player-name">
|
||||
{entry.display_name}
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
{entry.player_id === playerId && (
|
||||
<span className="you-label">{t("You")}</span>
|
||||
)}
|
||||
@@ -152,7 +153,9 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
|
||||
})}
|
||||
</span>
|
||||
<PlayerAvatar id={entry.player_id} />
|
||||
<h2>{entry.display_name}</h2>
|
||||
<h2>
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
</h2>
|
||||
<p>
|
||||
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
||||
</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, MapPin } from "lucide-react";
|
||||
import { Username } from "./Username";
|
||||
export type CardMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -11,6 +12,8 @@ export type CardMap = {
|
||||
panorama_count: number;
|
||||
image?: string | null;
|
||||
credit?: string;
|
||||
creditGlow?: boolean;
|
||||
creditAppearance?: import("@/shared/profile-appearance").ProfileAppearance;
|
||||
server?: { name: string } | undefined;
|
||||
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
|
||||
};
|
||||
@@ -51,7 +54,12 @@ export function MapCard({
|
||||
<p>{map.description}</p>
|
||||
{map.credit && (
|
||||
<p className="map-credit">
|
||||
{t("By")} {map.credit}
|
||||
{t("By")}{" "}
|
||||
<Username
|
||||
name={map.credit}
|
||||
appearance={map.creditAppearance}
|
||||
glow={map.creditGlow}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<div className="card-meta">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PanoramaPreview } from "./PanoramaPreview";
|
||||
import { reportCategories } from "@/shared/moderation";
|
||||
import type { Appeal } from "./MapAppeal";
|
||||
import { ModeratorSettings } from "./ModeratorSettings";
|
||||
import { ModeratorPrefixes } from "./ModeratorPrefixes";
|
||||
type ReviewMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -114,6 +115,7 @@ export function Moderation() {
|
||||
return (
|
||||
<>
|
||||
{authorized && <ModeratorSettings />}
|
||||
{authorized && <ModeratorPrefixes />}
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{t(error)}
|
||||
|
||||
218
components/ModeratorPrefixes.tsx
Normal file
218
components/ModeratorPrefixes.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Tags } from "lucide-react";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
import { api } from "@/lib/client-api";
|
||||
import {
|
||||
prefixIcons,
|
||||
prefixUpdateSchema,
|
||||
type BadgeUser,
|
||||
type PrefixIcon,
|
||||
} from "@/shared/profile-appearance";
|
||||
import { Username } from "./Username";
|
||||
|
||||
export function ModeratorPrefixes() {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState(""),
|
||||
[users, setUsers] = useState<BadgeUser[]>([]),
|
||||
[selected, setSelected] = useState<BadgeUser | null>(null);
|
||||
const [prefix, setPrefix] = useState(""),
|
||||
[icon, setIcon] = useState<PrefixIcon>("star"),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState(""),
|
||||
[saved, setSaved] = useState(false),
|
||||
[searched, setSearched] = useState(false);
|
||||
async function find(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSaved(false);
|
||||
setSelected(null);
|
||||
try {
|
||||
const result = await api<{ users: BadgeUser[] }>(
|
||||
"moderation/users?q=" + encodeURIComponent(search),
|
||||
);
|
||||
setUsers(result.users);
|
||||
setSearched(true);
|
||||
} catch (failure) {
|
||||
setError((failure as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function save(remove = false) {
|
||||
if (!selected || busy) return;
|
||||
const body = prefixUpdateSchema.safeParse({
|
||||
prefix: remove ? null : prefix,
|
||||
icon,
|
||||
});
|
||||
if (!body.success) {
|
||||
setError(
|
||||
"Use a prefix of 1–24 characters without hidden controls or markup",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSaved(false);
|
||||
try {
|
||||
const result = await api<{ user: BadgeUser }>(
|
||||
"moderation/users/" + selected.id,
|
||||
body.data,
|
||||
"PATCH",
|
||||
);
|
||||
setSelected(result.user);
|
||||
setPrefix(result.user.custom_prefix ?? "");
|
||||
setUsers((users) =>
|
||||
users.map((user) => (user.id === result.user.id ? result.user : user)),
|
||||
);
|
||||
setSaved(true);
|
||||
} catch (failure) {
|
||||
setError((failure as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const iconLabels = {
|
||||
star: t("Star"),
|
||||
crown: t("Crown"),
|
||||
heart: t("Heart"),
|
||||
flame: t("Flame"),
|
||||
gem: t("Gem"),
|
||||
trophy: t("Trophy"),
|
||||
sparkles: t("Sparkles"),
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className="tool-card moderator-prefixes"
|
||||
aria-labelledby="prefix-heading"
|
||||
>
|
||||
<div className="settings-heading">
|
||||
<Tags size={24} aria-hidden="true" />
|
||||
<div>
|
||||
<h2 id="prefix-heading">{t("Player prefixes")}</h2>
|
||||
<p>{t("Find a player and assign a custom prefix with an icon.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={find} className="prefix-search">
|
||||
<label className="field">
|
||||
{t("Player name or account ID")}
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
maxLength={64}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<button className="button" disabled={busy}>
|
||||
{t("Find players")}
|
||||
</button>
|
||||
</form>
|
||||
{searched && users.length === 0 && (
|
||||
<p className="muted">{t("No players found.")}</p>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<ul className="prefix-player-list">
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-pressed={selected?.id === user.id}
|
||||
onClick={() => {
|
||||
setSelected(user);
|
||||
setPrefix(user.custom_prefix ?? "");
|
||||
setIcon(
|
||||
prefixIcons.includes(user.custom_prefix_icon as PrefixIcon)
|
||||
? (user.custom_prefix_icon as PrefixIcon)
|
||||
: "star",
|
||||
);
|
||||
setSaved(false);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
<Username name={user.display_name} appearance={user} />
|
||||
<small>{user.id}</small>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{selected && (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
className="prefix-editor"
|
||||
>
|
||||
<label className="field">
|
||||
{t("Custom prefix")}
|
||||
<input
|
||||
value={prefix}
|
||||
onChange={(event) => {
|
||||
setPrefix(event.target.value);
|
||||
setSaved(false);
|
||||
}}
|
||||
minLength={1}
|
||||
maxLength={24}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<div className="field">
|
||||
<label htmlFor="custom-prefix-icon">{t("Prefix icon")}</label>
|
||||
<select
|
||||
id="custom-prefix-icon"
|
||||
value={icon}
|
||||
onChange={(event) => {
|
||||
setIcon(event.target.value as PrefixIcon);
|
||||
setSaved(false);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{prefixIcons.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{iconLabels[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="username-preview">
|
||||
<Username
|
||||
name={selected.display_name}
|
||||
appearance={{
|
||||
...selected,
|
||||
custom_prefix: prefix,
|
||||
custom_prefix_icon: icon,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button className="button primary" disabled={busy}>
|
||||
{t("Save prefix")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy || !selected.custom_prefix}
|
||||
onClick={() => void save(true)}
|
||||
>
|
||||
{t("Remove prefix")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{t(error)}
|
||||
</p>
|
||||
)}
|
||||
{saved && (
|
||||
<p role="status" className="success-message">
|
||||
{t("Player prefix saved.")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { Username } from "./Username";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -139,7 +140,7 @@ export function MultiplayerGame({
|
||||
{room.players.map((player) => (
|
||||
<li key={player.id}>
|
||||
<span>
|
||||
{player.name}
|
||||
<Username name={player.name} appearance={player.appearance} />
|
||||
{player.id === room.you && <small> {t("(you)")}</small>}
|
||||
<small>
|
||||
{player.left
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from "./I18nProvider";
|
||||
import { api } from "@/lib/client-api";
|
||||
import { roundClock } from "@/shared/round-clock";
|
||||
import type { RoomView } from "@/shared/multiplayer";
|
||||
import { Username } from "./Username";
|
||||
|
||||
export function MultiplayerRoom({ id }: { id: string }) {
|
||||
const { t, formatNumber } = useI18n();
|
||||
@@ -247,7 +248,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
{player.id === room.hostId && (
|
||||
<Crown size={17} aria-label={t("Room owner")} />
|
||||
)}{" "}
|
||||
{player.name}{" "}
|
||||
<Username
|
||||
name={player.name}
|
||||
appearance={player.appearance}
|
||||
/>{" "}
|
||||
{player.id === room.you && <small>{t("(you)")}</small>}
|
||||
</span>
|
||||
{owner && player.id !== room.you && (
|
||||
@@ -304,7 +308,16 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
<>
|
||||
<p className="lead">
|
||||
<Trophy size={24} aria-hidden="true" />{" "}
|
||||
{winners.map((player) => player.name).join(" & ")} ·{" "}
|
||||
{winners.map((player, index) => (
|
||||
<span key={player.id}>
|
||||
{index > 0 ? " & " : ""}
|
||||
<Username
|
||||
name={player.name}
|
||||
appearance={player.appearance}
|
||||
/>
|
||||
</span>
|
||||
))}{" "}
|
||||
·{" "}
|
||||
{t("{count} points", {
|
||||
count: formatNumber(winners[0]?.total ?? 0),
|
||||
})}
|
||||
@@ -334,7 +347,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
>
|
||||
<td>{player.rank}</td>
|
||||
<td>
|
||||
{player.name}
|
||||
<Username
|
||||
name={player.name}
|
||||
appearance={player.appearance}
|
||||
/>
|
||||
{player.left && <small> · {t("Left room")}</small>}
|
||||
</td>
|
||||
{room.rounds.map((turn) => (
|
||||
|
||||
19
components/ReportBugButton.tsx
Normal file
19
components/ReportBugButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Bug } from "lucide-react";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
export function ReportBugButton() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Link
|
||||
className="header-report"
|
||||
href={`/support?from=${encodeURIComponent(pathname)}`}
|
||||
aria-label={t("Report a bug")}
|
||||
>
|
||||
<Bug size={18} aria-hidden="true" />
|
||||
<span>{t("Report a bug")}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { Username } from "./Username";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MessageCircle, Send, X } from "lucide-react";
|
||||
@@ -197,7 +198,7 @@ export function RoomChat({
|
||||
className={message.playerId === playerId ? "room-chat-mine" : ""}
|
||||
>
|
||||
<strong>
|
||||
{message.name}
|
||||
<Username name={message.name} appearance={message.appearance} />
|
||||
{message.playerId === playerId && ` ${t("(you)")}`}
|
||||
</strong>
|
||||
<span>{message.text}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { user } from "@/lib/supabase";
|
||||
import { Brand } from "./Brand";
|
||||
import { SiteNav } from "./SiteNav";
|
||||
import { ReportBugButton } from "./ReportBugButton";
|
||||
export async function Header() {
|
||||
const { t } = await getI18n();
|
||||
const account = await user();
|
||||
@@ -14,12 +15,15 @@ export async function Header() {
|
||||
<div className="header-inner">
|
||||
<Brand />
|
||||
<SiteNav />
|
||||
<Link
|
||||
href={account ? "/dashboard" : "/log-in"}
|
||||
className="header-account"
|
||||
>
|
||||
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
|
||||
</Link>
|
||||
<div className="header-actions">
|
||||
<ReportBugButton />
|
||||
<Link
|
||||
href={account ? "/dashboard" : "/log-in"}
|
||||
className="header-account"
|
||||
>
|
||||
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
77
components/Username.tsx
Normal file
77
components/Username.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
import {
|
||||
Bug,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Crown,
|
||||
Heart,
|
||||
Flame,
|
||||
Gem,
|
||||
Trophy,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
import { glowColor, type ProfileAppearance } from "@/shared/profile-appearance";
|
||||
export const badgeIcons = {
|
||||
star: Star,
|
||||
crown: Crown,
|
||||
heart: Heart,
|
||||
flame: Flame,
|
||||
gem: Gem,
|
||||
trophy: Trophy,
|
||||
sparkles: Sparkles,
|
||||
};
|
||||
export function Username({
|
||||
name,
|
||||
glow,
|
||||
appearance = {},
|
||||
}: {
|
||||
name: string;
|
||||
glow?: boolean;
|
||||
appearance?: ProfileAppearance;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const color = glowColor(appearance.glow_color),
|
||||
enabled = glow ?? appearance.name_glow;
|
||||
const Icon =
|
||||
badgeIcons[appearance.custom_prefix_icon as keyof typeof badgeIcons] ??
|
||||
Star;
|
||||
return (
|
||||
<span className="username">
|
||||
{appearance.role === "moderator" && (
|
||||
<span
|
||||
className="username-prefix username-moderator"
|
||||
title={t("Moderator")}
|
||||
>
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
{t("Moderator")}
|
||||
</span>
|
||||
)}
|
||||
{appearance.bug_finder && (
|
||||
<span
|
||||
className="username-prefix username-bug-finder"
|
||||
title={t("Bug finder")}
|
||||
>
|
||||
<Bug size={13} aria-hidden="true" />
|
||||
{t("Bug finder")}
|
||||
</span>
|
||||
)}
|
||||
{appearance.custom_prefix && (
|
||||
<span className="username-prefix username-custom-prefix">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
{appearance.custom_prefix}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={enabled ? "username-glow" : undefined}
|
||||
style={
|
||||
enabled
|
||||
? { color, textShadow: `0 0 8px ${color}90, 0 0 18px ${color}60` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user