@flamingo-stack/openframe-frontend-core 0.0.720 → 0.0.722

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/{chunk-3HZ6XSZC.js → chunk-2TXGR7IK.js} +136 -59
  2. package/dist/{chunk-WSDT7LYL.js → chunk-3UBWDR65.js} +1 -1
  3. package/dist/{chunk-CMZ3QTFQ.js → chunk-4I3K6LEH.js} +24 -0
  4. package/dist/{chunk-4SIZCHVZ.cjs → chunk-4LY6R4TN.cjs} +126 -49
  5. package/dist/{chunk-2NASBO7O.js → chunk-5D2K7TFT.js} +3 -3
  6. package/dist/{chunk-HFEYEPOG.cjs → chunk-5K6YKRP5.cjs} +29 -29
  7. package/dist/{chunk-7HYEQKN6.js → chunk-65C4WHG4.js} +6 -6
  8. package/dist/{chunk-DOPOHO6H.cjs → chunk-6HRQLM6T.cjs} +20 -20
  9. package/dist/{chunk-B2N4YX66.js → chunk-6YBUQAOH.js} +1 -1
  10. package/dist/{chunk-GANIUMRU.js → chunk-7UZ2JZJY.js} +3 -3
  11. package/dist/{chunk-4HJRHEXD.cjs → chunk-ABSWD4PQ.cjs} +27 -27
  12. package/dist/{chunk-VYVPII4W.js → chunk-AWWLEYAU.js} +6 -6
  13. package/dist/{chunk-V6B2FBZK.js → chunk-BCU6UUNQ.js} +8 -8
  14. package/dist/{chunk-LO3KCLEF.js → chunk-BUDZWXRG.js} +4 -4
  15. package/dist/{chunk-WBNAEYTQ.cjs → chunk-BYUIFT7Q.cjs} +6 -6
  16. package/dist/{chunk-KSGMZHVV.cjs → chunk-DFM572VH.cjs} +2 -2
  17. package/dist/{chunk-YDPKUAP4.js → chunk-DJ4IFQNQ.js} +2 -0
  18. package/dist/{chunk-DYDDAA6O.cjs → chunk-DWSBTGKT.cjs} +816 -798
  19. package/dist/{chunk-PSMJYZMV.js → chunk-DX3W7DPC.js} +2 -2
  20. package/dist/{chunk-PXAKSPLZ.js → chunk-E7NIZXDT.js} +20 -8
  21. package/dist/{chunk-6W46LVK4.js → chunk-ETQVXIYW.js} +6 -6
  22. package/dist/{chunk-742BGXNX.js → chunk-EWED6ISQ.js} +1 -1
  23. package/dist/{chunk-Q5327DXK.js → chunk-F33RMJOL.js} +11 -11
  24. package/dist/{chunk-RZQGIWK3.cjs → chunk-FDWYBJYD.cjs} +8 -8
  25. package/dist/{chunk-QF464PPL.cjs → chunk-GAFK3WNT.cjs} +62 -62
  26. package/dist/{chunk-6BLN5WDS.cjs → chunk-GYSTZD6X.cjs} +40 -40
  27. package/dist/{chunk-TORERAXD.js → chunk-HA2ASGJM.js} +6 -6
  28. package/dist/{chunk-KT3U3ZRP.cjs → chunk-HP4UUBJC.cjs} +16 -16
  29. package/dist/{chunk-DFQYAYBH.cjs → chunk-HQIOMFJH.cjs} +2 -0
  30. package/dist/{chunk-AZRI4XBP.js → chunk-I3UUMRO7.js} +1 -1
  31. package/dist/{chunk-VYZCKY5K.js → chunk-IF72N4PE.js} +7 -7
  32. package/dist/{chunk-RFUOIQSG.cjs → chunk-IITZ6JSC.cjs} +37 -37
  33. package/dist/{chunk-7LPR4RZ3.cjs → chunk-JQ55FFIK.cjs} +31 -31
  34. package/dist/{chunk-GQ7JJUIT.cjs → chunk-K2JUJ37D.cjs} +25 -1
  35. package/dist/{chunk-URW23QUM.js → chunk-K76ACBR3.js} +2 -2
  36. package/dist/{chunk-7IOU6BJE.js → chunk-KBPGSPXQ.js} +2387 -2369
  37. package/dist/{chunk-ILNN6QRW.js → chunk-KS3YLYVL.js} +4 -4
  38. package/dist/{chunk-2C72EWQR.cjs → chunk-KWBH63GV.cjs} +26 -26
  39. package/dist/{chunk-IYVFSLWJ.js → chunk-KZQO5EJQ.js} +4 -0
  40. package/dist/{chunk-IMTY3GRR.cjs → chunk-LV6UJD2K.cjs} +2 -2
  41. package/dist/{chunk-UWJMDFDO.cjs → chunk-MJ4SXOG2.cjs} +28 -28
  42. package/dist/{chunk-VF2CJXZ2.cjs → chunk-NFABAOMD.cjs} +77 -79
  43. package/dist/{chunk-B5LNX4GZ.js → chunk-NMGWNUKB.js} +3 -3
  44. package/dist/{chunk-RDP5UZXQ.cjs → chunk-P7Y626YJ.cjs} +370 -358
  45. package/dist/{chunk-P3JMAMHP.js → chunk-REETLEL5.js} +1 -1
  46. package/dist/{chunk-FFT56TYB.cjs → chunk-RUZIICXT.cjs} +2 -2
  47. package/dist/{chunk-ISCZ6UNF.cjs → chunk-RVQV6J5K.cjs} +75 -75
  48. package/dist/{chunk-VBHGJFA3.js → chunk-SZ3CBFSH.js} +13 -13
  49. package/dist/{chunk-O3MKJU6A.js → chunk-SZF7ENIW.js} +4 -4
  50. package/dist/{chunk-F5BPGWGT.cjs → chunk-TKFTWM3V.cjs} +5 -1
  51. package/dist/{chunk-YK3X74SF.js → chunk-UYMTAFHG.js} +6 -8
  52. package/dist/{chunk-DHNJSQGX.cjs → chunk-VC7COEWF.cjs} +105 -105
  53. package/dist/{chunk-XW3Q623Q.js → chunk-VSYQJCNR.js} +2 -2
  54. package/dist/{chunk-IL3ZWNM2.cjs → chunk-VU7RU3LL.cjs} +7 -7
  55. package/dist/{chunk-XSM46FHD.cjs → chunk-VV5INIQB.cjs} +3 -3
  56. package/dist/{chunk-HTS46FFY.cjs → chunk-VYHCN5NU.cjs} +16 -16
  57. package/dist/{chunk-ZPHOQDAE.js → chunk-WY4SLTMZ.js} +3 -3
  58. package/dist/{chunk-5XDCCQ4A.cjs → chunk-XJYAUIOL.cjs} +13 -13
  59. package/dist/{chunk-ZHD3F2TH.cjs → chunk-XR7NVJXS.cjs} +13 -13
  60. package/dist/{chunk-EVMM3JE6.cjs → chunk-ZHC6MDGF.cjs} +43 -43
  61. package/dist/{chunk-K2BYVPGR.js → chunk-ZMHP4Y6M.js} +5 -5
  62. package/dist/{chunk-NYSMDM4J.cjs → chunk-ZSOFLPQN.cjs} +7 -7
  63. package/dist/{cloud-tenants-RKQRSDNE.js → cloud-tenants-4Z36KWSX.js} +14 -14
  64. package/dist/{cloud-tenants-UQZASXUB.cjs → cloud-tenants-5NIC5VXK.cjs} +15 -15
  65. package/dist/components/calendar.cjs +4 -4
  66. package/dist/components/calendar.js +1 -1
  67. package/dist/components/case-studies/index.cjs +29 -29
  68. package/dist/components/case-studies/index.js +15 -15
  69. package/dist/components/chat/index.cjs +10 -10
  70. package/dist/components/chat/index.js +9 -9
  71. package/dist/components/contact/index.cjs +16 -16
  72. package/dist/components/contact/index.js +15 -15
  73. package/dist/components/docs/index.cjs +19 -19
  74. package/dist/components/docs/index.js +18 -18
  75. package/dist/components/embeds/index.cjs +16 -16
  76. package/dist/components/embeds/index.js +15 -15
  77. package/dist/components/faq/index.cjs +16 -16
  78. package/dist/components/faq/index.js +15 -15
  79. package/dist/components/features/index.cjs +15 -15
  80. package/dist/components/features/index.js +14 -14
  81. package/dist/components/features/remote-session/remote-access-request-card.d.ts.map +1 -1
  82. package/dist/components/help-center-pages/download-apps-page.d.ts +68 -0
  83. package/dist/components/help-center-pages/download-apps-page.d.ts.map +1 -0
  84. package/dist/components/help-center-pages/index.cjs +347 -117
  85. package/dist/components/help-center-pages/index.d.ts +1 -0
  86. package/dist/components/help-center-pages/index.d.ts.map +1 -1
  87. package/dist/components/help-center-pages/index.js +260 -30
  88. package/dist/components/index.cjs +87 -85
  89. package/dist/components/index.js +30 -28
  90. package/dist/components/meeting-scheduler/index.cjs +67 -67
  91. package/dist/components/meeting-scheduler/index.js +16 -16
  92. package/dist/components/navigation/index.cjs +18 -18
  93. package/dist/components/navigation/index.js +17 -17
  94. package/dist/components/onboarding-guides/index.cjs +18 -18
  95. package/dist/components/onboarding-guides/index.js +17 -17
  96. package/dist/components/product-demo/index.cjs +8 -8
  97. package/dist/components/product-demo/index.js +8 -8
  98. package/dist/components/related-content/index.cjs +7 -7
  99. package/dist/components/related-content/index.js +6 -6
  100. package/dist/components/tickets/index.cjs +20 -20
  101. package/dist/components/tickets/index.js +19 -19
  102. package/dist/components/ui/button/button.d.ts.map +1 -1
  103. package/dist/components/ui/file-manager/index.cjs +11 -11
  104. package/dist/components/ui/file-manager/index.js +1 -1
  105. package/dist/components/ui/index.cjs +17 -15
  106. package/dist/components/ui/index.d.ts +1 -0
  107. package/dist/components/ui/index.d.ts.map +1 -1
  108. package/dist/components/ui/index.js +16 -14
  109. package/dist/components/ui/mobile-app-qr.d.ts +21 -0
  110. package/dist/components/ui/mobile-app-qr.d.ts.map +1 -0
  111. package/dist/components/ui/store-badges.d.ts +7 -4
  112. package/dist/components/ui/store-badges.d.ts.map +1 -1
  113. package/dist/{devices-U3Y4IIW4.js → devices-2TK3LAKM.js} +14 -14
  114. package/dist/{devices-7Q2EUM7B.cjs → devices-7IKT72DK.cjs} +16 -16
  115. package/dist/hooks/index.cjs +9 -3
  116. package/dist/hooks/index.d.ts +1 -0
  117. package/dist/hooks/index.d.ts.map +1 -1
  118. package/dist/hooks/index.js +8 -2
  119. package/dist/hooks/use-downloads.d.ts +41 -0
  120. package/dist/hooks/use-downloads.d.ts.map +1 -0
  121. package/dist/index.cjs +52 -20
  122. package/dist/index.js +50 -18
  123. package/dist/{knowledge-NST53EXE.js → knowledge-3GJ4MU3V.js} +14 -14
  124. package/dist/{knowledge-UYIZ74MA.cjs → knowledge-HYSEV35H.cjs} +16 -16
  125. package/dist/{logs-JVTRG232.js → logs-TATVM62H.js} +14 -14
  126. package/dist/{logs-HTH7X7KV.cjs → logs-ZX3NADQC.cjs} +24 -24
  127. package/dist/{policies-MXK2HB4W.cjs → policies-J4FSDKPI.cjs} +19 -19
  128. package/dist/{policies-DTXTRK56.js → policies-MRRKLF4Q.js} +14 -14
  129. package/dist/{remote-session-LAJZKQBN.js → remote-session-WCLR47RN.js} +6 -6
  130. package/dist/{remote-session-SKKF4PBV.cjs → remote-session-Z2YCUPHU.cjs} +8 -8
  131. package/dist/{software-update-P6BSM6HX.cjs → software-update-CXUBWYD2.cjs} +19 -19
  132. package/dist/{software-update-6RHMPM3W.js → software-update-KM46WUVQ.js} +14 -14
  133. package/dist/{tickets-board-BHLPAVNJ.cjs → tickets-board-R5OQSEEC.cjs} +10 -10
  134. package/dist/{tickets-board-PYDNELSK.js → tickets-board-U3RF3AO4.js} +5 -5
  135. package/dist/types/downloads.d.ts +83 -0
  136. package/dist/types/downloads.d.ts.map +1 -0
  137. package/dist/types/index.cjs +24 -0
  138. package/dist/types/index.d.ts +1 -0
  139. package/dist/types/index.d.ts.map +1 -1
  140. package/dist/types/index.js +16 -1
  141. package/dist/utils/format.d.ts +11 -0
  142. package/dist/utils/format.d.ts.map +1 -1
  143. package/dist/utils/index.cjs +15 -0
  144. package/dist/utils/index.d.ts +2 -1
  145. package/dist/utils/index.d.ts.map +1 -1
  146. package/dist/utils/index.js +13 -1
  147. package/dist/utils/mobile-app.cjs +37 -0
  148. package/dist/utils/mobile-app.d.ts +23 -0
  149. package/dist/utils/mobile-app.d.ts.map +1 -0
  150. package/dist/utils/mobile-app.js +34 -0
  151. package/package.json +7 -1
  152. package/src/components/features/remote-session/remote-access-request-card.tsx +6 -6
  153. package/src/components/help-center-pages/download-apps-page.tsx +388 -0
  154. package/src/components/help-center-pages/index.ts +1 -0
  155. package/src/components/ui/button/button.tsx +3 -0
  156. package/src/components/ui/index.ts +1 -0
  157. package/src/components/ui/mobile-app-qr.tsx +35 -0
  158. package/src/components/ui/store-badges.tsx +17 -9
  159. package/src/hooks/index.ts +1 -0
  160. package/src/hooks/use-downloads.ts +138 -0
  161. package/src/types/downloads.ts +99 -0
  162. package/src/types/index.ts +1 -0
  163. package/src/utils/format.ts +15 -0
  164. package/src/utils/index.ts +3 -1
  165. package/src/utils/mobile-app.ts +34 -0
