feat(hq-web): LivePreview — debounced double-buffer iframe + preview fetchers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent 52e8a03c81
commit c2eb7acba0

@ -337,3 +337,34 @@ export async function fetchPdfBlob(documentId: string): Promise<Blob> {
}
return res.blob()
}
export interface DocTotals {
taxablePaise: number; cgstPaise: number; sgstPaise: number
igstPaise: number; roundOffPaise: number; payablePaise: number
}
export interface DocPreview { html: string; totals: DocTotals; warnings: string[] }
/**
* Dedicated preview POST (mirrors fetchPdfBlob): carries the bearer token and the
* AbortSignal, and unlike apiFetch lets an aborted fetch reject as AbortError
* so LivePreview can silently drop superseded requests instead of surfacing a
* spurious "server unreachable".
*/
async function postPreview<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const token = localStorage.getItem(TOKEN_KEY) ?? ''
const res = await fetch(`/api${path}`, {
method: 'POST', signal,
headers: { 'content-type': 'application/json', ...(token !== '' ? { authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
})
const json = (await res.json().catch(() => ({}))) as T & { error?: string }
if (!res.ok) throw new Error((json as { error?: string }).error ?? `Preview failed: HTTP ${res.status}`)
return json
}
export const previewDocument = (body: unknown, signal?: AbortSignal): Promise<DocPreview> =>
postPreview<DocPreview>('/documents/preview', body, signal)
export const previewSample = (body: unknown, signal?: AbortSignal): Promise<{ html: string }> =>
postPreview<{ html: string }>('/previews/sample', body, signal)
export const getReminderPreview = (id: string): Promise<{ subject: string; body: string }> =>
apiFetch<{ subject: string; body: string }>(`/reminders/${id}/preview`)

@ -0,0 +1,111 @@
import { type CSSProperties, useEffect, useRef, useState } from 'react'
import type { DocTotals } from '../api'
export interface PreviewFetchResult { html: string; totals?: DocTotals; warnings?: string[] }
interface LivePreviewProps {
/** Serialized request body — identity for skip-if-unchanged. */
body: string
/** Bump to force an immediate (0ms) refire; body-only changes debounce 400ms. */
commitNonce: number
/** Performs the POST; must honour the AbortSignal. */
fetchPreview: (body: string, signal: AbortSignal) => Promise<PreviewFetchResult>
/** After each successful fetch — totals/warnings for the parent's strip. */
onResult?: (r: PreviewFetchResult) => void
/** In-flight/error changes — parent dims totals + shows the paused state. */
onStatus?: (s: { busy: boolean; error: string | null }) => void
}
const DEBOUNCE_MS = 400
/**
* Live preview pane (composer + company + quote-content). Debounced/immediate
* POST double-buffered iframe of the server's real documentHtml, so the paper
* on screen is the same HTML puppeteer rasterizes for the PDF. Never blanks: the
* previous paper stays under a 2px shimmer, the next render is written into a
* hidden stacked iframe, then cross-faded in with scrollTop kept. Modest by
* design a composer helper, not a general widget (YAGNI).
*/
export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatus }: LivePreviewProps) {
const aRef = useRef<HTMLIFrameElement>(null)
const bRef = useRef<HTMLIFrameElement>(null)
const [aShown, setAShown] = useState(true) // which buffer is visible
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const seq = useRef(0) // sequence guard: only the latest response wins
const lastBody = useRef<string | null>(null) // skip-if-unchanged
const prevCommit = useRef(commitNonce)
const shownRef = useRef(aShown)
shownRef.current = aShown
useEffect(() => { onStatus?.({ busy, error }) }, [busy, error, onStatus])
useEffect(() => {
const immediate = commitNonce !== prevCommit.current
prevCommit.current = commitNonce
if (body === lastBody.current) return // identical body — no POST (design §3)
const controller = new AbortController()
const mySeq = ++seq.current
const timer = setTimeout(() => {
setBusy(true)
fetchPreview(body, controller.signal)
.then((r) => {
if (mySeq !== seq.current) return // superseded
lastBody.current = body
setError(null)
swapIn(r.html)
onResult?.(r)
})
.catch((e: unknown) => {
if (controller.signal.aborted || mySeq !== seq.current) return
setError(e instanceof Error ? e.message : String(e)) // keep the last good paper
})
.finally(() => { if (mySeq === seq.current) setBusy(false) })
}, immediate ? 0 : DEBOUNCE_MS)
return () => { controller.abort(); clearTimeout(timer) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [body, commitNonce])
/** Write html into the hidden buffer; on load restore scroll then cross-fade. */
function swapIn(html: string): void {
const a = aRef.current, b = bRef.current
if (a === null || b === null) return
const visible = shownRef.current ? a : b
const hidden = shownRef.current ? b : a
// The frames are sandboxed WITHOUT allow-scripts (scripts never run), but WITH
// allow-same-origin so the double buffer can read/restore scrollTop. Guard the
// access anyway — a swap must never be broken by a cross-origin read throwing.
let keepScroll = 0
try { keepScroll = visible.contentWindow?.scrollY ?? 0 } catch { /* opaque origin — skip */ }
const onLoad = (): void => {
hidden.removeEventListener('load', onLoad)
try { hidden.contentWindow?.scrollTo(0, keepScroll) } catch { /* opaque origin — skip */ }
setAShown((s) => !s) // opacity transition = the 120ms cross-fade
}
hidden.addEventListener('load', onLoad)
hidden.srcdoc = html
}
const frame = (shown: boolean): CSSProperties => ({
position: 'absolute', inset: 0, width: '100%', height: '100%', border: 'none',
background: '#fff', opacity: shown ? 1 : 0, transition: 'opacity 120ms ease',
})
return (
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
{busy && <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'var(--accent, #4b8bf5)', opacity: 0.85, zIndex: 3 }} />}
{error !== null && (
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 3, background: 'var(--bg-raised, #fff)', border: '1px solid var(--border, #ccc)', borderRadius: 6, padding: '2px 8px', fontSize: 12 }}>
Preview paused retrying
</div>
)}
{/* No allow-scripts the pure-HTML/CSS template can never run JS; allow-same-origin
only lets the double buffer read/restore scrollTop (opaque origins throw on that). */}
<iframe ref={aRef} title="preview-a" sandbox="allow-same-origin" style={frame(aShown)} />
<iframe ref={bRef} title="preview-b" sandbox="allow-same-origin" style={frame(!aShown)} />
</div>
)
}

