@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.
- package/dist/{chunk-3HZ6XSZC.js → chunk-2TXGR7IK.js} +136 -59
- package/dist/{chunk-WSDT7LYL.js → chunk-3UBWDR65.js} +1 -1
- package/dist/{chunk-CMZ3QTFQ.js → chunk-4I3K6LEH.js} +24 -0
- package/dist/{chunk-4SIZCHVZ.cjs → chunk-4LY6R4TN.cjs} +126 -49
- package/dist/{chunk-2NASBO7O.js → chunk-5D2K7TFT.js} +3 -3
- package/dist/{chunk-HFEYEPOG.cjs → chunk-5K6YKRP5.cjs} +29 -29
- package/dist/{chunk-7HYEQKN6.js → chunk-65C4WHG4.js} +6 -6
- package/dist/{chunk-DOPOHO6H.cjs → chunk-6HRQLM6T.cjs} +20 -20
- package/dist/{chunk-B2N4YX66.js → chunk-6YBUQAOH.js} +1 -1
- package/dist/{chunk-GANIUMRU.js → chunk-7UZ2JZJY.js} +3 -3
- package/dist/{chunk-4HJRHEXD.cjs → chunk-ABSWD4PQ.cjs} +27 -27
- package/dist/{chunk-VYVPII4W.js → chunk-AWWLEYAU.js} +6 -6
- package/dist/{chunk-V6B2FBZK.js → chunk-BCU6UUNQ.js} +8 -8
- package/dist/{chunk-LO3KCLEF.js → chunk-BUDZWXRG.js} +4 -4
- package/dist/{chunk-WBNAEYTQ.cjs → chunk-BYUIFT7Q.cjs} +6 -6
- package/dist/{chunk-KSGMZHVV.cjs → chunk-DFM572VH.cjs} +2 -2
- package/dist/{chunk-YDPKUAP4.js → chunk-DJ4IFQNQ.js} +2 -0
- package/dist/{chunk-DYDDAA6O.cjs → chunk-DWSBTGKT.cjs} +816 -798
- package/dist/{chunk-PSMJYZMV.js → chunk-DX3W7DPC.js} +2 -2
- package/dist/{chunk-PXAKSPLZ.js → chunk-E7NIZXDT.js} +20 -8
- package/dist/{chunk-6W46LVK4.js → chunk-ETQVXIYW.js} +6 -6
- package/dist/{chunk-742BGXNX.js → chunk-EWED6ISQ.js} +1 -1
- package/dist/{chunk-Q5327DXK.js → chunk-F33RMJOL.js} +11 -11
- package/dist/{chunk-RZQGIWK3.cjs → chunk-FDWYBJYD.cjs} +8 -8
- package/dist/{chunk-QF464PPL.cjs → chunk-GAFK3WNT.cjs} +62 -62
- package/dist/{chunk-6BLN5WDS.cjs → chunk-GYSTZD6X.cjs} +40 -40
- package/dist/{chunk-TORERAXD.js → chunk-HA2ASGJM.js} +6 -6
- package/dist/{chunk-KT3U3ZRP.cjs → chunk-HP4UUBJC.cjs} +16 -16
- package/dist/{chunk-DFQYAYBH.cjs → chunk-HQIOMFJH.cjs} +2 -0
- package/dist/{chunk-AZRI4XBP.js → chunk-I3UUMRO7.js} +1 -1
- package/dist/{chunk-VYZCKY5K.js → chunk-IF72N4PE.js} +7 -7
- package/dist/{chunk-RFUOIQSG.cjs → chunk-IITZ6JSC.cjs} +37 -37
- package/dist/{chunk-7LPR4RZ3.cjs → chunk-JQ55FFIK.cjs} +31 -31
- package/dist/{chunk-GQ7JJUIT.cjs → chunk-K2JUJ37D.cjs} +25 -1
- package/dist/{chunk-URW23QUM.js → chunk-K76ACBR3.js} +2 -2
- package/dist/{chunk-7IOU6BJE.js → chunk-KBPGSPXQ.js} +2387 -2369
- package/dist/{chunk-ILNN6QRW.js → chunk-KS3YLYVL.js} +4 -4
- package/dist/{chunk-2C72EWQR.cjs → chunk-KWBH63GV.cjs} +26 -26
- package/dist/{chunk-IYVFSLWJ.js → chunk-KZQO5EJQ.js} +4 -0
- package/dist/{chunk-IMTY3GRR.cjs → chunk-LV6UJD2K.cjs} +2 -2
- package/dist/{chunk-UWJMDFDO.cjs → chunk-MJ4SXOG2.cjs} +28 -28
- package/dist/{chunk-VF2CJXZ2.cjs → chunk-NFABAOMD.cjs} +77 -79
- package/dist/{chunk-B5LNX4GZ.js → chunk-NMGWNUKB.js} +3 -3
- package/dist/{chunk-RDP5UZXQ.cjs → chunk-P7Y626YJ.cjs} +370 -358
- package/dist/{chunk-P3JMAMHP.js → chunk-REETLEL5.js} +1 -1
- package/dist/{chunk-FFT56TYB.cjs → chunk-RUZIICXT.cjs} +2 -2
- package/dist/{chunk-ISCZ6UNF.cjs → chunk-RVQV6J5K.cjs} +75 -75
- package/dist/{chunk-VBHGJFA3.js → chunk-SZ3CBFSH.js} +13 -13
- package/dist/{chunk-O3MKJU6A.js → chunk-SZF7ENIW.js} +4 -4
- package/dist/{chunk-F5BPGWGT.cjs → chunk-TKFTWM3V.cjs} +5 -1
- package/dist/{chunk-YK3X74SF.js → chunk-UYMTAFHG.js} +6 -8
- package/dist/{chunk-DHNJSQGX.cjs → chunk-VC7COEWF.cjs} +105 -105
- package/dist/{chunk-XW3Q623Q.js → chunk-VSYQJCNR.js} +2 -2
- package/dist/{chunk-IL3ZWNM2.cjs → chunk-VU7RU3LL.cjs} +7 -7
- package/dist/{chunk-XSM46FHD.cjs → chunk-VV5INIQB.cjs} +3 -3
- package/dist/{chunk-HTS46FFY.cjs → chunk-VYHCN5NU.cjs} +16 -16
- package/dist/{chunk-ZPHOQDAE.js → chunk-WY4SLTMZ.js} +3 -3
- package/dist/{chunk-5XDCCQ4A.cjs → chunk-XJYAUIOL.cjs} +13 -13
- package/dist/{chunk-ZHD3F2TH.cjs → chunk-XR7NVJXS.cjs} +13 -13
- package/dist/{chunk-EVMM3JE6.cjs → chunk-ZHC6MDGF.cjs} +43 -43
- package/dist/{chunk-K2BYVPGR.js → chunk-ZMHP4Y6M.js} +5 -5
- package/dist/{chunk-NYSMDM4J.cjs → chunk-ZSOFLPQN.cjs} +7 -7
- package/dist/{cloud-tenants-RKQRSDNE.js → cloud-tenants-4Z36KWSX.js} +14 -14
- package/dist/{cloud-tenants-UQZASXUB.cjs → cloud-tenants-5NIC5VXK.cjs} +15 -15
- package/dist/components/calendar.cjs +4 -4
- package/dist/components/calendar.js +1 -1
- package/dist/components/case-studies/index.cjs +29 -29
- package/dist/components/case-studies/index.js +15 -15
- package/dist/components/chat/index.cjs +10 -10
- package/dist/components/chat/index.js +9 -9
- package/dist/components/contact/index.cjs +16 -16
- package/dist/components/contact/index.js +15 -15
- package/dist/components/docs/index.cjs +19 -19
- package/dist/components/docs/index.js +18 -18
- package/dist/components/embeds/index.cjs +16 -16
- package/dist/components/embeds/index.js +15 -15
- package/dist/components/faq/index.cjs +16 -16
- package/dist/components/faq/index.js +15 -15
- package/dist/components/features/index.cjs +15 -15
- package/dist/components/features/index.js +14 -14
- package/dist/components/features/remote-session/remote-access-request-card.d.ts.map +1 -1
- package/dist/components/help-center-pages/download-apps-page.d.ts +68 -0
- package/dist/components/help-center-pages/download-apps-page.d.ts.map +1 -0
- package/dist/components/help-center-pages/index.cjs +347 -117
- package/dist/components/help-center-pages/index.d.ts +1 -0
- package/dist/components/help-center-pages/index.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.js +260 -30
- package/dist/components/index.cjs +87 -85
- package/dist/components/index.js +30 -28
- package/dist/components/meeting-scheduler/index.cjs +67 -67
- package/dist/components/meeting-scheduler/index.js +16 -16
- package/dist/components/navigation/index.cjs +18 -18
- package/dist/components/navigation/index.js +17 -17
- package/dist/components/onboarding-guides/index.cjs +18 -18
- package/dist/components/onboarding-guides/index.js +17 -17
- package/dist/components/product-demo/index.cjs +8 -8
- package/dist/components/product-demo/index.js +8 -8
- package/dist/components/related-content/index.cjs +7 -7
- package/dist/components/related-content/index.js +6 -6
- package/dist/components/tickets/index.cjs +20 -20
- package/dist/components/tickets/index.js +19 -19
- package/dist/components/ui/button/button.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +11 -11
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +17 -15
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +16 -14
- package/dist/components/ui/mobile-app-qr.d.ts +21 -0
- package/dist/components/ui/mobile-app-qr.d.ts.map +1 -0
- package/dist/components/ui/store-badges.d.ts +7 -4
- package/dist/components/ui/store-badges.d.ts.map +1 -1
- package/dist/{devices-U3Y4IIW4.js → devices-2TK3LAKM.js} +14 -14
- package/dist/{devices-7Q2EUM7B.cjs → devices-7IKT72DK.cjs} +16 -16
- package/dist/hooks/index.cjs +9 -3
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -2
- package/dist/hooks/use-downloads.d.ts +41 -0
- package/dist/hooks/use-downloads.d.ts.map +1 -0
- package/dist/index.cjs +52 -20
- package/dist/index.js +50 -18
- package/dist/{knowledge-NST53EXE.js → knowledge-3GJ4MU3V.js} +14 -14
- package/dist/{knowledge-UYIZ74MA.cjs → knowledge-HYSEV35H.cjs} +16 -16
- package/dist/{logs-JVTRG232.js → logs-TATVM62H.js} +14 -14
- package/dist/{logs-HTH7X7KV.cjs → logs-ZX3NADQC.cjs} +24 -24
- package/dist/{policies-MXK2HB4W.cjs → policies-J4FSDKPI.cjs} +19 -19
- package/dist/{policies-DTXTRK56.js → policies-MRRKLF4Q.js} +14 -14
- package/dist/{remote-session-LAJZKQBN.js → remote-session-WCLR47RN.js} +6 -6
- package/dist/{remote-session-SKKF4PBV.cjs → remote-session-Z2YCUPHU.cjs} +8 -8
- package/dist/{software-update-P6BSM6HX.cjs → software-update-CXUBWYD2.cjs} +19 -19
- package/dist/{software-update-6RHMPM3W.js → software-update-KM46WUVQ.js} +14 -14
- package/dist/{tickets-board-BHLPAVNJ.cjs → tickets-board-R5OQSEEC.cjs} +10 -10
- package/dist/{tickets-board-PYDNELSK.js → tickets-board-U3RF3AO4.js} +5 -5
- package/dist/types/downloads.d.ts +83 -0
- package/dist/types/downloads.d.ts.map +1 -0
- package/dist/types/index.cjs +24 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +16 -1
- package/dist/utils/format.d.ts +11 -0
- package/dist/utils/format.d.ts.map +1 -1
- package/dist/utils/index.cjs +15 -0
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +13 -1
- package/dist/utils/mobile-app.cjs +37 -0
- package/dist/utils/mobile-app.d.ts +23 -0
- package/dist/utils/mobile-app.d.ts.map +1 -0
- package/dist/utils/mobile-app.js +34 -0
- package/package.json +7 -1
- package/src/components/features/remote-session/remote-access-request-card.tsx +6 -6
- package/src/components/help-center-pages/download-apps-page.tsx +388 -0
- package/src/components/help-center-pages/index.ts +1 -0
- package/src/components/ui/button/button.tsx +3 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/mobile-app-qr.tsx +35 -0
- package/src/components/ui/store-badges.tsx +17 -9
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-downloads.ts +138 -0
- package/src/types/downloads.ts +99 -0
- package/src/types/index.ts +1 -0
- package/src/utils/format.ts +15 -0
- package/src/utils/index.ts +3 -1
- 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
|
-
|
|
303
|
-
|
|
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
|
|
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
|
-
|
|
323
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/index.ts
CHANGED
package/src/utils/format.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|