@flamingo-stack/openframe-frontend-core 0.0.720 → 0.0.721
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/assets/index.cjs +6 -0
- package/dist/assets/index.d.ts +1 -0
- package/dist/assets/index.d.ts.map +1 -1
- package/dist/assets/index.js +5 -1
- package/dist/assets/mobile-app-qr.d.ts +17 -0
- package/dist/assets/mobile-app-qr.d.ts.map +1 -0
- package/dist/{chunk-7IOU6BJE.js → chunk-2P2M27EW.js} +2393 -2367
- package/dist/{chunk-3HZ6XSZC.js → chunk-2TXGR7IK.js} +136 -59
- package/dist/{chunk-LO3KCLEF.js → chunk-3LY7V3RF.js} +3 -3
- package/dist/{chunk-6BLN5WDS.cjs → chunk-3R32ZNA2.cjs} +40 -40
- package/dist/{chunk-WSDT7LYL.js → chunk-3UBWDR65.js} +1 -1
- package/dist/{chunk-XW3Q623Q.js → chunk-44Q6MV7C.js} +2 -2
- package/dist/{chunk-CMZ3QTFQ.js → chunk-4I3K6LEH.js} +24 -0
- package/dist/{chunk-4SIZCHVZ.cjs → chunk-4LY6R4TN.cjs} +126 -49
- package/dist/{chunk-TORERAXD.js → chunk-57UTI7ZH.js} +6 -6
- package/dist/{chunk-K2BYVPGR.js → chunk-5QXE5HMV.js} +4 -4
- package/dist/{chunk-QF464PPL.cjs → chunk-6AJIOW3S.cjs} +57 -57
- package/dist/{chunk-B2N4YX66.js → chunk-6YBUQAOH.js} +1 -1
- package/dist/{chunk-6W46LVK4.js → chunk-74N742ZG.js} +6 -6
- package/dist/{chunk-4HJRHEXD.cjs → chunk-7Z5FMQYR.cjs} +22 -22
- package/dist/{chunk-WBNAEYTQ.cjs → chunk-BYUIFT7Q.cjs} +6 -6
- package/dist/{chunk-VF2CJXZ2.cjs → chunk-BYVVE7HP.cjs} +58 -58
- package/dist/{chunk-AZRI4XBP.js → chunk-CO3FOZIH.js} +1 -1
- package/dist/{chunk-XSM46FHD.cjs → chunk-D2DH6VZL.cjs} +3 -3
- package/dist/{chunk-KSGMZHVV.cjs → chunk-DFM572VH.cjs} +2 -2
- package/dist/{chunk-YDPKUAP4.js → chunk-DJ4IFQNQ.js} +2 -0
- package/dist/{chunk-VBHGJFA3.js → chunk-E6SMMMKV.js} +12 -12
- package/dist/{chunk-DHNJSQGX.cjs → chunk-F7ORKKEF.cjs} +103 -103
- package/dist/{chunk-RFUOIQSG.cjs → chunk-GCK75D7F.cjs} +9 -9
- package/dist/{chunk-ZHD3F2TH.cjs → chunk-GGBYZKMS.cjs} +11 -11
- package/dist/{chunk-YK3X74SF.js → chunk-GYAXV3MZ.js} +3 -3
- package/dist/{chunk-RDP5UZXQ.cjs → chunk-HJMSNOBT.cjs} +344 -323
- package/dist/{chunk-DFQYAYBH.cjs → chunk-HQIOMFJH.cjs} +2 -0
- package/dist/{chunk-PXAKSPLZ.js → chunk-HXB7SEYG.js} +27 -6
- package/dist/{chunk-HTS46FFY.cjs → chunk-I2254QN6.cjs} +16 -16
- package/dist/{chunk-ILNN6QRW.js → chunk-JDBZ4ZHU.js} +3 -3
- package/dist/{chunk-GQ7JJUIT.cjs → chunk-K2JUJ37D.cjs} +25 -1
- package/dist/{chunk-URW23QUM.js → chunk-K76ACBR3.js} +2 -2
- package/dist/{chunk-2NASBO7O.js → chunk-KFOAV77K.js} +2 -2
- package/dist/{chunk-Q5327DXK.js → chunk-LAXKEBDD.js} +10 -10
- package/dist/{chunk-O3MKJU6A.js → chunk-LGKMR7NZ.js} +4 -4
- package/dist/{chunk-5XDCCQ4A.cjs → chunk-LODMNSQY.cjs} +13 -13
- package/dist/{chunk-V6B2FBZK.js → chunk-NJ5TBTJG.js} +7 -7
- package/dist/{chunk-HFEYEPOG.cjs → chunk-NMPVK53I.cjs} +29 -29
- package/dist/{chunk-GANIUMRU.js → chunk-NPSJE7J3.js} +1 -1
- package/dist/{chunk-IL3ZWNM2.cjs → chunk-O5EHOJZD.cjs} +4 -4
- package/dist/{chunk-KT3U3ZRP.cjs → chunk-OKE5BIQH.cjs} +8 -8
- package/dist/{chunk-RZQGIWK3.cjs → chunk-QLTXT562.cjs} +2 -2
- package/dist/{chunk-PSMJYZMV.js → chunk-QQCFPMAA.js} +2 -2
- package/dist/{chunk-7LPR4RZ3.cjs → chunk-QYS4YVEP.cjs} +31 -31
- package/dist/{chunk-VYVPII4W.js → chunk-RCI3PALX.js} +6 -6
- package/dist/{chunk-P3JMAMHP.js → chunk-REETLEL5.js} +1 -1
- package/dist/{chunk-2C72EWQR.cjs → chunk-RJMWJAJO.cjs} +26 -26
- package/dist/{chunk-ISCZ6UNF.cjs → chunk-RQZQHLNS.cjs} +73 -73
- package/dist/{chunk-FFT56TYB.cjs → chunk-RUZIICXT.cjs} +2 -2
- package/dist/{chunk-7HYEQKN6.js → chunk-S63QBBT7.js} +5 -5
- package/dist/{chunk-B5LNX4GZ.js → chunk-UXU7Y5BH.js} +2 -2
- package/dist/{chunk-DYDDAA6O.cjs → chunk-UZTOI6FJ.cjs} +766 -740
- package/dist/{chunk-EVMM3JE6.cjs → chunk-VFICDEYJ.cjs} +43 -43
- package/dist/{chunk-DOPOHO6H.cjs → chunk-XE2K53GH.cjs} +18 -18
- package/dist/{chunk-UWJMDFDO.cjs → chunk-XXO52RZR.cjs} +26 -26
- package/dist/{chunk-VYZCKY5K.js → chunk-ZLUFEWZZ.js} +7 -7
- package/dist/{chunk-NYSMDM4J.cjs → chunk-ZSOFLPQN.cjs} +7 -7
- package/dist/{chunk-ZPHOQDAE.js → chunk-ZZEKXGFS.js} +1 -1
- package/dist/{cloud-tenants-UQZASXUB.cjs → cloud-tenants-HZHKRUIW.cjs} +13 -13
- package/dist/{cloud-tenants-RKQRSDNE.js → cloud-tenants-Q4TCJS5Y.js} +12 -12
- package/dist/components/calendar.cjs +4 -4
- package/dist/components/calendar.js +1 -1
- package/dist/components/case-studies/index.cjs +27 -27
- package/dist/components/case-studies/index.js +13 -13
- package/dist/components/chat/index.cjs +8 -8
- package/dist/components/chat/index.js +7 -7
- package/dist/components/contact/index.cjs +14 -14
- package/dist/components/contact/index.js +13 -13
- package/dist/components/docs/index.cjs +17 -17
- package/dist/components/docs/index.js +16 -16
- package/dist/components/embeds/index.cjs +14 -14
- package/dist/components/embeds/index.js +13 -13
- package/dist/components/faq/index.cjs +14 -14
- package/dist/components/faq/index.js +13 -13
- package/dist/components/features/index.cjs +13 -13
- package/dist/components/features/index.js +12 -12
- package/dist/components/help-center-pages/download-apps-page.d.ts +55 -0
- package/dist/components/help-center-pages/download-apps-page.d.ts.map +1 -0
- package/dist/components/help-center-pages/index.cjs +329 -114
- 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 +243 -28
- package/dist/components/index.cjs +81 -79
- package/dist/components/index.js +28 -26
- package/dist/components/meeting-scheduler/index.cjs +51 -51
- package/dist/components/meeting-scheduler/index.js +14 -14
- package/dist/components/navigation/index.cjs +16 -16
- package/dist/components/navigation/index.js +15 -15
- package/dist/components/onboarding-guides/index.cjs +16 -16
- package/dist/components/onboarding-guides/index.js +15 -15
- 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 +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +18 -18
- package/dist/components/tickets/index.js +17 -17
- 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 +15 -13
- 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 +14 -12
- package/dist/components/ui/mobile-app-qr.d.ts +23 -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-7Q2EUM7B.cjs → devices-6XHUFIC6.cjs} +14 -14
- package/dist/{devices-U3Y4IIW4.js → devices-NOUGVTES.js} +12 -12
- 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 +57 -17
- package/dist/index.js +56 -16
- package/dist/{knowledge-NST53EXE.js → knowledge-2YTUXQ2Z.js} +12 -12
- package/dist/{knowledge-UYIZ74MA.cjs → knowledge-ANEKPMO2.cjs} +14 -14
- package/dist/{logs-HTH7X7KV.cjs → logs-U3IB3HUM.cjs} +22 -22
- package/dist/{logs-JVTRG232.js → logs-WVQIONUT.js} +12 -12
- package/dist/{policies-DTXTRK56.js → policies-66EGDW2H.js} +12 -12
- package/dist/{policies-MXK2HB4W.cjs → policies-UFRN2EQF.cjs} +17 -17
- package/dist/{remote-session-LAJZKQBN.js → remote-session-6WEUJZDC.js} +4 -4
- package/dist/{remote-session-SKKF4PBV.cjs → remote-session-A75X5NPC.cjs} +6 -6
- package/dist/{software-update-P6BSM6HX.cjs → software-update-2MHKL76D.cjs} +17 -17
- package/dist/{software-update-6RHMPM3W.js → software-update-3ZD3MXF3.js} +12 -12
- package/dist/{tickets-board-BHLPAVNJ.cjs → tickets-board-IFWMIZ35.cjs} +9 -9
- package/dist/{tickets-board-PYDNELSK.js → tickets-board-MYXG3V72.js} +4 -4
- package/dist/types/downloads.d.ts +67 -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/index.cjs +20 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +16 -1
- package/dist/utils/mobile-app.cjs +46 -0
- package/dist/utils/mobile-app.d.ts +40 -0
- package/dist/utils/mobile-app.d.ts.map +1 -0
- package/dist/utils/mobile-app.js +40 -0
- package/package.json +7 -1
- package/src/assets/index.ts +1 -0
- package/src/assets/mobile-app-qr.ts +19 -0
- package/src/components/help-center-pages/download-apps-page.tsx +364 -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 +40 -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 +82 -0
- package/src/types/index.ts +1 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/mobile-app.ts +57 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { MOBILE_APP_QR_PATH, MOBILE_APP_QR_VIEWBOX_SIZE } from '../../assets/mobile-app-qr';
|
|
2
|
+
import { cn } from '../../utils/cn';
|
|
3
|
+
import { MOBILE_APP_INSTALL_URL } from '../../utils/mobile-app';
|
|
4
|
+
|
|
5
|
+
export interface MobileAppQrProps {
|
|
6
|
+
/** Sizes the code; the caller owns its footprint. */
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
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.
|
|
13
|
+
*
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export function MobileAppQr({ className }: MobileAppQrProps) {
|
|
28
|
+
return (
|
|
29
|
+
<svg
|
|
30
|
+
viewBox={`0 0 ${MOBILE_APP_QR_VIEWBOX_SIZE} ${MOBILE_APP_QR_VIEWBOX_SIZE}`}
|
|
31
|
+
fill="none"
|
|
32
|
+
shapeRendering="crispEdges"
|
|
33
|
+
role="img"
|
|
34
|
+
aria-label={`QR code for ${MOBILE_APP_INSTALL_URL}`}
|
|
35
|
+
className={cn('h-[120px] w-[120px] text-ods-bg', className)}
|
|
36
|
+
>
|
|
37
|
+
<path stroke="currentColor" d={MOBILE_APP_QR_PATH} />
|
|
38
|
+
</svg>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -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,82 @@
|
|
|
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
|
+
/** Where the mobile app is installed from. A store the server names no listing for is null, and is not offered. */
|
|
48
|
+
export interface MobileAppLinks {
|
|
49
|
+
appStoreUrl: string | null;
|
|
50
|
+
googlePlayUrl: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Everything the download page shows. All of it is the server's: the lib holds
|
|
55
|
+
* no link of its own and falls back to none, so what is missing is not drawn.
|
|
56
|
+
*/
|
|
57
|
+
export interface DownloadsPublic {
|
|
58
|
+
/** The desktop installers and install commands, in the order they are offered. Per system, the first installer is the default. */
|
|
59
|
+
desktop: AppDownload[];
|
|
60
|
+
/** The mobile app's store listings. */
|
|
61
|
+
mobile: MobileAppLinks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A system's name on a button and a card. */
|
|
65
|
+
export const DESKTOP_OS_LABELS: Record<DesktopOs, string> = { mac: 'Mac', windows: 'Windows', linux: 'Linux' };
|
|
66
|
+
|
|
67
|
+
/** An architecture's name beside a button, when a system has more than one installer. */
|
|
68
|
+
export const DOWNLOAD_ARCHITECTURE_LABELS: Record<string, string> = {
|
|
69
|
+
universal: 'Apple Silicon and Intel',
|
|
70
|
+
x64: 'x64',
|
|
71
|
+
arm64: 'ARM',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** What a host learns when a visitor takes an install action on the page. */
|
|
75
|
+
export interface DownloadActionEvent {
|
|
76
|
+
/** `installer`: a download button. `command`: a copied install command. */
|
|
77
|
+
action: 'installer' | 'command';
|
|
78
|
+
/** The row's id (`mac`, `windows-arm64`, `homebrew`). */
|
|
79
|
+
id: string;
|
|
80
|
+
os: DesktopOs;
|
|
81
|
+
architecture: string | null;
|
|
82
|
+
}
|
package/src/types/index.ts
CHANGED
package/src/utils/index.ts
CHANGED
|
@@ -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,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The download page's own addresses: where it lives, the parameter that sends a
|
|
3
|
+
* phone on to its store, and the URL the install QR code encodes. The store
|
|
4
|
+
* listings and installer links are NOT here: they are the server's
|
|
5
|
+
* (`DownloadsPublic`, 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
|
+
* On the download page: "send a phone or a tablet on to its store". The install
|
|
18
|
+
* QR code carries it, because whoever scans a code is holding the phone the app
|
|
19
|
+
* goes on. Anyone who opens the page without it reads the page.
|
|
20
|
+
*/
|
|
21
|
+
export const DOWNLOAD_PAGE_STORE_PARAM = 'store';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one URL the install QR code encodes: the website's download page, asking
|
|
25
|
+
* it to send a phone on to its store.
|
|
26
|
+
*
|
|
27
|
+
* Absolute and on the production host on purpose: a code must point at
|
|
28
|
+
* production whichever deployment rendered the page it came from. `MobileAppQr`
|
|
29
|
+
* draws this value as committed path data, so the two change together: see the
|
|
30
|
+
* regeneration note on that component.
|
|
31
|
+
*/
|
|
32
|
+
export const MOBILE_APP_INSTALL_URL = `https://www.flamingo.run${DOWNLOAD_PAGE_PATH}?${DOWNLOAD_PAGE_STORE_PARAM}=1`;
|
|
33
|
+
|
|
34
|
+
/** The download page as it is printed for a human to read or type: no scheme, no `www`, no query. */
|
|
35
|
+
export const MOBILE_APP_INSTALL_HOST_PATH = MOBILE_APP_INSTALL_URL.replace(/^https:\/\/(www\.)?/, '').replace(
|
|
36
|
+
/\?.*$/,
|
|
37
|
+
'',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The store for a phone or tablet, or `null` for every other system and for a
|
|
42
|
+
* store the server names no listing for (`links` is the server's answer).
|
|
43
|
+
*
|
|
44
|
+
* The server passes the request's User-Agent; a browser also passes
|
|
45
|
+
* `navigator.maxTouchPoints`, the only signal that tells an iPad asking for the
|
|
46
|
+
* desktop site (it calls itself a Macintosh) from a real Mac.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveMobileStoreUrl(
|
|
49
|
+
links: MobileAppLinks,
|
|
50
|
+
userAgent: string | null | undefined,
|
|
51
|
+
maxTouchPoints?: number,
|
|
52
|
+
): string | null {
|
|
53
|
+
const os = detectVisitorOs(userAgent, { maxTouchPoints });
|
|
54
|
+
if (os === 'ios') return links.appStoreUrl;
|
|
55
|
+
if (os === 'android') return links.googlePlayUrl;
|
|
56
|
+
return null;
|
|
57
|
+
}
|