Files
guru-connect/dashboard/src/features/codes/GenerateCodeModal.tsx
Mike Swanson 664f33d5ab
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m27s
Build and Test / Build Agent (Windows) (push) Successful in 7m11s
Build and Test / Security Audit (push) Successful in 4m32s
Build and Test / Build Summary (push) Has been skipped
feat(dashboard): GuruConnect v2 Support Codes view
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>
2026-05-30 13:59:18 -07:00

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(" ");
}