@@ -22,6 +22,7 @@ export { ProductReleasesListPage, type ProductReleasesListPageProps } from './pr
22
22
  export { DeliveryPage, type DeliveryPageProps } from './delivery-page';
23
23
  export { OnboardingGuidesCatalogPage, type OnboardingGuidesCatalogPageProps } from './onboarding-guides-catalog-page';
24
24
  export { TrustCenterPage, TrustCenterPageSkeleton, type TrustCenterPageProps } from './trust-center-page';
25
+ export { DownloadAppsPage, type DownloadAppsPageProps, type DownloadAppsWebOption } from './download-apps-page';
25
26
 
26
27
  // Existing full-pages re-exported for a single Help Center import site.
27
28
  export { FaqDocumentPage, type FaqDocumentPageProps } from '../faq';
@@ -339,6 +339,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function ButtonI
339
339
  download={download}
340
340
  prefetch={false}
341
341
  className={cn(shellClasses, isDisabled && 'pointer-events-none')}
342
+ // The click still reaches the caller (a download is what analytics counts).
343
+ onClick={onClick as unknown as React.MouseEventHandler<HTMLAnchorElement> | undefined}
342
344
  aria-label={props['aria-label']}
343
345
  >
344
346
  {splitContent}
@@ -423,6 +425,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function ButtonI
423
425
  download={download}