@ -3,9 +3,10 @@ import { useNavigate } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
createDocument, getClients, getModules, getPrices,
createDocument, getClients, getModules, getPrices, previewDocument,
KIND_LABEL, type Client, type Kind, type ModulePrice,
} from '../api'
import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients'
const today = () => new Date().toISOString().slice(0, 10)
@ -51,6 +52,7 @@ export function NewDocument() {
const [terms, setTerms] = useState('')
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
const [commitNonce, setCommitNonce] = useState(0)
// -- client type-ahead (debounced) --
const [q, setQ] = useState('')
@ -120,7 +122,21 @@ export function NewDocument() {
.catch((e: Error) => { setError(e.message); setSaving(false) })
}
const previewBody = JSON.stringify({
docType, clientId: client?.id ?? '',
lines: lines.filter((l) => l.moduleId !== '' && l.kind !== '').map((l) => ({
moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty),
...(l.edition !== 'standard' ? { edition: l.edition } : {}),
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
...(l.description !== '' ? { description: l.description } : {}),
contentLines: splitContent(l.contentText),
})),
...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
})
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
<div style={{ flex: '0 0 600px', minWidth: 0 }}>
<div className="wf-page">
<PageHeader
title="New Document"
@ -148,7 +164,7 @@ export function NewDocument() {
<button
key={c.id} type="button" className="wf"
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none' }}
onClick={() => { setClient(c); setHits([]) }}
onClick={() => { setClient(c); setHits([]); setCommitNonce((n) => n + 1) }}
>
{c.code} · {c.name}
</button>
@ -274,5 +290,16 @@ export function NewDocument() {
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save Draft'}</Button>
</div>
</div>
</div>
<div style={{ flex: 1, minWidth: 0, background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, height: 'calc(100vh - 140px)', position: 'sticky', top: 12, padding: 12 }}>
<div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview
body={previewBody}
commitNonce={commitNonce}
fetchPreview={(b, signal) => previewDocument(JSON.parse(b), signal)}
/>
</div>
</div>
</div>
)
}

Loading…
Cancel
Save