@flamingo-stack/openframe-frontend-core 0.0.721 → 0.0.723

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 (154) hide show
  1. package/dist/assets/index.cjs +0 -6
  2. package/dist/assets/index.d.ts +0 -1
  3. package/dist/assets/index.d.ts.map +1 -1
  4. package/dist/assets/index.js +1 -5
  5. package/dist/{chunk-I2254QN6.cjs → chunk-2MC4DRSC.cjs} +3 -3
  6. package/dist/{chunk-UXU7Y5BH.js → chunk-2VOPQJNR.js} +3 -3
  7. package/dist/{chunk-QYS4YVEP.cjs → chunk-3RIKO2O2.cjs} +31 -31
  8. package/dist/{chunk-KFOAV77K.js → chunk-5D2K7TFT.js} +1 -1
  9. package/dist/{chunk-GGBYZKMS.cjs → chunk-6QTGLBJH.cjs} +5 -5
  10. package/dist/{chunk-5QXE5HMV.js → chunk-7JXCLUU7.js} +3 -3
  11. package/dist/{chunk-LAXKEBDD.js → chunk-A7JPMNHF.js} +7 -7
  12. package/dist/{chunk-7Z5FMQYR.cjs → chunk-ABSWD4PQ.cjs} +14 -14
  13. package/dist/{chunk-742BGXNX.js → chunk-EWED6ISQ.js} +1 -1
  14. package/dist/{chunk-S63QBBT7.js → chunk-F6JAGSKV.js} +5 -5
  15. package/dist/{chunk-QLTXT562.cjs → chunk-FDWYBJYD.cjs} +6 -6
  16. package/dist/{chunk-2P2M27EW.js → chunk-G6YNPBWV.js} +2219 -2202
  17. package/dist/{chunk-QQCFPMAA.js → chunk-GRDJR2Q7.js} +1 -1
  18. package/dist/{chunk-O5EHOJZD.cjs → chunk-GYUFKNOQ.cjs} +7 -7
  19. package/dist/{chunk-OKE5BIQH.cjs → chunk-HP4UUBJC.cjs} +8 -8
  20. package/dist/{chunk-HXB7SEYG.js → chunk-IIBTYCEN.js} +7 -16
  21. package/dist/{chunk-GCK75D7F.cjs → chunk-JENNI7XU.cjs} +56 -31
  22. package/dist/{chunk-ZLUFEWZZ.js → chunk-JL7AHSFE.js} +6 -6
  23. package/dist/{chunk-JDBZ4ZHU.js → chunk-KS3YLYVL.js} +2 -2
  24. package/dist/{chunk-6AJIOW3S.cjs → chunk-KSADTFNZ.cjs} +37 -37
  25. package/dist/{chunk-D2DH6VZL.cjs → chunk-KSZRALRL.cjs} +3 -3
  26. package/dist/{chunk-RJMWJAJO.cjs → chunk-KY4WQYYG.cjs} +19 -19
  27. package/dist/{chunk-IYVFSLWJ.js → chunk-KZQO5EJQ.js} +4 -0
  28. package/dist/{chunk-IMTY3GRR.cjs → chunk-LV6UJD2K.cjs} +2 -2
  29. package/dist/{chunk-UZTOI6FJ.cjs → chunk-MWLVYYRX.cjs} +619 -602
  30. package/dist/{chunk-44Q6MV7C.js → chunk-N4VCU6XP.js} +1 -1
  31. package/dist/{chunk-F7ORKKEF.cjs → chunk-NESUUHPV.cjs} +83 -83
  32. package/dist/{chunk-BYVVE7HP.cjs → chunk-NFABAOMD.cjs} +34 -36
  33. package/dist/{chunk-3R32ZNA2.cjs → chunk-OHC2MPUO.cjs} +35 -35
  34. package/dist/{chunk-RCI3PALX.js → chunk-OVG6MV3X.js} +6 -6
  35. package/dist/{chunk-HJMSNOBT.cjs → chunk-OWRD2GZU.cjs} +258 -267
  36. package/dist/{chunk-VFICDEYJ.cjs → chunk-P7MDECYI.cjs} +41 -41
  37. package/dist/{chunk-NPSJE7J3.js → chunk-PKS2QQJU.js} +29 -4
  38. package/dist/{chunk-57UTI7ZH.js → chunk-QBXEF7D5.js} +4 -4
  39. package/dist/{chunk-74N742ZG.js → chunk-SBCJFJ6Q.js} +4 -4
  40. package/dist/{chunk-XXO52RZR.cjs → chunk-TDSLKKX7.cjs} +18 -18
  41. package/dist/{chunk-F5BPGWGT.cjs → chunk-TKFTWM3V.cjs} +5 -1
  42. package/dist/{chunk-GYAXV3MZ.js → chunk-UYMTAFHG.js} +4 -6
  43. package/dist/{chunk-XE2K53GH.cjs → chunk-VGXAZEQP.cjs} +17 -17
  44. package/dist/{chunk-3LY7V3RF.js → chunk-VIFVSFYY.js} +2 -2
  45. package/dist/{chunk-LODMNSQY.cjs → chunk-WSMOTZRJ.cjs} +11 -11
  46. package/dist/{chunk-CO3FOZIH.js → chunk-WVA7SCLY.js} +1 -1
  47. package/dist/{chunk-ZZEKXGFS.js → chunk-WY4SLTMZ.js} +2 -2
  48. package/dist/{chunk-NJ5TBTJG.js → chunk-XTG7W75B.js} +5 -5
  49. package/dist/{chunk-NMPVK53I.cjs → chunk-Y5H3M7OM.cjs} +23 -23
  50. package/dist/{chunk-E6SMMMKV.js → chunk-YGIUWQEN.js} +9 -9
  51. package/dist/{chunk-RQZQHLNS.cjs → chunk-YQOJOW2Z.cjs} +66 -66
  52. package/dist/{chunk-LGKMR7NZ.js → chunk-YUSA7IPD.js} +3 -3
  53. package/dist/{cloud-tenants-HZHKRUIW.cjs → cloud-tenants-3TVX7U23.cjs} +10 -10
  54. package/dist/{cloud-tenants-Q4TCJS5Y.js → cloud-tenants-N6KV2T7X.js} +9 -9
  55. package/dist/components/case-studies/index.cjs +23 -23
  56. package/dist/components/case-studies/index.js +10 -10
  57. package/dist/components/chat/index.cjs +7 -7
  58. package/dist/components/chat/index.js +6 -6
  59. package/dist/components/contact/index.cjs +11 -11
  60. package/dist/components/contact/index.js +10 -10
  61. package/dist/components/docs/index.cjs +14 -14
  62. package/dist/components/docs/index.js +13 -13
  63. package/dist/components/embeds/index.cjs +11 -11
  64. package/dist/components/embeds/index.js +10 -10
  65. package/dist/components/faq/index.cjs +11 -11
  66. package/dist/components/faq/index.js +10 -10
  67. package/dist/components/features/index.cjs +10 -10
  68. package/dist/components/features/index.js +9 -9
  69. package/dist/components/features/remote-session/remote-access-request-card.d.ts.map +1 -1
  70. package/dist/components/help-center-pages/download-apps-page.d.ts +19 -6
  71. package/dist/components/help-center-pages/download-apps-page.d.ts.map +1 -1
  72. package/dist/components/help-center-pages/index.cjs +170 -151
  73. package/dist/components/help-center-pages/index.d.ts +1 -1
  74. package/dist/components/help-center-pages/index.d.ts.map +1 -1
  75. package/dist/components/help-center-pages/index.js +88 -69
  76. package/dist/components/help-center-pages/trust-center-page.d.ts +34 -1
  77. package/dist/components/help-center-pages/trust-center-page.d.ts.map +1 -1
  78. package/dist/components/help-center-pages/trust-center-sections.d.ts +1 -1
  79. package/dist/components/help-center-pages/trust-center-sections.d.ts.map +1 -1
  80. package/dist/components/index.cjs +66 -64
  81. package/dist/components/index.js +23 -21
  82. package/dist/components/meeting-scheduler/index.cjs +47 -47
  83. package/dist/components/meeting-scheduler/index.js +9 -9
  84. package/dist/components/navigation/index.cjs +13 -13
  85. package/dist/components/navigation/index.js +12 -12
  86. package/dist/components/onboarding-guides/index.cjs +13 -13
  87. package/dist/components/onboarding-guides/index.js +12 -12
  88. package/dist/components/product-demo/index.cjs +8 -8
  89. package/dist/components/product-demo/index.js +8 -8
  90. package/dist/components/related-content/index.cjs +5 -5
  91. package/dist/components/related-content/index.js +4 -4
  92. package/dist/components/tickets/index.cjs +14 -14
  93. package/dist/components/tickets/index.js +13 -13
  94. package/dist/components/ui/data-attribution.d.ts +3 -1
  95. package/dist/components/ui/data-attribution.d.ts.map +1 -1
  96. package/dist/components/ui/download-button.d.ts +19 -0
  97. package/dist/components/ui/download-button.d.ts.map +1 -0
  98. package/dist/components/ui/index.cjs +12 -10
  99. package/dist/components/ui/index.d.ts +1 -0
  100. package/dist/components/ui/index.d.ts.map +1 -1
  101. package/dist/components/ui/index.js +11 -9
  102. package/dist/components/ui/mobile-app-qr.d.ts +13 -15
  103. package/dist/components/ui/mobile-app-qr.d.ts.map +1 -1
  104. package/dist/{devices-NOUGVTES.js → devices-275UZZUS.js} +9 -9
  105. package/dist/{devices-6XHUFIC6.cjs → devices-MAALZ7DX.cjs} +11 -11
  106. package/dist/index.cjs +19 -19
  107. package/dist/index.js +23 -23
  108. package/dist/{knowledge-2YTUXQ2Z.js → knowledge-THXC2KC3.js} +9 -9
  109. package/dist/{knowledge-ANEKPMO2.cjs → knowledge-YMMULPPK.cjs} +11 -11
  110. package/dist/{logs-WVQIONUT.js → logs-GM2XBHYT.js} +9 -9
  111. package/dist/{logs-U3IB3HUM.cjs → logs-IBUKQOHS.cjs} +18 -18
  112. package/dist/{policies-UFRN2EQF.cjs → policies-J5GJXD6G.cjs} +14 -14
  113. package/dist/{policies-66EGDW2H.js → policies-NKMRK6RR.js} +9 -9
  114. package/dist/{remote-session-6WEUJZDC.js → remote-session-WCLR47RN.js} +4 -4
  115. package/dist/{remote-session-A75X5NPC.cjs → remote-session-Z2YCUPHU.cjs} +6 -6
  116. package/dist/{software-update-3ZD3MXF3.js → software-update-EKKMKDWI.js} +9 -9
  117. package/dist/{software-update-2MHKL76D.cjs → software-update-JHC4T4SE.cjs} +14 -14
  118. package/dist/{tickets-board-IFWMIZ35.cjs → tickets-board-R5OQSEEC.cjs} +8 -8
  119. package/dist/{tickets-board-MYXG3V72.js → tickets-board-U3RF3AO4.js} +3 -3
  120. package/dist/types/downloads.d.ts +16 -0
  121. package/dist/types/downloads.d.ts.map +1 -1
  122. package/dist/types/index.cjs +27 -2
  123. package/dist/types/index.js +25 -3
  124. package/dist/types/trust-center.d.ts +19 -2
  125. package/dist/types/trust-center.d.ts.map +1 -1
  126. package/dist/utils/format.d.ts +11 -0
  127. package/dist/utils/format.d.ts.map +1 -1
  128. package/dist/utils/index.cjs +4 -9
  129. package/dist/utils/index.d.ts +1 -1
  130. package/dist/utils/index.d.ts.map +1 -1
  131. package/dist/utils/index.js +4 -7
  132. package/dist/utils/mobile-app.cjs +0 -9
  133. package/dist/utils/mobile-app.d.ts +8 -25
  134. package/dist/utils/mobile-app.d.ts.map +1 -1
  135. package/dist/utils/mobile-app.js +1 -7
  136. package/package.json +1 -1
  137. package/src/assets/index.ts +0 -1
  138. package/src/components/features/remote-session/remote-access-request-card.tsx +6 -6
  139. package/src/components/help-center-pages/download-apps-page.tsx +53 -29
  140. package/src/components/help-center-pages/index.ts +1 -1
  141. package/src/components/help-center-pages/trust-center-page.tsx +50 -29
  142. package/src/components/help-center-pages/trust-center-sections.tsx +2 -2
  143. package/src/components/ui/data-attribution.tsx +19 -6
  144. package/src/components/ui/download-button.tsx +34 -0
  145. package/src/components/ui/index.ts +1 -0
  146. package/src/components/ui/mobile-app-qr.tsx +17 -22
  147. package/src/types/downloads.ts +17 -0
  148. package/src/types/trust-center.ts +52 -4
  149. package/src/utils/format.ts +15 -0
  150. package/src/utils/index.ts +1 -1
  151. package/src/utils/mobile-app.ts +8 -31
  152. package/dist/assets/mobile-app-qr.d.ts +0 -17
  153. package/dist/assets/mobile-app-qr.d.ts.map +0 -1
  154. package/src/assets/mobile-app-qr.ts +0 -19
