diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 5e09ec3..3b4cd6c 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -337,3 +337,34 @@ export async function fetchPdfBlob(documentId: string): Promise { } 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(path: string, body: unknown, signal?: AbortSignal): Promise { + 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 => + postPreview('/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`) diff --git a/apps/hq-web/src/components/LivePreview.tsx b/apps/hq-web/src/components/LivePreview.tsx new file mode 100644 index 0000000..eb7f9c6 --- /dev/null +++ b/apps/hq-web/src/components/LivePreview.tsx @@ -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 + /** 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(null) + const bRef = useRef(null) + const [aShown, setAShown] = useState(true) // which buffer is visible + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const seq = useRef(0) // sequence guard: only the latest response wins + const lastBody = useRef(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 ( +
+ {busy &&
} + {error !== null && ( +
+ Preview paused — retrying +
+ )} + {/* 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). */} +