424
426
  prefetch={false}
425
427
  className={cn(classes, isDisabled && 'pointer-events-none')}
428
+ onClick={onClick as unknown as React.MouseEventHandler<HTMLAnchorElement> | undefined}
426
429
  aria-label={props['aria-label']}
427
430
  >
428
431
  {content}
@@ -73,6 +73,7 @@ export * from './status-badge';
73
73
  export * from './palette-badge';
74
74
  export * from './department-badge';
75
75
  export * from './store-badges';
76
+ export * from './mobile-app-qr';
76
77
  export * from './status-indicator';
77
78
  export * from './toaster';
78
79
  // TODO: Add other UI components as they are moved to ui-kit
@@ -0,0 +1,35 @@
1
+ import type { MobileAppInstallLink } from '../../types/downloads';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export interface MobileAppQrProps {
5
+ /** The server's install link: the address and the artwork that encodes it. */
6
+ install: MobileAppInstallLink;
7
+ /** Sizes the code; the caller owns its footprint. */
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * The install QR code, drawn inline: the modules are the ODS accent colour and
13
+ * the background is transparent, so the code sits directly on whatever surface
14
+ * it is placed on. The artwork and the address it encodes are the server's
15
+ * (`MobileAppInstallLink`): this component holds neither.
16
+ *
17
+ * On a dark surface that is a light-on-dark code. Phone cameras read both
18
+ * polarities; what they need is contrast between the accent and the surface, and
19
+ * the quiet zone, which the server bakes into the viewBox as a transparent
20
+ * margin. Place it on a plain surface (a card, the page), never over an image
21
+ * or a surface close to the accent in brightness.
22
+ */
23
+ export function MobileAppQr({ install, className }: MobileAppQrProps) {
24
+ return (
25
+ <svg
26
+ viewBox={`0 0 ${install.qrViewBoxSize} ${install.qrViewBoxSize}`}
27
+ shapeRendering="crispEdges"
28
+ role="img"
29
+ aria-label={`QR code for ${install.url}`}
30
+ className={cn('h-[120px] w-[120px] text-ods-accent', className)}
31
+ >
32
+ <path fill="currentColor" d={install.qrPath} />
33
+ </svg>
34
+ );
35
+ }
@@ -299,32 +299,40 @@ export function GooglePlayBadge({ className }: StoreBadgeProps) {
299
299
  }
300
300
 
301
301
  export interface StoreBadgeLinksProps {
302
- appStoreUrl: string;
303
- googlePlayUrl: string;
302
+ /** The App Store listing. Absent or null: that badge is not drawn. */
303
+ appStoreUrl?: string | null;
304
+ /** The Google Play listing. Absent or null: that badge is not drawn. */
305
+ googlePlayUrl?: string | null;
304
306
  /** Opens both listings in a new tab. */
305
307
  openInNewTab?: boolean;
306
308
  className?: string;
307
309
  }
308
310
 
309
311
  /**
310
- * The pair of store badges as links — the canonical "get our mobile app" row.
312
+ * The store badges as links — the canonical "get our mobile app" row. A store
313
+ * with no listing has no badge, and with neither the row is not rendered.
311
314
  *
312
315
  * Both are rendered at the same height because Apple and Google each require
313
316
  * their badge to be no smaller than any other store's badge beside it, which is
314
317
  * only satisfiable by drawing them equal.
315
318
  */
316
319
  export function StoreBadgeLinks({ appStoreUrl, googlePlayUrl, openInNewTab = true, className }: StoreBadgeLinksProps) {
320
+ if (!appStoreUrl && !googlePlayUrl) return null;
317
321
  return (
318
322
  <div className={cn('flex flex-wrap items-center gap-[var(--spacing-system-lf)]', className)}>
319
323
  {/* `rel` on both branches: `noopener` is inert without a target, but `noreferrer` is
320
324
  not — dropping it on the same-window branch sent the tenant's hostname to Apple
321
325
  and Google as a Referer. */}
322
- <a href={appStoreUrl} target={openInNewTab ? '_blank' : undefined} rel="noopener noreferrer">
323
- <AppStoreBadge className="h-12 w-auto" />
324
- </a>
325
- <a href={googlePlayUrl} target={openInNewTab ? '_blank' : undefined} rel="noopener noreferrer">
326
- <GooglePlayBadge className="h-12 w-auto" />
327
- </a>
326
+ {appStoreUrl && (
327
+ <a href={appStoreUrl} target={openInNewTab ? '_blank' : undefined} rel="noopener noreferrer">
328
+ <AppStoreBadge className="h-12 w-auto" />
329
+ </a>
330
+ )}
331
+ {googlePlayUrl && (
332
+ <a href={googlePlayUrl} target={openInNewTab ? '_blank' : undefined} rel="noopener noreferrer">
333
+ <GooglePlayBadge className="h-12 w-auto" />
334
+ </a>
335
+ )}
328
336
  </div>
329
337
  );
330
338
  }
@@ -4,6 +4,7 @@ export * from './platform';
4
4
  export * from './use-toast';
5
5
  export * from './use-contact-submission';
6
6
  export * from './use-copy-to-clipboard';
7
+ export * from './use-downloads';
7
8
 
8
9
  // Image authentication hooks
9
10
  export * from './use-batch-images';
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useSyncExternalStore } from 'react';
4
+ import { DOWNLOADS_API_PATH, type AppDownload, type DownloadsPublic } from '../types/downloads';
5
+ import { contentFetch } from '../utils/embed-content-fetch';
6
+ import { desktopOsOf } from '../utils/visitor-os';
7
+ import { useVisitorOs } from './ui/use-visitor-os';
8
+
9
+ export interface DownloadsState {
10
+ /** The server's answer; null until it lands. */
11
+ data: DownloadsPublic | null;
12
+ isLoading: boolean;
13
+ error: boolean;
14
+ }
15
+
16
+ interface Entry {
17
+ state: DownloadsState;
18
+ request: Promise<void> | null;
19
+ listeners: Set<() => void>;
20
+ }
21
+
22
+ const IDLE: DownloadsState = { data: null, isLoading: false, error: false };
23
+
24
+ /**
25
+ * One entry per endpoint for the life of the page. Every download button on a
26
+ * page asks the same question, so they share ONE request and one answer: the
27
+ * list changes only when the server is redeployed, never while a page is open.
28
+ */
29
+ const entries = new Map<string, Entry>();
30
+
31
+ function entryFor(endpoint: string): Entry {
32
+ let entry = entries.get(endpoint);
33
+ if (!entry) {
34
+ entry = { state: IDLE, request: null, listeners: new Set() };
35
+ entries.set(endpoint, entry);
36
+ }
37
+ return entry;
38
+ }
39
+
40
+ function publish(endpoint: string, state: DownloadsState) {
41
+ const entry = entryFor(endpoint);
42
+ entry.state = state;
43
+ for (const listener of entry.listeners) listener();
44
+ }
45
+
46
+ function load(endpoint: string, force = false): void {
47
+ const entry = entryFor(endpoint);
48
+ if (entry.request || (entry.state.data && !force)) return;
49
+ publish(endpoint, { data: entry.state.data, isLoading: true, error: false });
50
+ entry.request = contentFetch(endpoint)
51
+ .then(async response => {
52
+ if (!response.ok) throw new Error(`Request failed (${response.status})`);
53
+ publish(endpoint, { data: (await response.json()) as DownloadsPublic, isLoading: false, error: false });
54
+ })
55
+ .catch(() => publish(endpoint, { data: entry.state.data, isLoading: false, error: true }))
56
+ .finally(() => {
57
+ entry.request = null;
58
+ });
59
+ }
60
+
61
+ /** Test seam: forget every answer. */
62
+ export function resetDownloadsStore(): void {
63
+ entries.clear();
64
+ }
65
+
66
+ export interface UseDownloadsOptions {
67
+ /** GET endpoint for the public projection. Default `DOWNLOADS_API_PATH`; embedders pass their proxy path. */
68
+ endpoint?: string;
69
+ /** The server's copy (a host that read it during SSR): no request is made. */
70
+ initialData?: DownloadsPublic;
71
+ /** False: nothing is requested (a host that does not offer the desktop app). Default true. */
72
+ enabled?: boolean;
73
+ }
74
+
75
+ /**
76
+ * What the OpenFrame desktop app is installed from, read FROM THE SERVER
77
+ * (`GET /api/downloads`). The links and package names live in the server's
78
+ * environment; no client holds a copy of its own. Every caller on a page shares
79
+ * one request.
80
+ */
81
+ export function useDownloads({ endpoint = DOWNLOADS_API_PATH, initialData, enabled = true }: UseDownloadsOptions = {}) {
82
+ const entry = entryFor(endpoint);
83
+ // A server copy is the answer: held from the first render (server and client alike), and never re-requested.
84
+ // Written during render so THIS caller's first paint has it; a render cannot notify other callers.
85
+ if (initialData && entry.state.data !== initialData && !entry.request) {
86
+ entry.state = { data: initialData, isLoading: false, error: false };
87
+ }
88
+ // So the callers that rendered before this one (a header button above the page) are told after
89
+ // the commit: they re-read the shared answer instead of staying on the empty one they first saw.
90
+ useEffect(() => {
91
+ if (!initialData) return;
92
+ for (const listener of entry.listeners) listener();
93
+ }, [entry, initialData]);
94
+ const subscribe = useCallback(
95
+ (listener: () => void) => {
96
+ entry.listeners.add(listener);
97
+ return () => {
98
+ entry.listeners.delete(listener);
99
+ };
100
+ },
101
+ [entry],
102
+ );
103
+ const state = useSyncExternalStore(
104
+ subscribe,
105
+ () => entry.state,
106
+ () => entry.state,
107
+ );
108
+
109
+ useEffect(() => {
110
+ if (enabled) load(endpoint);
111
+ }, [enabled, endpoint]);
112
+
113
+ const reload = useCallback(() => load(endpoint, true), [endpoint]);
114
+ // Until the effect has asked, an enabled caller with no answer is loading, not empty.
115
+ const isLoading = state.isLoading || (enabled && !state.data && !state.error);
116
+ return { data: state.data, isLoading, error: state.error, reload };
117
+ }
118
+
119
+ export interface VisitorDesktopDownload {
120
+ /** False until the visitor's system AND the server's list are both known. */
121
+ known: boolean;
122
+ /** This visitor's installer (the first one for their system); null when nothing installs on it. */
123
+ download: AppDownload | null;
124
+ }
125
+
126
+ /**
127
+ * The installer a download button offers THIS visitor: the server's first
128
+ * installer for the system they are on. `enabled: false` asks for nothing.
129
+ */
130
+ export function useVisitorDesktopDownload(options: UseDownloadsOptions = {}): VisitorDesktopDownload {
131
+ const enabled = options.enabled ?? true;
132
+ const visitor = useVisitorOs();
133
+ const { data, error } = useDownloads(options);
134
+ if (!enabled || !visitor.known || (!data && !error)) return { known: false, download: null };
135
+ const os = desktopOsOf(visitor.os);
136
+ const download = data?.desktop.find(row => row.kind === 'binary' && row.url && row.os === os) ?? null;
137
+ return { known: true, download };
138
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The download page's wire shape and vocabulary: what the OpenFrame apps are
3
+ * installed from. The hub answers `DOWNLOADS_API_PATH` with `DownloadsPublic`;
4
+ * the lib's `<DownloadAppsPage>` renders it (hub SSR, openframe-frontend through
5
+ * its `/content` proxy, the embedding example).
6
+ */
7
+ import type { DesktopOs } from '../utils/visitor-os';
8
+
9
+ /** GET endpoint for the public downloads projection (the hub's path; embedders prefix their proxy). */
10
+ export const DOWNLOADS_API_PATH = '/api/downloads';
11
+
12
+ /** How long a copy stays fresh: the CDN window of the route, and the page's revalidation age. */
13
+ export const DOWNLOADS_CACHE_SECONDS = 300;
14
+
15
+ export const DOWNLOADS_TITLE = 'Download OpenFrame';
16
+ export const DOWNLOADS_TAGLINE =
17
+ 'Install it on the computer you manage IT from, and on your phone. One account everywhere.';
18
+ /** The title and subtitle when a host offers the mobile app only. */
19
+ export const DOWNLOADS_MOBILE_TITLE = 'Get the OpenFrame app';
20
+ export const DOWNLOADS_MOBILE_TAGLINE =
21
+ 'Alerts and tickets on your phone, signed in with the account you already have.';
22
+
23
+ /**
24
+ * What a row is: an installer file, or a one-line package manager install. THE
25
+ * one declaration: the type, the server's rows and every reader derive from it.
26
+ */
27
+ export const APP_DOWNLOAD_KINDS = ['binary', 'command'] as const;
28
+ export type AppDownloadKind = (typeof APP_DOWNLOAD_KINDS)[number];
29
+
30
+ /** One way to install the desktop app on one system. */
31
+ export interface AppDownload {
32
+ id: string;
33
+ kind: AppDownloadKind;
34
+ os: DesktopOs;
35
+ /** `universal`, `x64`, `arm64`; null for a command. */
36
+ architecture: string | null;
37
+ /** The installer's address (`binary`). */
38
+ url: string | null;
39
+ /** The whole command to paste (`command`). */
40
+ command: string | null;
41
+ /** "macOS 13+". */
42
+ minOsLabel: string | null;
43
+ /** The package manager's name (`command`), or the button's own words (`binary`). */
44
+ label: string | null;
45
+ }
46
+
47
+ /**
48
+ * The install link a phone opens to reach its store, and the QR code that
49
+ * encodes it. Both are the server's: the link is an address on the website
50
+ * (answered there with a redirect by device), and the artwork is drawn from it
51
+ * on the server, so the two can never disagree and the lib names no host.
52
+ */
53
+ export interface MobileAppInstallLink {
54
+ /** The absolute address the code encodes. */
55
+ url: string;
56
+ /** The side of the artwork's square viewBox, quiet zone included. */
57
+ qrViewBoxSize: number;
58
+ /** The dark modules as one SVG path of filled rectangles (`fill` draws them). */
59
+ qrPath: string;
60
+ }
61
+
62
+ /** Where the mobile app is installed from. A store the server names no listing for is null, and is not offered. */
63
+ export interface MobileAppLinks {
64
+ appStoreUrl: string | null;
65
+ googlePlayUrl: string | null;
66
+ /** The install link and its QR code. Absent or null: no code is drawn. */
67
+ install?: MobileAppInstallLink | null;
68
+ }
69
+
70
+ /**
71
+ * Everything the download page shows. All of it is the server's: the lib holds
72
+ * no link of its own and falls back to none, so what is missing is not drawn.
73
+ */
74
+ export interface DownloadsPublic {
75
+ /** The desktop installers and install commands, in the order they are offered. Per system, the first installer is the default. */
76
+ desktop: AppDownload[];
77
+ /** The mobile app's store listings. */
78
+ mobile: MobileAppLinks;
79
+ }
80
+
81
+ /** A system's name on a button and a card. */
82
+ export const DESKTOP_OS_LABELS: Record<DesktopOs, string> = { mac: 'Mac', windows: 'Windows', linux: 'Linux' };
83
+
84
+ /** An architecture's name beside a button, when a system has more than one installer. */
85
+ export const DOWNLOAD_ARCHITECTURE_LABELS: Record<string, string> = {
86
+ universal: 'Apple Silicon and Intel',
87
+ x64: 'x64',
88
+ arm64: 'ARM',
89
+ };
90
+
91
+ /** What a host learns when a visitor takes an install action on the page. */
92
+ export interface DownloadActionEvent {
93
+ /** `installer`: a download button. `command`: a copied install command. */
94
+ action: 'installer' | 'command';
95
+ /** The row's id (`mac`, `windows-arm64`, `homebrew`). */
96
+ id: string;
97
+ os: DesktopOs;
98
+ architecture: string | null;
99
+ }
@@ -55,6 +55,7 @@ export * from './waitlist';
55
55
  // Business logic types
