import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { ApiError, keys, runOptionsQuery, startRun } from '../api'; import type { CeilingBounds, Id, Run } from '../api'; import { useText, type MessageKey, type Text } from '../i18n/text'; import { Button } from '../ui/Button'; import { Checkbox } from '../ui/Checkbox'; import { Modal } from '../ui/Modal'; import { Slider } from '../ui/Slider'; import { Blank } from './Blank'; import { Loaded } from './Loaded'; import { counted } from './format'; import { chapters as chapterForms } from './units'; import styles from './RunStart.module.css'; interface Props { bookId: Id; isOpen: boolean; onClose: () => void; } /** * Starting a translation: the ceiling of THIS run and the stop at the bank. * * The bounds are read right before the form is shown and not taken off the book card, because the * maximum belongs to the ACCOUNT and moves while the book does not (contract, `run-options`). They * arrive already trimmed and are not clamped again here: a second clamp on the client would be a * second copy of a policy that lives on the platform. * * ⚠ The scale counts CHAPTERS and nothing else. There is no sum, no estimate and no money on this * screen in any form — the chapters → money conversion is the platform's whole and only (§4.8). */ export function RunStart({ bookId, isOpen, onClose }: Props) { const text = useText(); const client = useQueryClient(); // Read only while the window is open: bounds fetched behind a closed form are bounds that will be // stale by the time it opens. const options = useQuery({ ...runOptionsQuery(bookId), enabled: isOpen }); return ( {(data) => (
Promise.all([ client.invalidateQueries({ queryKey: keys.runOptions(bookId) }), client.invalidateQueries({ queryKey: keys.book(bookId) }), ]) } /> )} ); } /** * The form itself, mounted only once the bounds are known. * * ⚠ When the bounds MOVE — which is what a `409` means — the chosen value goes back to the * platform's new preset, because a value picked against bounds that no longer exist is not a * choice. Adjusted during the render rather than by remounting the form or by an effect: a remount * would take the refusal off the screen at the very moment it has to be read, and an effect would * draw the old scale for one frame first. */ function Form({ bookId, ceiling, onClose, onConflict, }: { bookId: Id; ceiling: CeilingBounds; onClose: () => void; onConflict: () => Promise; }) { const text = useText(); const client = useQueryClient(); const [chapters, setChapters] = useState(ceiling.default_chapters); // The bounds the value above was chosen against. The query layer keeps the identity of data that // did not change (structural sharing), so this fires on a real move of the bounds and not on // every refetch. const [chosenAgainst, setChosenAgainst] = useState(ceiling); if (chosenAgainst !== ceiling) { setChosenAgainst(ceiling); setChapters(ceiling.default_chapters); } // ⚠ OFF by default, and that is a choice about TODAY rather than about the product. With the stop // on, a run ends at `awaiting_bank` — and nothing in this build can take it further: the signing // screen is S5, the bank has no read channel at all (BACKLOG Ф-43), and `resume` answers 409 // while the set of decisions is incomplete (contract §resumeRun). Preselecting it would be // preselecting a dead end. The checkbox itself stays, because the choice is the user's and belongs // to THIS run (owner, 02.08); the default flips back the day S5 gives the stop somewhere to go. const [verifyBank, setVerifyBank] = useState(false); const run = useMutation({ mutationFn: () => startRun(bookId, { ceiling_chapters: chapters, verify_bank: verifyBank }), onSuccess: () => { // The run lives on the book card — that is where the shell reads the stream's id from. void client.invalidateQueries({ queryKey: keys.book(bookId) }); onClose(); }, onError: (error) => { // A 409 says the state moved between the read and this call. What moved is the platform's to // name (its phrase reaches the screen untouched); ours is to read the state again and let the // person look — never a quiet retry with a request the platform has just refused. if (error instanceof ApiError && error.status === 409) void onConflict(); }, }); // `max_chapters: 0` is not a scale with nothing on it: no run can start at all, and the honest // shape of that is the exhausted state instead of a control that cannot be used. if (ceiling.max_chapters === 0) { return ( ); } return (

{text('run.verifyBankHint')}

{/* A permanent live region, as in the intake form: the refusal appears away from the focus, and a reader that is not told is a reader who thinks the press did nothing (WCAG 4.1.3). */}
{run.isError && }
); } /** * Why the run did not start. As in the intake form, the phrase is the platform's where it sent one * and the ADVICE is ours: a moved ceiling and a deployment that cannot run at all end in different * next actions, and neither of them is "press it again". */ function Refused({ error }: { error: Error }) { const text = useText(); const [phrase, advice] = refusal(error, text); return (

{phrase}

{text(advice)}

); } function refusal(error: Error, text: Text): [string, MessageKey] { if (!(error instanceof ApiError)) return [text('loaded.unknownError'), 'upload.refusedAdvice']; if (error.status === 0) return [text('loaded.serverUnreachable'), 'upload.retryAdvice']; const own: Partial> = { 409: ['run.conflict', 'run.conflictAdvice'], 503: ['run.unavailable', 'run.unavailableAdvice'], }; const [fallback, advice] = own[error.status] ?? ['run.failedTitle', 'upload.refusedAdvice']; return [error.problem?.detail ?? error.problem?.title ?? text(fallback), advice]; }