SetupPreview.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. 'use client'
  2. import { Maximize2 } from 'lucide-react'
  3. import { useTranslations } from 'next-intl'
  4. import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
  5. import { invoiceLabels } from '@/features/invoice-designer/Lib/invoiceLabels'
  6. import {
  7. type PdfMessages,
  8. withMarineDocumentLabels,
  9. } from '@/features/invoice-designer/Lib/marineLabels'
  10. import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
  11. import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
  12. import { SpecThumbnail } from '@/features/invoice-designer/Render/SpecThumbnail'
  13. import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
  14. import type { Locale } from '@/i18n/config'
  15. import { track } from '@/lib/analytics'
  16. import { type PreviewSample, previewInvoiceInput } from '../../Lib/previewInvoice'
  17. import type { SetupDraft } from '../../Lib/setupDraft'
  18. interface LanguagePack {
  19. locale: Locale
  20. labels: Record<string, string>
  21. /** The same labels with a boat yard's words: vessel, HIN, engine hours. */
  22. marineLabels: Record<string, string>
  23. sample: PreviewSample
  24. }
  25. /** The printed labels and the sample job's prose, for one language. */
  26. async function loadPack(locale: Locale): Promise<LanguagePack> {
  27. const [pdf, onboarding] = await Promise.all([
  28. import(`../../../../../messages/${locale}/pdf.json`),
  29. import(`../../../../../messages/${locale}/onboarding.json`),
  30. ])
  31. const labels = invoiceLabels(pdf.default, 'invoice')
  32. return {
  33. locale,
  34. labels,
  35. marineLabels: withMarineDocumentLabels(labels, pdf.default as PdfMessages),
  36. sample: onboarding.default.setup.sample,
  37. }
  38. }
  39. /** White kept beside and below the printed content, in points. */
  40. const GUTTER = 14
  41. /**
  42. * The page cropped to what is printed and drawn as wide as its box: the side
  43. * margins and the empty bottom of the page are cut. A whole A4 page in a side
  44. * column prints its text at about 5px; cropped, it is nearly twice that.
  45. */
  46. function CroppedSheet({ spec }: { spec: DocumentSpec }) {
  47. const boxRef = useRef<HTMLDivElement>(null)
  48. const [width, setWidth] = useState(0)
  49. useEffect(() => {
  50. const box = boxRef.current
  51. if (!box) return
  52. const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width))
  53. observer.observe(box)
  54. return () => observer.disconnect()
  55. }, [])
  56. // The visible part of the page, in points: margins cut down to a gutter.
  57. const cropX = Math.max(0, Math.min(spec.page.margin.left, spec.page.margin.right) - GUTTER)
  58. const cropTop = Math.max(0, spec.page.margin.top - GUTTER)
  59. const scale = width ? width / (spec.page.width - cropX * 2) : 0
  60. // Where the printed content ends, in points, so the empty rest is not drawn.
  61. const [contentBottom, setContentBottom] = useState(0)
  62. useLayoutEffect(() => {
  63. const page = boxRef.current?.querySelector<HTMLElement>('[data-preview-page] > div > div')
  64. if (!page) return
  65. const measure = () => {
  66. let bottom = 0
  67. for (const child of Array.from(page.children) as HTMLElement[]) {
  68. // A side rail of a framed design runs the full page and ends nothing.
  69. if (child.offsetHeight >= spec.page.height * 0.9) continue
  70. bottom = Math.max(bottom, child.offsetTop + child.offsetHeight)
  71. }
  72. setContentBottom(bottom)
  73. }
  74. measure()
  75. // A logo loading or web fonts arriving change the content's height after
  76. // it is drawn, so it is measured again whenever its size changes.
  77. const observer = new ResizeObserver(measure)
  78. for (const child of Array.from(page.children)) observer.observe(child)
  79. void document.fonts?.ready.then(measure)
  80. return () => observer.disconnect()
  81. }, [spec, scale])
  82. const visibleHeight =
  83. Math.min(spec.page.height, (contentBottom || spec.page.height) + GUTTER) - cropTop
  84. return (
  85. <div
  86. ref={boxRef}
  87. className="relative overflow-hidden bg-white"
  88. style={{ height: scale ? Math.round(visibleHeight * scale) : undefined }}
  89. >
  90. {scale > 0 && (
  91. <div
  92. data-preview-page=""
  93. className="[&>div]:border-0!"
  94. style={{ position: 'absolute', left: -cropX * scale, top: -cropTop * scale }}
  95. >
  96. <SpecThumbnail spec={spec} height={spec.page.height * scale} />
  97. </div>
  98. )}
  99. </div>
  100. )
  101. }
  102. /**
  103. * The first invoice, drawn by the same builder and renderer as the real one,
  104. * in the language customers get. It follows every choice as it is made, and
  105. * opens at full size for a closer look.
  106. */
  107. export function SetupPreview({ draft, caption }: { draft: SetupDraft; caption: string }) {
  108. const t = useTranslations('onboarding.setup.preview')
  109. const [pack, setPack] = useState<LanguagePack | null>(null)
  110. const [enlarged, setEnlarged] = useState(false)
  111. useEffect(() => {
  112. let alive = true
  113. loadPack(draft.locale)
  114. .then((next) => alive && setPack(next))
  115. .catch(() => undefined)
  116. return () => {
  117. alive = false
  118. }
  119. }, [draft.locale])
  120. const spec = useMemo(
  121. () =>
  122. pack
  123. ? buildInvoicePrintSpec(
  124. previewInvoiceInput(
  125. draft,
  126. draft.kind === 'marine' ? pack.marineLabels : pack.labels,
  127. pack.sample
  128. )
  129. )
  130. : null,
  131. [draft, pack]
  132. )
  133. return (
  134. <aside aria-label={t('label')} className="flex min-w-0 flex-col gap-3">
  135. <span className="inline-flex items-center gap-2 text-[13px] font-semibold text-muted-foreground">
  136. <span className="h-2 w-2 rounded-full bg-emerald-600" aria-hidden="true" />
  137. {caption}
  138. </span>
  139. {spec ? (
  140. <button
  141. type="button"
  142. onClick={() => {
  143. track('workshop:setup_preview_open')
  144. setEnlarged(true)
  145. }}
  146. className="group relative block w-full cursor-zoom-in overflow-hidden rounded-lg text-left shadow-[0_18px_40px_rgba(70,52,18,0.10)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
  147. lang={draft.locale}
  148. >
  149. <CroppedSheet spec={spec} />
  150. <span className="absolute right-2.5 bottom-2.5 inline-flex items-center gap-1.5 rounded-full bg-neutral-900/80 px-2.5 py-1 text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
  151. <Maximize2 className="h-3 w-3" aria-hidden="true" />
  152. {t('enlarge')}
  153. </span>
  154. </button>
  155. ) : (
  156. <div
  157. className="flex aspect-[1/1.414] items-center justify-center rounded-lg bg-white text-sm text-neutral-500"
  158. role="status"
  159. >
  160. {t('loading')}
  161. </div>
  162. )}
  163. <Dialog open={enlarged} onOpenChange={setEnlarged}>
  164. <DialogContent className="max-h-[92vh] w-[calc(100vw-2rem)] max-w-[820px] gap-3 overflow-y-auto p-4 sm:max-w-[820px] sm:p-5">
  165. <DialogTitle className="pr-10 text-[15px] font-semibold">{t('label')}</DialogTitle>
  166. <DialogDescription className="sr-only">{caption}</DialogDescription>
  167. {spec && (
  168. <div className="overflow-hidden rounded-md" lang={draft.locale}>
  169. <CroppedSheet spec={spec} />
  170. </div>
  171. )}
  172. </DialogContent>
  173. </Dialog>
  174. </aside>
  175. )
  176. }