56
56
  export * from './faq';
57
57
  export * from './trust-center';
58
+ export * from './downloads';
58
59
  export * from './content-ref';
59
60
  export * from './report';
60
61
  export * from './stack';
@@ -1059,3 +1059,18 @@ export function formatWebinarTimeMeta(
1059
1059
  const time = formatProgramTimeRange(at, { startAt: opts.startAt, withZoneLabel: opts.withZoneLabel });
1060
1060
  return [time, duration].filter(Boolean).join(' · ');
1061
1061
  }
1062
+
1063
+ /**
1064
+ * A web address as it is shown to a person to read or type, not to follow: no
1065
+ * scheme, no query or fragment, no trailing slash.
1066
+ * `https://www.example.com/mobile?from=qr` reads `example.com/mobile`.
1067
+ *
1068
+ * `www` is dropped by default, because nobody types it; `keepWww` keeps it where
1069
+ * the address is shown as the site's own name.
1070
+ */
1071
+ export function formatUrlForDisplay(url: string, options: { keepWww?: boolean } = {}): string {
1072
+ return url
1073
+ .replace(options.keepWww ? /^https?:\/\//i : /^https?:\/\/(www\.)?/i, '')
1074
+ .replace(/[?#].*$/, '')
1075
+ .replace(/\/$/, '');
1076
+ }
@@ -16,7 +16,7 @@ export {
16
16
  // Number / currency / byte / date formatters live in `./format` (single
17
17
  // source of truth). Re-exported here so existing callers that pull from
18
18
  // the barrel keep working without changing imports.
19
- export { formatDate, formatNumber, formatPrice, formatBytes } from './format';
19
+ export { formatDate, formatNumber, formatPrice, formatBytes, formatUrlForDisplay } from './format';
20
20
  export { TRIAL_DAYS, TRIAL_LABELS } from './trial';
21
21
  export {
22
22
  VENDOR_CLASSIFICATIONS,
@@ -118,6 +118,8 @@ export * from './video-bite-id';
118
118
  // OS type utilities
119
119
  export * from './os-utils';
120
120
  export * from './visitor-os';
121
+ // Also exported via the granular subpath `./utils/mobile-app` (server and Edge consumers).
122
+ export * from './mobile-app';
121
123
  // Phone utilities
122
124
  export * from './country-phone-utils';
123
125
  export * from './compliance-standards';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The download page's address, and what a server needs to send a phone on to
3
+ * its store. No link lives here: the store listings, the installer links and
4
+ * the install link the QR code encodes are all the server's (`DownloadsPublic`,
5
+ * read through `useDownloads`).
6
+ *
7
+ * Pure and server-safe; also published as the granular subpath
8
+ * `./utils/mobile-app`, for an Edge proxy and other server-only consumers.
9
+ */
10
+ import type { MobileAppLinks } from '../types/downloads';
11
+ import { detectVisitorOs } from './visitor-os';
12
+
13
+ /** The website's download page: every installer and the mobile app. */
14
+ export const DOWNLOAD_PAGE_PATH = '/download';
15
+
16
+ /**
17
+ * The store for a phone or tablet, or `null` for every other system and for a
18
+ * store the server names no listing for (`links` is the server's answer).
19
+ *
20
+ * The server passes the request's User-Agent (the install link's redirect).
21
+ * A browser may also pass `navigator.maxTouchPoints`, the only signal that tells
22
+ * an iPad asking for the desktop site (it calls itself a Macintosh) from a real
23
+ * Mac; the server cannot, so that iPad lands on the download page and its badges.
24
+ */
25
+ export function resolveMobileStoreUrl(
26
+ links: MobileAppLinks,
27
+ userAgent: string | null | undefined,
28
+ maxTouchPoints?: number,
29
+ ): string | null {
30
+ const os = detectVisitorOs(userAgent, { maxTouchPoints });
31
+ if (os === 'ios') return links.appStoreUrl;
32
+ if (os === 'android') return links.googlePlayUrl;
33
+ return null;
34
+ }