@@ -1 +1 @@
1
- {"version":3,"file":"mobile-app.d.ts","sourceRoot":"","sources":["../../src/utils/mobile-app.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAGzD,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAE9C;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,UAAU,CAAC;AAEjD;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,8CAAiF,CAAC;AAErH,qGAAqG;AACrG,eAAO,MAAM,4BAA4B,QAGxC,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,cAAc,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACpC,cAAc,CAAC,EAAE,MAAM,GACtB,MAAM,GAAG,IAAI,CAKf"}
1
+ {"version":3,"file":"mobile-app.d.ts","sourceRoot":"","sources":["../../src/utils/mobile-app.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAGzD,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAE9C;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,cAAc,EACrB,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACpC,cAAc,CAAC,EAAE,MAAM,GACtB,MAAM,GAAG,IAAI,CAKf"}
@@ -24,12 +24,6 @@ function detectVisitorOs(userAgent, hints = {}) {
24
24
 
25
25
  // src/utils/mobile-app.ts
26
26
  var DOWNLOAD_PAGE_PATH = "/download";
27
- var DOWNLOAD_PAGE_STORE_PARAM = "store";
28
- var MOBILE_APP_INSTALL_URL = `https://www.flamingo.run${DOWNLOAD_PAGE_PATH}?${DOWNLOAD_PAGE_STORE_PARAM}=1`;
29
- var MOBILE_APP_INSTALL_HOST_PATH = MOBILE_APP_INSTALL_URL.replace(/^https:\/\/(www\.)?/, "").replace(
30
- /\?.*$/,
31
- ""
32
- );
33
27
  function resolveMobileStoreUrl(links, userAgent, maxTouchPoints) {
34
28
  const os = detectVisitorOs(userAgent, { maxTouchPoints });
35
29
  if (os === "ios") return links.appStoreUrl;
@@ -37,4 +31,4 @@ function resolveMobileStoreUrl(links, userAgent, maxTouchPoints) {
37
31
  return null;
38
32
  }
39
33
 
40
- export { DOWNLOAD_PAGE_PATH, DOWNLOAD_PAGE_STORE_PARAM, MOBILE_APP_INSTALL_HOST_PATH, MOBILE_APP_INSTALL_URL, resolveMobileStoreUrl };
34
+ export { DOWNLOAD_PAGE_PATH, resolveMobileStoreUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.721",
3
+ "version": "0.0.723",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -5,4 +5,3 @@
5
5
  export const assets = {} as const;
6
6
  export * from './fae-avatar';
7
7
  export * from './sad-flamingo';
8
- export * from './mobile-app-qr';
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { formatUrlForDisplay } from '../../../utils/format';
3
4
  import { ExternalLinkIcon, InfoCircleIcon } from '../../icons-v2-generated';
4
5
  import { Button } from '../../ui/button/button';
5
6
  import { RemoteSessionOrgLogo } from './remote-session-parts';
@@ -21,11 +22,6 @@ export interface RemoteAccessRequestCardProps {
21
22
  onOpenSite?: (url: string) => void;
22
23
  }
23
24
 
24
- /** `https://www.techflow.com/` -> `www.techflow.com`. */
25
- function displaySiteUrl(url: string): string {
26
- return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
27
- }
28
-
29
25
  /** The end user's consent dialog for a remote access request: who is asking, what they get, Decline / Allow Access. */
30
26
  export function RemoteAccessRequestCard({
31
27
  party,
@@ -47,7 +43,11 @@ export function RemoteAccessRequestCard({
47
43
  <RemoteSessionOrgLogo name={organizationName} logoUrl={party.organizationLogoUrl} sizePx={64} />
48
44
  <div className="flex min-w-0 flex-1 flex-col">
49
45
  <p className="w-full truncate text-ods-text-primary text-h3">{organizationName}</p>
50
- {siteUrl && <p className="w-full truncate text-ods-text-secondary text-h4">{displaySiteUrl(siteUrl)}</p>}
46
+ {siteUrl && (
47
+ <p className="w-full truncate text-ods-text-secondary text-h4">
48
+ {formatUrlForDisplay(siteUrl, { keepWww: true })}
49
+ </p>
50
+ )}
51
51
  </div>
52
52
  {openableSite && onOpenSite && (
53
53
  <Button variant="outline" size="icon" aria-label="Open website" onClick={() => onOpenSite(openableSite)}>
@@ -8,8 +8,14 @@
8
8
  * when a visitor looks for another system). In a card the installer is THE
9
9
  * action (the other architecture is a quieter button) and each package
10
10
  * manager is one line under it, with its copy button.
11
- * - The mobile app: the store badges, and the install QR code beside them.
12
- * - `footer`: the host's own last word (the website's "start on the web" card).
11
+ * - The mobile app: the store badges, and the install QR code beside them. The
12
+ * code and the address it encodes are the server's (`mobile.install`): the
13
+ * website's install link, which the server answers with a redirect to the
14
+ * phone's store. This page redirects nobody, and with no install link from
15
+ * the server it draws no code.
16
+ * - `web`: the way in for someone who installs nothing (the website's trial
17
+ * signup). The host names the words and the button; the page draws the card,
18
+ * in the frame of every other card on it.
13
19
  *
14
20
  * DATA: all of it is the server's answer (`DownloadsPublic`), read by
15
21
  * `useDownloads` against `endpoint` (default `DOWNLOADS_API_PATH`; embedders
@@ -26,7 +32,7 @@
26
32
  * The chrome is the canonical `PageShell` + frozen `PageLayout`.
27
33
  */
28
34
 
29
- import { useEffect, type ComponentType, type ReactNode } from 'react';
35
+ import type { ComponentType, ReactNode } from 'react';
30
36
  import { useRouter } from '../../embed-shims/next-navigation';
31
37
  import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard';
32
38
  import { useDownloads } from '../../hooks/use-downloads';
@@ -44,7 +50,7 @@ import {
44
50
  type MobileAppLinks,
45
51
  } from '../../types/downloads';
46
52
  import { cn } from '../../utils/cn';
47
- import { DOWNLOAD_PAGE_STORE_PARAM, MOBILE_APP_INSTALL_HOST_PATH, resolveMobileStoreUrl } from '../../utils/mobile-app';
53
+ import { formatUrlForDisplay } from '../../utils/format';
48
54
  import { DESKTOP_OSES, type DesktopOs } from '../../utils/visitor-os';
49
55
  import { CommandBox } from '../features/command-box';
50
56
  import { AppleLogoIcon } from '../icons-v2-generated/brand-logos/apple-logo-icon';
@@ -78,8 +84,16 @@ export interface DownloadAppsPageProps {
78
84
  openStoresInNewTab?: boolean;
79
85
  /** Called when a visitor downloads an installer or copies an install command (the host's analytics). */
80
86
  onDownloadAction?: (event: DownloadActionEvent) => void;
81
- /** The host's own closing block, under the mobile app. */
82
- footer?: ReactNode;
87
+ /** The closing card, under the mobile app: using OpenFrame without installing anything. Default: none. */
88
+ web?: DownloadAppsWebOption;
89
+ }
90
+
91
+ /** The "no install" card: the host's words and its own button (the page holds no signup link). */
92
+ export interface DownloadAppsWebOption {
93
+ title: string;
94
+ description: string;
95
+ /** The host's button (the website's trial signup). */
96
+ action: ReactNode;
83
97
  }
84
98
 
85
99
  const SECTIONS_CLASS = 'flex flex-col gap-[var(--spacing-system-xlf)]';
@@ -206,6 +220,24 @@ function SystemCard({
206
220
  );
207
221
  }
208
222
 
223
+ /** The closing card: one line of what the web offers, and the host's button at the far end. */
224
+ function WebCard({ web }: { web: DownloadAppsWebOption }) {
225
+ return (
226
+ <section
227
+ aria-labelledby="download-web"
228
+ className={cn(CARD_CLASS, 'content-md:flex-row content-md:items-center content-md:justify-between')}
229
+ >
230
+ <div className="flex flex-col gap-[var(--spacing-system-xsf)]">
231
+ <h2 id="download-web" className="m-0 text-ods-text-primary text-h3">
232
+ {web.title}
233
+ </h2>
234
+ <p className="m-0 text-ods-text-secondary text-h4">{web.description}</p>
235
+ </div>
236
+ <div className="shrink-0">{web.action}</div>
237
+ </section>
238
+ );
239
+ }
240
+
209
241
  /** A section's cards while the server's answer loads: the same grid and card frame, a button's box in each. */
210
242
  function CardsSkeleton({ label }: { label: string }) {
211
243
  return (
@@ -248,7 +280,7 @@ export function DownloadAppsPage({
248
280
  showDesktop = true,
249
281
  openStoresInNewTab = true,
250
282
  onDownloadAction,
251
- footer,
283
+ web,
252
284
  }: DownloadAppsPageProps) {
253
285
  const { data, isLoading, error, reload } = useDownloads({ endpoint, initialData });
254
286
  // The page adapts to the server's answer: a section exists only for what it names.
@@ -258,15 +290,6 @@ export function DownloadAppsPage({
258
290
  // Until the answer lands, a host that offers the desktop app is titled for it.
259
291
  const offersDesktop = showDesktop && (!data || systems.length > 0);
260
292
 
261
- useEffect(() => {
262
- // The install QR code arrives with this parameter: a phone or a tablet goes
263
- // straight on to its store. `replace`, so Back returns to whatever sent the
264
- // visitor here. Anyone else who opens the page on a phone reads it.
265
- if (!mobile || !new URLSearchParams(window.location.search).has(DOWNLOAD_PAGE_STORE_PARAM)) return;
266
- const storeUrl = resolveMobileStoreUrl(mobile, navigator.userAgent, navigator.maxTouchPoints);
267
- if (storeUrl) window.location.replace(storeUrl);
268
- }, [mobile]);
269
-
270
293
  let body: ReactNode;
271
294
  if (error && !data) {
272
295
  body = <LoadError message="Could not load the downloads" onRetry={reload} />;
@@ -298,7 +321,7 @@ export function DownloadAppsPage({
298
321
 
299
322
  {mobile && hasMobile && (
300
323
  <Section id="download-mobile" title="Mobile app">
301
- <div className={CARDS_GRID_CLASS}>
324
+ <div className={cardsGridClass(mobile.install ? 2 : 1)}>
302
325
  <div className={CARD_CLASS}>
303
326
  <CardHeader title={storeCardTitle(mobile)} />
304
327
  <p className="m-0 text-ods-text-secondary text-h4">Get alerts and respond to tickets on the go.</p>
@@ -308,18 +331,19 @@ export function DownloadAppsPage({
308
331
  openInNewTab={openStoresInNewTab}
309
332
  />
310
333
  </div>
311
- <div className={cn(CARD_CLASS, 'content-md:flex-row content-md:items-center content-md:justify-between')}>
312
- <div className="flex flex-col gap-[var(--spacing-system-sf)]">
313
- <CardHeader title="Scan with your phone" />
314
- <p className="m-0 text-ods-text-secondary text-h4">
315
- Point your camera at the code, or open {MOBILE_APP_INSTALL_HOST_PATH} on your phone.
316
- </p>
317
- </div>
318
- {/* The plate is light on purpose: a QR code is read dark on light. */}
319
- <div className="w-fit shrink-0 rounded-md bg-ods-bg-inverted p-[var(--spacing-system-sf)]">
320
- <MobileAppQr className="h-[120px] w-[120px]" />
334
+ {mobile.install && (
335
+ <div
336
+ className={cn(CARD_CLASS, 'content-md:flex-row content-md:items-center content-md:justify-between')}
337
+ >
338
+ <div className="flex flex-col gap-[var(--spacing-system-sf)]">
339
+ <CardHeader title="Scan with your phone" />
340
+ <p className="m-0 text-ods-text-secondary text-h4">
341
+ Point your camera at the code, or open {formatUrlForDisplay(mobile.install.url)} on your phone.
342
+ </p>
343
+ </div>
344
+ <MobileAppQr install={mobile.install} className="h-[144px] w-[144px] shrink-0" />
321
345
  </div>
322
- </div>
346
+ )}
323
347
  </div>
324
348
  </Section>
325
349
  )}
@@ -336,7 +360,7 @@ export function DownloadAppsPage({
336
360
  >
337
361
  <div className={SECTIONS_CLASS}>
338
362
  {body}
339
- {footer}
363
+ {web && <WebCard web={web} />}
340
364
  </div>
341
365
  </DownloadAppsChrome>
342
366
  );
@@ -22,7 +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 } from './download-apps-page';
25
+ export { DownloadAppsPage, type DownloadAppsPageProps, type DownloadAppsWebOption } from './download-apps-page';
26
26
 
27
27
  // Existing full-pages re-exported for a single Help Center import site.
28
28
  export { FaqDocumentPage, type FaqDocumentPageProps } from '../faq';
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * ONE page, not tabs (2026 trust-center practice: buyers skim and Ctrl-F, and a
7
7
  * questionnaire needs a deep link per section): hero with the monitoring status
8
- * and the two actions, then anchored sections in reading order
8
+ * (with the page's PDF as a quiet link at its end) and the Request access action, then anchored sections in reading order
9
9
  * (`TRUST_CENTER_SECTIONS`: AI & data use → compliance → controls → documents →
10
10
  * subprocessors → FAQ → questions) with a sticky section rail on desktop
11
11
  * (`StickySectionNav` + `useScrollSpy`, the vendor-page / DocViewer pattern).
@@ -34,11 +34,12 @@ import { useSelfFetch } from '../../hooks/use-self-fetch';
34
34
  import {
35
35
  TRUST_CENTER_API_PATH,
36
36
  TRUST_CENTER_CACHE_SECONDS,
37
- TRUST_CENTER_SECTIONS,
38
37
  TRUST_CENTER_TAGLINE,
39
38
  TRUST_CENTER_TITLE,
40
- isTrustCenterMonitored,
39
+ trustCenterMonitoringStatus,
41
40
  trustCenterDocumentUrl,
41
+ trustCenterPdfUrl,
42
+ visibleTrustCenterSections,
42
43
  type TrustCenterControlsPage,
43
44
  type TrustCenterPublic,
44
45
  type TrustCenterSectionId,
@@ -50,6 +51,7 @@ import { PageShell } from '../layout/article-detail-layout';
50
51
  import { PageLayout } from '../layout/page-layout';
51
52
  import { StickySectionNav } from '../navigation/sticky-section-nav';
52
53
  import { DataAttribution } from '../ui/data-attribution';
54
+ import { DownloadButton } from '../ui/download-button';
53
55
  import { EntityImage } from '../ui/entity-image';
54
56
  import { LoadError } from '../ui/error-state';
55
57
  import { StatusIndicator } from '../ui/status-indicator';
@@ -85,6 +87,13 @@ export interface TrustCenterPageProps {
85
87
  subtitle?: string;
86
88
  /** The FAQ section's "ask the assistant" card (`FaqSection`'s `ask`): the host states its topic. Absent: no card. */
87
89
  ask?: FaqSectionProps['ask'];
90
+ /**
91
+ * Host content shown after the trust sections and BEFORE the FAQ (the hub's
92
+ * own "why we build in the open" sections). The FAQ is the last thing on a
93
+ * page, so with this the page draws: trust sections, this content at the
94
+ * page's full width, then the FAQ. Absent: the FAQ closes the sections column.
95
+ */
96
+ beforeFaq?: ReactNode;
88
97
  }
89
98
 
90
99
  /** One section's page content. Its `h2` is the `TRUST_CENTER_SECTIONS` label — never a literal here. */
@@ -93,20 +102,6 @@ interface TrustSectionView {
93
102
  render: (data: TrustCenterPublic) => ReactNode;
94
103
  }
95
104
 
96
- /** Sections that have content, in page order. */
97
- function visibleSections(data: TrustCenterPublic): Array<(typeof TRUST_CENTER_SECTIONS)[number]> {
98
- const hasContent: Record<TrustCenterSectionId, boolean> = {
99
- ai: data.aiPractices.length > 0,
100
- compliance: data.frameworks.length > 0,
101
- controls: data.controlDomains.length > 0,
102
- documents: data.documents.length > 0,
103
- subprocessors: data.subprocessors.length > 0,
104
- faq: data.faqs.length > 0,
105
- contact: true,
106
- };
107
- return TRUST_CENTER_SECTIONS.filter(section => hasContent[section.id]);
108
- }
109
-
110
105
  /**
111
106
  * The Controls section's first answer, from the page's own server data: the
112
107
  * first category's controls with no query — exactly what the controls endpoint
@@ -136,12 +131,9 @@ function monitoringStatus(
136
131
  hydrated: boolean,
137
132
  nowMs: number,
138
133
  ): { status: 'success' | 'pending' | 'missing'; label: string } {
139
- if (!data.connected) return { status: 'missing', label: 'Live control monitoring is not enabled yet' };
140
134
  // Before mount the clock is the server's, so the text claims nothing either way.
141
- if (!hydrated) return { status: 'missing', label: 'Checking monitoring status' };
142
- return isTrustCenterMonitored(data, nowMs)
143
- ? { status: 'success', label: 'Controls continuously monitored' }
144
- : { status: 'pending', label: 'Monitoring paused' };
135
+ if (data.connected && !hydrated) return { status: 'missing', label: 'Checking monitoring status' };
136
+ return trustCenterMonitoringStatus(data, nowMs);
145
137
  }
146
138
 
147
139
  export function TrustCenterPage({
@@ -152,6 +144,7 @@ export function TrustCenterPage({
152
144
  title = TRUST_CENTER_TITLE,
153
145
  subtitle = TRUST_CENTER_TAGLINE,
154
146
  ask,
147
+ beforeFaq,
155
148
  }: TrustCenterPageProps) {
156
149
  const { data, isLoading, error, reload } = useSelfFetch<TrustCenterPublic>(endpoint, {
157
150
  initialData,
@@ -180,7 +173,7 @@ export function TrustCenterPage({
180
173
  documentTitle: null,
181
174
  });
182
175
 
183
- const sections = useMemo(() => (data ? visibleSections(data) : []), [data]);
176
+ const sections = useMemo(() => (data ? visibleTrustCenterSections(data) : []), [data]);
184
177
  // Anchors. The scroll spy lights the rail from whatever scrolls the sections
185
178
  // (the window, or a host shell's `<main overflow-y-auto>` — OpenFrame's
186
179
  // `AppLayout`) and keeps the URL's hash on the section in view (`syncHash`;
@@ -205,9 +198,15 @@ export function TrustCenterPage({
205
198
  const actions = hasGatedDocuments
206
199
  ? [{ label: 'Request access', variant: 'accent' as const, onClick: () => openRequest(null) }]
207
200
  : undefined;
201
+ // The page as a file (the hub's `pdf` route beside `endpoint`). An occasional
202
+ // utility on a read-only page, so it is NOT a header action (a button there,
203
+ // and a fixed bar on a phone, are for the page's task: Request access). It is
204
+ // the shared small `DownloadButton` at the end of the status line under the
205
+ // title, at every width.
206
+ const download = <DownloadButton label="Download PDF" href={trustCenterPdfUrl(endpoint)} />;
208
207
 
209
208
  // Section id → its content. Titles come from `TRUST_CENTER_SECTIONS`; which
210
- // sections show comes from `visibleSections` — this map only says what each renders.
209
+ // sections show comes from `visibleTrustCenterSections` — this map only says what each renders.
211
210
  const views: Record<TrustCenterSectionId, TrustSectionView> = {
212
211
  ai: {
213
212
  lead: TRUST_SECTION_LEADS.ai,
@@ -239,6 +238,11 @@ export function TrustCenterPage({
239
238
  contact: { render: () => <ContactSection onContact={() => openRequest(null)} /> },
240
239
  };
241
240
 
241
+ // With host content before it, the FAQ leaves the sections column and closes the page (below).
242
+ const faqSection = sections.find(section => section.id === 'faq');
243
+ const faqLast = Boolean(beforeFaq) && Boolean(faqSection);
244
+ const columnSections = faqLast ? sections.filter(section => section.id !== 'faq') : sections;
245
+
242
246
  let body: ReactNode;
243
247
  if (error && !data) {
244
248
  body = <LoadError message="Could not load the trust center" onRetry={reload} />;
@@ -264,13 +268,16 @@ export function TrustCenterPage({
264
268
  }
265
269
  source={data.dataSource.name}
266
270
  lastUpdated={data.syncedAt}
271
+ action={download}
267
272
  />
268
- ) : null}
273
+ ) : (
274
+ download
275
+ )}
269
276
  </div>
270
277
 
271
278
  <div className={TRUST_BODY_GRID_CLASS}>
272
279
  <div className={TRUST_SECTIONS_COLUMN_CLASS}>
273
- {sections.map(section => {
280
+ {columnSections.map(section => {
274
281
  const view = views[section.id];
275
282
  return (
276
283
  <TrustSection key={section.id} id={section.id} title={section.label} lead={view.lead}>
@@ -302,9 +309,23 @@ export function TrustCenterPage({
302
309
  }
303
310
 
304
311
  return (
305
- <TrustCenterChrome shell={shell} title={title} subtitle={subtitle} backButton={backButton} actions={actions}>
306
- {body}
307
- </TrustCenterChrome>
312
+ <>
313
+ <TrustCenterChrome shell={shell} title={title} subtitle={subtitle} backButton={backButton} actions={actions}>
314
+ {body}
315
+ </TrustCenterChrome>
316
+ {beforeFaq}
317
+ {faqLast && faqSection && data ? (
318
+ // The page's own content box at its FULL width (no section-rail column): a closing FAQ takes the
319
+ // same space the FAQ block takes at the foot of every other page.
320
+ <div className={shell ? 'bg-ods-bg' : undefined}>
321
+ <div className={shell ? 'page-shell-content mx-auto max-w-[1920px]' : 'page-shell-content'}>
322
+ <TrustSection id={faqSection.id} title={faqSection.label} lead={views.faq.lead}>
323
+ {views.faq.render(data)}
324
+ </TrustSection>
325
+ </div>
326
+ </div>
327
+ ) : null}
328
+ </>
308
329
  );
309
330
  }
310
331
 
@@ -72,7 +72,7 @@ const BODY_TEXT = 'text-h6 text-ods-text-secondary';
72
72
  * beside the section rail.
73
73
  */
74
74
  export const TRUST_STATUS_ROW_CLASS =
75
- 'flex flex-col gap-[var(--spacing-system-xs)] content-md:flex-row content-md:items-center content-md:justify-between';
75
+ 'flex flex-col gap-[var(--spacing-system-xsf)] content-md:flex-row content-md:items-center content-md:justify-between';
76
76
  export const TRUST_BODY_GRID_CLASS =
77
77
  'grid grid-cols-1 gap-[var(--spacing-system-xl)] content-lg:grid-cols-[minmax(0,1fr)_12rem]';
78
78
  export const TRUST_SECTIONS_COLUMN_CLASS = 'flex min-w-0 flex-col gap-[var(--spacing-system-xxl)]';
@@ -675,7 +675,7 @@ export function TrustCenterSkeleton() {
675
675
  <TextSkeleton.Body className="w-44" />
676
676
  </div>
677
677
  {/* `DataAttribution`'s own layout: two lines below `sm`, one row above it. */}
678
- <div className="flex shrink-0 flex-col gap-[var(--spacing-system-xxs)] content-sm:flex-row content-sm:items-center content-sm:gap-[var(--spacing-system-sf)]">
678
+ <div className="flex shrink-0 flex-col gap-[var(--spacing-system-xsf)] content-sm:flex-row content-sm:items-center content-sm:gap-[var(--spacing-system-sf)]">
679
679
  <div className="flex items-center gap-[var(--spacing-system-xsf)]">
680
680
  <UnifiedSkeleton className="size-4 rounded" />
681
681
  <TextSkeleton.Body className="w-36" />
@@ -8,6 +8,20 @@ export interface DataAttributionProps {
8
8
  source: string;
9
9
  /** ISO instant of the last successful sync, or null when it never ran. */
10
10
  lastUpdated: string | null;
11
+ /** One control at the end of the line: a way to take this data away (a `DownloadButton` for a file of it). */
12
+ action?: ReactNode;
13
+ }
14
+
15
+ const LINE_CLASS =
16
+ 'flex shrink-0 flex-col gap-[var(--spacing-system-xsf)] text-ods-text-primary text-h6 content-sm:flex-row content-sm:items-center content-sm:gap-[var(--spacing-system-sf)]';
17
+ const ITEM_CLASS = 'flex items-center gap-[var(--spacing-system-xsf)]';
18
+
19
+ function Separator() {
20
+ return (
21
+ <span className="hidden text-ods-text-secondary content-sm:inline" aria-hidden="true">
22
+ &middot;
23
+ </span>
24
+ );
11
25
  }
12
26
 
13
27
  /**
@@ -16,17 +30,16 @@ export interface DataAttributionProps {
16
30
  * UTC (`formatDate`), so a server-rendered page and its hydrated copy print the
17
31
  * same text.
18
32
  */
19
- export function DataAttribution({ icon, source, lastUpdated }: DataAttributionProps) {
33
+ export function DataAttribution({ icon, source, lastUpdated, action }: DataAttributionProps) {
20
34
  return (
21
- <div className="flex shrink-0 flex-col gap-[var(--spacing-system-xxs)] text-ods-text-primary text-h6 content-sm:flex-row content-sm:items-center content-sm:gap-[var(--spacing-system-sf)]">
22
- <div className="flex items-center gap-[var(--spacing-system-xsf)]">
35
+ <div className={LINE_CLASS}>
36
+ <div className={ITEM_CLASS}>
23
37
  {icon}
24
38
  <span>Data synced from {source}</span>
25
39
  </div>
26
- <span className="hidden text-ods-text-secondary content-sm:inline" aria-hidden="true">
27
- &middot;
28
- </span>
40
+ <Separator />
29
41
  <span className="text-ods-text-secondary">Last updated: {lastUpdated ? formatDate(lastUpdated) : 'Never'}</span>
42
+ {action ? <div className="flex content-sm:pl-[var(--spacing-system-xs)]">{action}</div> : null}
30
43
  </div>
31
44
  );
32
45
  }
@@ -0,0 +1,34 @@
1
+ import { Download01Icon } from '../icons-v2-generated/interface/download-01-icon';
2
+ import { Button, type ButtonProps } from './button/button';
3
+
4
+ export type DownloadButtonProps = Omit<ButtonProps, 'leftIcon' | 'children'> & {
5
+ /** What is downloaded, as the button says it ("Download PDF"). */
6
+ label: string;
7
+ };
8
+
9
+ /**
10
+ * THE download button: a regular small `Button` in the quiet `link` variant
11
+ * (no surface, secondary text like the facts it sits beside, the accent on
12
+ * hover) with the download glyph, wherever a surface hands the reader a file of
13
+ * what it shows (the Trust Center's PDF, the margin report's PDF). One look and
14
+ * one size, so a download reads the same on every page and never louder than
15
+ * the page's own action.
16
+ *
17
+ * With `href` it is a link that opens in a new tab (an attachment downloads
18
+ * there without leaving the page). With `onClick` the host fetches the file
19
+ * itself and passes `loading` while it does.
20
+ */
21
+ export function DownloadButton({ label, variant = 'link', size = 'small', href, ...props }: DownloadButtonProps) {
22
+ return (
23
+ <Button
24
+ variant={variant}
25
+ size={size}
26
+ leftIcon={<Download01Icon aria-hidden="true" />}
27
+ href={href}
28
+ {...(href ? { openInNewTab: true, prefetch: false } : {})}
29
+ {...props}
30
+ >
31
+ {label}
32
+ </Button>
33
+ );
34
+ }
@@ -108,6 +108,7 @@ export * from './device-card';
108
108
  export * from './device-card-compact';
109
109
  export * from './entity-image';
110
110
  export * from './data-attribution';
111
+ export * from './download-button';
111
112
  export * from './compliance-badge';
112
113
  export * from './feature-card';
113
114
  export * from './feature-list';
@@ -1,40 +1,35 @@
1
- import { MOBILE_APP_QR_PATH, MOBILE_APP_QR_VIEWBOX_SIZE } from '../../assets/mobile-app-qr';
1
+ import type { MobileAppInstallLink } from '../../types/downloads';
2
2
  import { cn } from '../../utils/cn';
3
- import { MOBILE_APP_INSTALL_URL } from '../../utils/mobile-app';
4
3
 
5
4
  export interface MobileAppQrProps {
5
+ /** The server's install link: the address and the artwork that encodes it. */
6
+ install: MobileAppInstallLink;
6
7
  /** Sizes the code; the caller owns its footprint. */
7
8
  className?: string;
8
9
  }
9
10
 
10
11
  /**
11
- * The install QR code, drawn inline rather than loaded from `/public` so the
12
- * modules take their colour from the surface they sit on.
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.
13
16
  *
14
- * Dark modules, and the caller supplies a light plate. Decoders binarize the
15
- * image and expect dark-on-light; inverting that is one of the two things they
16
- * reliably fail on. The other is a missing quiet zone, which is why the 4-module
17
- * margin is baked into the viewBox — keep it even when the plate is larger.
18
- *
19
- * That polarity holds only under the dark theme, where `text-ods-bg` is the dark
20
- * value and `bg-ods-bg-inverted` the light one; both flip under `.theme-light`.
21
- * Do not render this inside a light-theme scope: an inverted code is silently
22
- * unscannable rather than visibly broken.
23
- *
24
- * The artwork is the asset `MOBILE_APP_QR_PATH` (`assets/mobile-app-qr`), which
25
- * encodes {@link MOBILE_APP_INSTALL_URL}; the regeneration note lives there.
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.
26
22
  */
27
- export function MobileAppQr({ className }: MobileAppQrProps) {
23
+ export function MobileAppQr({ install, className }: MobileAppQrProps) {
28
24
  return (
29
25
  <svg
30
- viewBox={`0 0 ${MOBILE_APP_QR_VIEWBOX_SIZE} ${MOBILE_APP_QR_VIEWBOX_SIZE}`}
31
- fill="none"
26
+ viewBox={`0 0 ${install.qrViewBoxSize} ${install.qrViewBoxSize}`}
32
27
  shapeRendering="crispEdges"
33
28
  role="img"
34
- aria-label={`QR code for ${MOBILE_APP_INSTALL_URL}`}
35
- className={cn('h-[120px] w-[120px] text-ods-bg', className)}
29
+ aria-label={`QR code for ${install.url}`}
30
+ className={cn('h-[120px] w-[120px] text-ods-accent', className)}
36
31
  >
37
- <path stroke="currentColor" d={MOBILE_APP_QR_PATH} />
32
+ <path fill="currentColor" d={install.qrPath} />
38
33
  </svg>
39
34
  );
40
35
  }
@@ -44,10 +44,27 @@ export interface AppDownload {
44
44
  label: string | null;
45
45
  }
46
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
+
47
62
  /** Where the mobile app is installed from. A store the server names no listing for is null, and is not offered. */
48
63
  export interface MobileAppLinks {
49
64
  appStoreUrl: string | null;
50
65
  googlePlayUrl: string | null;
66
+ /** The install link and its QR code. Absent or null: no code is drawn. */
67
+ install?: MobileAppInstallLink | null;
51
68
  }
52
69
 
53
70
  /**