Generate, list, and cancel attended-support codes (XXX-XXX-XXX), built on the v2 codes API and existing UI primitives. - Codes table: code in mono, status badge (pending+pulse/connected/ completed/cancelled), bound client/machine, created-by, created (relative + absolute tooltip). Sticky header, skeleton load, actionable empty/error states. - Generate opens a focused reveal modal showing the code large in JetBrains Mono with copy and a read-aloud instruction; the code is announced character-by-character for screen readers. Mint is ref- guarded so it creates exactly one code per open (no StrictMode dupe). - Cancel via confirm dialog (POST /api/codes/:code/cancel), disabled for non-cancellable statuses; invalidates the codes query. List polls 7s. - Shared API client now tolerates non-JSON 200 bodies, so the cancel endpoint's plain-text "Code cancelled" success no longer surfaces as a failure. Error-envelope handling unchanged. Passed Code Review (no blockers after fixes) and local gates (tsc/lint/build green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
5.0 KiB
TypeScript
154 lines
5.0 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { ApiError } from "../../api/client";
|
|
import type { SupportCode } from "../../api/types";
|
|
import { Button } from "../../components/ui/Button";
|
|
import { Modal } from "../../components/ui/Modal";
|
|
import { Spinner } from "../../components/ui/Spinner";
|
|
import { ErrorState } from "../../components/ui/States";
|
|
import { CopyIcon } from "../../components/layout/icons";
|
|
import { useClipboard } from "../../lib/useClipboard";
|
|
import { useGenerateCode } from "./hooks";
|
|
import "./codes.css";
|
|
|
|
interface GenerateCodeModalProps {
|
|
/** Whether the generate dialog is open. */
|
|
open: boolean;
|
|
/** Operator name to attribute the code to (server stamps `created_by`). */
|
|
technicianName?: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/**
|
|
* Generate-a-code flow. Opening the dialog mints a fresh code immediately, then
|
|
* reveals it large in JetBrains Mono so the tech can read it to the end user
|
|
* over the phone. The code is the single high-signal element on this surface;
|
|
* everything else is secondary. There is no per-second countdown — the
|
|
* SupportCode the API returns has no `expires_at`, and a redeem/cancel surfaces
|
|
* through the table's poll, so a timer here would be both impossible to source
|
|
* accurately and a needless render storm.
|
|
*/
|
|
export function GenerateCodeModal({
|
|
open,
|
|
technicianName,
|
|
onClose,
|
|
}: GenerateCodeModalProps) {
|
|
const generate = useGenerateCode();
|
|
const { copied, copy } = useClipboard();
|
|
const [result, setResult] = useState<SupportCode | null>(null);
|
|
// Minting a code is a durable single-use side effect. Guard it behind a ref so
|
|
// StrictMode's mount->cleanup->mount double-invoke can't fire two real POSTs
|
|
// per open; re-arm on close so the next open mints fresh.
|
|
const mintedFor = useRef(false);
|
|
|
|
// Mint once when the dialog opens; reset on close so a re-open mints a fresh
|
|
// code. Minting in an effect (not on a button click) lets the dialog own the
|
|
// loading/error/success states cleanly, mirroring JoinSessionModal.
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setResult(null);
|
|
generate.reset();
|
|
mintedFor.current = false; // re-arm for the next open
|
|
return;
|
|
}
|
|
if (mintedFor.current) return; // StrictMode remount: already minted
|
|
mintedFor.current = true;
|
|
let cancelled = false;
|
|
generate
|
|
.mutateAsync({ technician_name: technicianName })
|
|
.then((res) => {
|
|
if (!cancelled) setResult(res);
|
|
})
|
|
.catch(() => {
|
|
// Surfaced via generate.isError below.
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// Mint exactly once per open. The mutation object is stable.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open]);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
title="Support code"
|
|
onClose={onClose}
|
|
footer={
|
|
<Button variant="primary" onClick={onClose}>
|
|
Done
|
|
</Button>
|
|
}
|
|
>
|
|
{generate.isPending && !result ? (
|
|
<div className="codegen__loading">
|
|
<Spinner label="Generating code…" />
|
|
</div>
|
|
) : generate.isError ? (
|
|
<ErrorState
|
|
title="Could not generate a code"
|
|
message={
|
|
generate.error instanceof ApiError
|
|
? generate.error.message
|
|
: "The relay did not return a code. Check the relay status, then try again."
|
|
}
|
|
action={
|
|
<Button
|
|
variant="primary"
|
|
onClick={() =>
|
|
void generate
|
|
.mutateAsync({ technician_name: technicianName })
|
|
.then(setResult)
|
|
.catch(() => {})
|
|
}
|
|
>
|
|
Try again
|
|
</Button>
|
|
}
|
|
/>
|
|
) : result ? (
|
|
<>
|
|
<p className="codegen__lede">
|
|
Read this code to the end user. It starts an attended support session
|
|
and can be used once.
|
|
</p>
|
|
|
|
<div className="codegen__codewrap">
|
|
<output className="codegen__code" aria-label={`Support code ${spell(result.code)}`}>
|
|
{result.code}
|
|
</output>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void copy(result.code)}
|
|
aria-label={copied ? "Code copied to clipboard" : "Copy code to clipboard"}
|
|
>
|
|
<CopyIcon width={14} height={14} />
|
|
{copied ? "Copied" : "Copy"}
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="codegen__hint">
|
|
It stays active until the user redeems it or you cancel it. Once
|
|
redeemed it cannot be used again.
|
|
</p>
|
|
</>
|
|
) : null}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Spell a grouped code out for the screen-reader label so it is announced
|
|
* character by character ("K, 7, P, ...") instead of as a mangled word. The
|
|
* visible code stays the compact `XXX-XXX-XXX` form.
|
|
*/
|
|
function spell(code: string): string {
|
|
return code
|
|
.replace(/-/g, " ")
|
|
.split("")
|
|
.filter((c) => c !== " ")
|
|
.join(" ");
|
|
}
|