@firecms/core 3.4.0-canary.cb27f16 → 3.4.0-canary.ce97da7

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 (88) hide show
  1. package/README.md +4 -0
  2. package/dist/components/ErrorBoundary.d.ts +1 -3
  3. package/dist/components/LicenseBanner.d.ts +23 -0
  4. package/dist/components/index.d.ts +1 -0
  5. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/plugin_translations.d.ts +38 -0
  8. package/dist/core/pro_plugins.d.ts +40 -0
  9. package/dist/de-DMp6cIY1.js +702 -0
  10. package/dist/de-DMp6cIY1.js.map +1 -0
  11. package/dist/editor/extensions/Image.d.ts +11 -0
  12. package/dist/editor/index.d.ts +2 -1
  13. package/dist/editor/lazy_editor.d.ts +6 -0
  14. package/dist/editor-BSo_aS1I.js +4563 -0
  15. package/dist/editor-BSo_aS1I.js.map +1 -0
  16. package/dist/fr-Cckz4IkR.js +702 -0
  17. package/dist/fr-Cckz4IkR.js.map +1 -0
  18. package/dist/hi-CDCoIqbu.js +702 -0
  19. package/dist/hi-CDCoIqbu.js.map +1 -0
  20. package/dist/hooks/index.d.ts +2 -0
  21. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  22. package/dist/hooks/useProjectLog.d.ts +11 -6
  23. package/dist/hooks/useTranslation.d.ts +1 -1
  24. package/dist/index.es.js +7938 -15928
  25. package/dist/index.es.js.map +1 -1
  26. package/dist/index.umd.js +7710 -6925
  27. package/dist/index.umd.js.map +1 -1
  28. package/dist/it-BJCUqixj.js +702 -0
  29. package/dist/it-BJCUqixj.js.map +1 -0
  30. package/dist/pl-DgT_joFf.js +704 -0
  31. package/dist/pl-DgT_joFf.js.map +1 -0
  32. package/dist/pt-B3dN3x0X.js +704 -0
  33. package/dist/pt-B3dN3x0X.js.map +1 -0
  34. package/dist/types/analytics.d.ts +1 -1
  35. package/dist/types/firecms.d.ts +18 -1
  36. package/dist/types/firecms_context.d.ts +8 -0
  37. package/dist/types/index.d.ts +1 -0
  38. package/dist/types/license.d.ts +54 -0
  39. package/dist/types/plugins.d.ts +9 -1
  40. package/dist/types/translations.d.ts +35 -2
  41. package/dist/util/date_fns_locales.d.ts +18 -0
  42. package/dist/util/index.d.ts +1 -0
  43. package/dist/util/lazy_eager.d.ts +3 -3
  44. package/package.json +4 -4
  45. package/src/app/Scaffold.tsx +7 -0
  46. package/src/components/ErrorBoundary.tsx +12 -18
  47. package/src/components/LicenseBanner.tsx +209 -0
  48. package/src/components/index.tsx +1 -0
  49. package/src/contexts/LicenseStatusContext.tsx +50 -0
  50. package/src/core/DefaultAppBar.tsx +28 -16
  51. package/src/core/FireCMS.tsx +73 -47
  52. package/src/core/SideDialogs.tsx +44 -1
  53. package/src/core/index.tsx +1 -0
  54. package/src/core/plugin_translations.ts +87 -0
  55. package/src/core/pro_plugins.ts +79 -0
  56. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  57. package/src/editor/extensions/Image.ts +17 -1
  58. package/src/editor/hooks/useProseMirror.ts +5 -0
  59. package/src/editor/index.ts +11 -1
  60. package/src/editor/lazy_editor.tsx +33 -0
  61. package/src/hooks/index.tsx +2 -0
  62. package/src/hooks/useBuildNavigationController.tsx +47 -7
  63. package/src/hooks/useFireCMSContext.tsx +7 -3
  64. package/src/hooks/useLicenseStatus.tsx +32 -0
  65. package/src/hooks/useProjectLog.tsx +27 -9
  66. package/src/hooks/useTranslation.ts +3 -1
  67. package/src/i18n/FireCMSi18nProvider.tsx +164 -34
  68. package/src/locales/de.ts +18 -2
  69. package/src/locales/en.ts +21 -3
  70. package/src/locales/es.ts +18 -2
  71. package/src/locales/fr.ts +18 -2
  72. package/src/locales/hi.ts +18 -2
  73. package/src/locales/it.ts +18 -2
  74. package/src/locales/pl.ts +18 -2
  75. package/src/locales/pt.ts +18 -2
  76. package/src/preview/components/DatePreview.tsx +2 -4
  77. package/src/types/analytics.ts +3 -0
  78. package/src/types/firecms.tsx +19 -1
  79. package/src/types/firecms_context.tsx +9 -0
  80. package/src/types/index.ts +1 -0
  81. package/src/types/license.ts +62 -0
  82. package/src/types/plugins.tsx +10 -1
  83. package/src/types/translations.ts +35 -2
  84. package/src/util/date_fns_locales.ts +168 -0
  85. package/src/util/index.ts +1 -0
  86. package/src/util/lazy_eager.tsx +30 -8
  87. package/src/util/navigation_utils.ts +6 -1
  88. package/src/util/useStorageUploadController.tsx +6 -1
@@ -0,0 +1,79 @@
1
+ import { AccessResponse, FireCMSPlugin } from "../types";
2
+
3
+ /**
4
+ * Keys of the plugins that need a FireCMS PRO license.
5
+ *
6
+ * Kept in sync by hand with `PRO_PLUGIN_KEYS` in
7
+ * `saas_backend/functions/src/business_rules/licenses.ts`, which decides from
8
+ * the same list whether a project needs a license. Change both together.
9
+ *
10
+ * @group Core
11
+ */
12
+ export const PRO_PLUGIN_KEYS: readonly string[] = [
13
+ "collection_editor",
14
+ "user_management",
15
+ "import_export",
16
+ "import",
17
+ "export",
18
+ "entity_history",
19
+ "data_enhancement",
20
+ "datatalk"
21
+ ];
22
+
23
+ /**
24
+ * PRO plugins that stay mounted while PRO is paused. `user_management` supplies
25
+ * sign-in checks and roles, so removing it would lock users out of the CMS
26
+ * instead of pausing a feature; its own Users and Roles views show a notice.
27
+ */
28
+ const KEPT_WHILE_PAUSED: readonly string[] = ["user_management"];
29
+
30
+ /**
31
+ * Whether PRO features are paused for this license status. Any `blocked`
32
+ * response pauses them, whatever its `licenseState` (`expired` and
33
+ * `invalid_project` both come with it). An unknown status pauses nothing.
34
+ *
35
+ * @group Core
36
+ */
37
+ export function isProPaused(licenseStatus?: AccessResponse | null): boolean {
38
+ return Boolean(licenseStatus?.blocked);
39
+ }
40
+
41
+ /**
42
+ * Whether this plugin is dropped while PRO is paused.
43
+ *
44
+ * @group Core
45
+ */
46
+ export function isPausedPlugin(plugin: Pick<FireCMSPlugin, "key">): boolean {
47
+ return PRO_PLUGIN_KEYS.includes(plugin.key) && !KEPT_WHILE_PAUSED.includes(plugin.key);
48
+ }
49
+
50
+ /**
51
+ * Where to get or fix the license for this status: the server's
52
+ * `subscribeUrl` when it is an https URL, otherwise the license page on
53
+ * app.firecms.co with the project, when known.
54
+ *
55
+ * @group Core
56
+ */
57
+ export function getLicenseSubscribeUrl(licenseStatus?: AccessResponse | null): string {
58
+ const subscribeUrl = licenseStatus?.subscribeUrl;
59
+ if (subscribeUrl && /^https:\/\//i.test(subscribeUrl))
60
+ return subscribeUrl;
61
+ const params = new URLSearchParams({ intent: "pro" });
62
+ if (licenseStatus?.projectId) params.set("projectId", licenseStatus.projectId);
63
+ return `https://app.firecms.co/subscriptions?${params.toString()}`;
64
+ }
65
+
66
+ /**
67
+ * The plugins that stay active under this license status. Returns the same
68
+ * array when nothing is dropped, so it can feed memo and effect dependencies.
69
+ *
70
+ * @group Core
71
+ */
72
+ export function filterPausedPlugins<P extends Pick<FireCMSPlugin, "key">>(
73
+ plugins: P[] | undefined,
74
+ licenseStatus?: AccessResponse | null
75
+ ): P[] | undefined {
76
+ if (!plugins || !isProPaused(licenseStatus)) return plugins;
77
+ const active = plugins.filter(plugin => !isPausedPlugin(plugin));
78
+ return active.length === plugins.length ? plugins : active;
79
+ }
@@ -198,11 +198,14 @@ const autocompleteSuggestionItem: SuggestionItem = {
198
198
  let buffer = "";
199
199
  const result = await aiController.autocomplete(textBeforeCursor, textAfterCursor, (delta) => {
200
200
  buffer += delta;
201
- if (delta.length !== 0) {
201
+ if (delta.length !== 0 && !view.isDestroyed) {
202
202
  textLoadingCommands.toggleLoadingDecoration(view.state, view.dispatch, buffer);
203
203
  }
204
204
  });
205
205
 
206
+ // The editor may have been unmounted while the completion streamed in.
207
+ if (view.isDestroyed) return;
208
+
206
209
  // Insert parsed text result at cursor natively
207
210
  try {
208
211
  // The AI controller might stream literal "\n" characters depending on its JSON decoding layer.
@@ -4,9 +4,20 @@ import { Plugin, PluginKey } from "prosemirror-state";
4
4
 
5
5
  export type UploadFn = (image: File) => Promise<string>;
6
6
 
7
+ /**
8
+ * Shows the image as a placeholder at `pos`, uploads it, and swaps the
9
+ * placeholder for the uploaded image once it has loaded.
10
+ *
11
+ * Every step after the first runs asynchronously — the FileReader callback that
12
+ * calls this, the upload, and the preload of the uploaded image — and the editor
13
+ * can be unmounted during any of them (the user closes the entity or navigates
14
+ * away while an image is still uploading). Dispatching on a destroyed
15
+ * `EditorView` throws inside ProseMirror (`docView` is null), so each step
16
+ * checks the view is still alive before touching it.
17
+ */
7
18
  export async function onFileRead(view: EditorView, readerEvent: ProgressEvent<FileReader>, pos: number, upload: UploadFn, image: File) {
8
19
 
9
- const { schema } = view.state;
20
+ if (view.isDestroyed) return;
10
21
 
11
22
  // @ts-ignore
12
23
  const plugin = view.state.plugins.find((p: Plugin) => p.key === ImagePluginKey.key);
@@ -31,7 +42,12 @@ export async function onFileRead(view: EditorView, readerEvent: ProgressEvent<Fi
31
42
  const src = await upload(image);
32
43
  console.debug("Uploaded image", src);
33
44
 
45
+ // The upload is kept (the caller stored the file), but there is no longer
46
+ // an editor to put it in.
47
+ if (view.isDestroyed) return;
48
+
34
49
  const replacePlaceholder = () => {
50
+ if (view.isDestroyed) return;
35
51
  // Retrieve the LATEST state after the async upload
36
52
  let currentDecos = plugin.getState(view.state) as DecorationSet;
37
53
  const foundDecos = currentDecos.find(undefined, undefined, spec => spec.id === decoId);
@@ -58,6 +58,11 @@ export function useProseMirror({ initialContent, editable = true, handleImageUpl
58
58
  state: defaultState,
59
59
  editable: () => editable,
60
60
  dispatchTransaction: (tr: Transaction) => {
61
+ // Async work (image uploads and their preload, AI autocomplete
62
+ // streams) can finish after the editor unmounted. Applying a
63
+ // transaction to a destroyed view throws inside ProseMirror
64
+ // (`docView` is null), so drop it: there is nothing to update.
65
+ if (editorView.isDestroyed) return;
61
66
  const newState = editorView.state.apply(tr);
62
67
  editorView.updateState(newState);
63
68
  setState(newState);
@@ -1,2 +1,12 @@
1
- export * from "./editor";
1
+ // The editor's runtime entry point is the lazy wrapper, not the implementation:
2
+ // a static re-export here would pull ProseMirror into every consumer of
3
+ // `@firecms/core`. The types come straight from the implementation and are
4
+ // erased at build time, so they cost nothing.
5
+ export { FireCMSEditor } from "./lazy_editor";
6
+ export type {
7
+ CustomEditorComponent,
8
+ MarkdownEditorConfig,
9
+ FireCMSEditorTextSize,
10
+ FireCMSEditorProps
11
+ } from "./editor";
2
12
  export * from "./types";
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ import { CircularProgressCenter } from "../components/CircularProgressCenter";
6
+ import type { FireCMSEditorProps } from "./editor";
7
+
8
+ /**
9
+ * `FireCMSEditor`, loaded on demand.
10
+ *
11
+ * The editor is built on ProseMirror — roughly 250KB across a dozen packages —
12
+ * and is only reachable from a markdown field, which most views never render.
13
+ * `MarkdownEditorFieldBinding` already loaded it lazily, but that split was
14
+ * defeated by `src/index.ts` re-exporting the editor from the package barrel:
15
+ * a static re-export puts the module back in the entry graph regardless of who
16
+ * imports it dynamically. So ProseMirror sat in `@firecms/core` for everyone.
17
+ *
18
+ * The name and props are unchanged, and this component carries its own Suspense
19
+ * boundary, so it stays a drop-in for the synchronous component it replaces —
20
+ * a consumer rendering it without a boundary of their own still works.
21
+ */
22
+ const LazyFireCMSEditor = React.lazy(() =>
23
+ import("./editor").then((module) => ({ default: module.FireCMSEditor })));
24
+
25
+ export function FireCMSEditor(props: FireCMSEditorProps) {
26
+ return (
27
+ <React.Suspense fallback={<CircularProgressCenter/>}>
28
+ <LazyFireCMSEditor {...props}/>
29
+ </React.Suspense>
30
+ );
31
+ }
32
+
33
+ FireCMSEditor.displayName = "FireCMSEditor";
@@ -14,6 +14,7 @@ export * from "./useDialogsController";
14
14
  export * from "./useSideDialogsController";
15
15
  export * from "./useSideEntityController";
16
16
  export * from "./useFireCMSContext";
17
+ export * from "./useAnalyticsController";
17
18
  export * from "./useSnackbarController";
18
19
  export * from "./useModeController";
19
20
  export * from "./useClipboard";
@@ -32,3 +33,4 @@ export * from "./useBuildModeController";
32
33
 
33
34
  export * from "./useValidateAuthenticator";
34
35
  export * from "./useTranslation";
36
+ export * from "./useLicenseStatus";
@@ -1,6 +1,8 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import equal from "react-fast-compare"
3
3
  import { useBlocker, useNavigate } from "react-router-dom";
4
+ import { filterPausedPlugins, isProPaused } from "../core/pro_plugins";
5
+ import { useLicenseStatus } from "./useLicenseStatus";
4
6
 
5
7
  import {
6
8
  AuthController,
@@ -115,7 +117,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
115
117
  views: viewsProp,
116
118
  adminViews: adminViewsProp,
117
119
  viewsOrder,
118
- plugins,
120
+ plugins: allPlugins,
119
121
  userConfigPersistence,
120
122
  dataSourceDelegate,
121
123
  disabled,
@@ -124,6 +126,27 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
124
126
 
125
127
  const navigate = useNavigate();
126
128
 
129
+ // While PRO is paused, PRO plugins contribute no collections, collection
130
+ // modifications, views or navigation entries. This hook runs above
131
+ // `FireCMS`, so the status arrives through the store `FireCMS` publishes to,
132
+ // and its arrival re-renders this hook, which resolves navigation again.
133
+ const licenseStatus = useLicenseStatus();
134
+ const proPaused = isProPaused(licenseStatus);
135
+ const plugins = useMemo(
136
+ () => filterPausedPlugins(allPlugins, licenseStatus),
137
+ [allPlugins, licenseStatus]
138
+ );
139
+
140
+ // Navigation resolves asynchronously, so a resolution started before the
141
+ // pause changed can finish after one started since. This ref holds the
142
+ // current pause so the older one is dropped instead of restoring what the
143
+ // paused plugins contributed. Declared before the effect that refreshes.
144
+ const proPausedRef = useRef(proPaused);
145
+ useEffect(() => {
146
+ proPausedRef.current = proPaused;
147
+ }, [proPaused]);
148
+ const resolvedProPausedRef = useRef<boolean | undefined>(undefined);
149
+
127
150
  const collectionsRef = useRef<EntityCollection[] | undefined>(undefined);
128
151
  const viewsRef = useRef<CMSView[] | undefined>(undefined);
129
152
  const adminViewsRef = useRef<CMSView[] | undefined>(undefined);
@@ -302,7 +325,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
302
325
  groups: uniqueGroups,
303
326
  onNavigationEntriesUpdate: onNavigationEntriesUpdateCallback!,
304
327
  };
305
- }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups]);
328
+ }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups, plugins]);
306
329
 
307
330
  const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
308
331
  if (!plugins) {
@@ -342,6 +365,8 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
342
365
 
343
366
  console.debug("Refreshing navigation");
344
367
 
368
+ const resolvingWhilePaused = proPaused;
369
+
345
370
  try {
346
371
 
347
372
  const [resolvedCollections = [], resolvedViews, resolvedAdminViews = []] = await Promise.all([
@@ -351,10 +376,21 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
351
376
  ]
352
377
  );
353
378
 
379
+ // Superseded: a newer resolution runs with the current pause.
380
+ if (resolvingWhilePaused !== proPausedRef.current)
381
+ return;
382
+
354
383
  const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder, undefined, onNavigationEntriesOrderUpdate);
355
384
 
356
- let shouldUpdateTopLevelNav = false;
357
- if (!areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
385
+ // When the pause changes, everything below is replaced, including
386
+ // what the comparisons ignore: they skip functions, and a plugin may
387
+ // have added only collection callbacks, or only regrouped entries.
388
+ const pauseChanged = resolvedProPausedRef.current !== undefined
389
+ && resolvedProPausedRef.current !== resolvingWhilePaused;
390
+ resolvedProPausedRef.current = resolvingWhilePaused;
391
+
392
+ let shouldUpdateTopLevelNav = pauseChanged;
393
+ if (pauseChanged || !areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
358
394
  collectionsRef.current = resolvedCollections;
359
395
  console.debug("Collections have changed", resolvedCollections);
360
396
  shouldUpdateTopLevelNav = true;
@@ -363,7 +399,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
363
399
  collectionsRef.current = resolvedCollections;
364
400
  shouldUpdateTopLevelNav = true;
365
401
  }
366
- if (!equal(viewsRef.current, resolvedViews)) {
402
+ if (pauseChanged || !equal(viewsRef.current, resolvedViews)) {
367
403
  viewsRef.current = resolvedViews;
368
404
  shouldUpdateTopLevelNav = true;
369
405
  }
@@ -400,6 +436,8 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
400
436
  viewsProp,
401
437
  adminViewsProp,
402
438
  computeTopNavigation,
439
+ plugins,
440
+ proPaused
403
441
  ]);
404
442
 
405
443
  useEffect(() => {
@@ -588,7 +626,9 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
588
626
  getParentCollectionIds,
589
627
  convertIdsToPaths,
590
628
  navigate,
591
- plugins
629
+ // All of them, paused or not: `FireCMS` sends every key to the license
630
+ // check and applies the same pause to what it renders.
631
+ plugins: allPlugins
592
632
  };
593
633
  }
594
634
 
@@ -12,6 +12,7 @@ import { useCustomizationController } from "./useCustomizationController";
12
12
  import { useAnalyticsController } from "./useAnalyticsController";
13
13
  import React, { useEffect } from "react";
14
14
  import { useInternalUserManagementController } from "./useInternalUserManagementController";
15
+ import { useLicenseStatus } from "./useLicenseStatus";
15
16
 
16
17
  /**
17
18
  * Hook to retrieve the {@link FireCMSContext}.
@@ -36,6 +37,7 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
36
37
  const customizationController = useCustomizationController();
37
38
  const analyticsController = useAnalyticsController();
38
39
  const userManagement = useInternalUserManagementController<USER>();
40
+ const licenseStatus = useLicenseStatus();
39
41
 
40
42
  const fireCMSContextRef = React.useRef<FireCMSContext<USER, AuthControllerType>>({
41
43
  authController,
@@ -49,7 +51,8 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
49
51
  dialogsController,
50
52
  customizationController,
51
53
  analyticsController,
52
- userManagement
54
+ userManagement,
55
+ licenseStatus
53
56
  });
54
57
 
55
58
  useEffect(() => {
@@ -65,9 +68,10 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
65
68
  dialogsController,
66
69
  customizationController,
67
70
  analyticsController,
68
- userManagement
71
+ userManagement,
72
+ licenseStatus
69
73
  };
70
- }, [authController, dialogsController, navigation, sideDialogsController]);
74
+ }, [authController, dialogsController, navigation, sideDialogsController, licenseStatus]);
71
75
 
72
76
  return fireCMSContextRef.current;
73
77
  }
@@ -0,0 +1,32 @@
1
+ import { useContext, useSyncExternalStore } from "react";
2
+ import { AccessResponse } from "../types";
3
+ import {
4
+ getLicenseStatusSnapshot,
5
+ getServerLicenseStatusSnapshot,
6
+ LicenseStatusContext,
7
+ subscribeLicenseStatus
8
+ } from "../contexts/LicenseStatusContext";
9
+
10
+ /**
11
+ * The FireCMS license status of this project: whether PRO runs on a trial, is
12
+ * licensed, or is paused, plus the link to get a license.
13
+ *
14
+ * `null` until the license check answers, and when it is not sent at all
15
+ * (`telemetry={false}` without an `apiKey`). A response from an older server
16
+ * may lack `licenseState`; treat that as unknown. PRO features are paused when
17
+ * `blocked` is true; use `isProPaused(status)` for that check.
18
+ *
19
+ * Works inside `FireCMS` and also above it, e.g. in the component that calls
20
+ * `useBuildNavigationController`.
21
+ *
22
+ * @group Hooks and utilities
23
+ */
24
+ export function useLicenseStatus(): AccessResponse | null {
25
+ const fromContext = useContext(LicenseStatusContext);
26
+ const published = useSyncExternalStore(
27
+ subscribeLicenseStatus,
28
+ getLicenseStatusSnapshot,
29
+ getServerLicenseStatusSnapshot
30
+ );
31
+ return fromContext !== undefined ? fromContext : published;
32
+ }
@@ -1,14 +1,10 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import { AuthController, DataSourceDelegate, FireCMSPlugin } from "../types";
2
+ import { AccessResponse, AuthController, DataSourceDelegate, FireCMSPlugin } from "../types";
3
+ import { PRO_PLUGIN_KEYS } from "../core/pro_plugins";
3
4
 
4
5
  export const DEFAULT_SERVER_DEV = "https://api-kdoe6pj3qq-ey.a.run.app";
5
6
  export const DEFAULT_SERVER = "https://api.firecms.co";
6
7
 
7
- export type AccessResponse = {
8
- blocked?: boolean;
9
- message?: string;
10
- }
11
-
12
8
  async function makeRequest(authController: AuthController, dataSourceKey: string, pluginKeys: string[] | undefined, apiKey?: string): Promise<AccessResponse> {
13
9
  let idToken: string | null;
14
10
  try {
@@ -40,24 +36,46 @@ export interface UseProjectLogParams {
40
36
  apiKey?: string;
41
37
  authController: AuthController;
42
38
  dataSourceDelegate: DataSourceDelegate;
39
+ /**
40
+ * Every plugin the app mounts, before any are paused: the server decides
41
+ * from these keys whether the project needs a license.
42
+ */
43
43
  plugins?: FireCMSPlugin<any, any, any>[];
44
+ /**
45
+ * When false, no request is sent, unless the request is a license check:
46
+ * with an `apiKey`, or with a PRO plugin mounted, it is always sent.
47
+ */
48
+ telemetry?: boolean;
44
49
  }
45
50
 
46
51
  export function useProjectLog({
47
52
  authController,
48
53
  dataSourceDelegate,
49
54
  plugins,
50
- apiKey
55
+ apiKey,
56
+ telemetry = true
51
57
  }: UseProjectLogParams): AccessResponse | null {
52
58
  const [accessResponse, setAccessResponse] = useState<AccessResponse | null>(null);
53
59
  const accessedUserRef = useRef<string | null>(null);
54
60
  const dataSourceKey = dataSourceDelegate.key;
55
61
  const pluginKeys = plugins?.map(plugin => plugin.key);
62
+ // Opting out is for apps without PRO plugins. With one mounted, the
63
+ // request is what starts the trial and pauses PRO when it ends, so
64
+ // skipping it would unlock PRO for good.
65
+ const runsProPlugin = (pluginKeys ?? []).some(key => PRO_PLUGIN_KEYS.includes(key));
66
+ const enabled = Boolean(apiKey) || runsProPlugin || telemetry !== false;
56
67
  useEffect(() => {
68
+ if (!enabled) return;
57
69
  if (authController.user && authController.user.uid !== accessedUserRef.current && !authController.initialLoading) {
58
- makeRequest(authController, dataSourceKey, pluginKeys, apiKey).then(setAccessResponse);
70
+ makeRequest(authController, dataSourceKey, pluginKeys, apiKey)
71
+ .then((response) => setAccessResponse(response && typeof response === "object" ? response : null))
72
+ .catch((error) => {
73
+ // The check never gates the CMS, so a failed one only
74
+ // leaves the license status unknown.
75
+ console.debug("FireCMS license check failed", error);
76
+ });
59
77
  accessedUserRef.current = authController.user.uid;
60
78
  }
61
- }, [authController, dataSourceKey, pluginKeys]);
79
+ }, [authController, dataSourceKey, pluginKeys, enabled]);
62
80
  return accessResponse;
63
81
  }
@@ -23,8 +23,10 @@ export function useTranslation() {
23
23
  * Also supports i18next interpolation variables, e.g.
24
24
  * t("add_to_field", { fieldName: "Tags" })
25
25
  * t("error_deleting", { message: err.message })
26
+ * Pass `count` as a number to pick a plural form (`key_one`, falling back
27
+ * to `key`); i18next ignores a string `count`.
26
28
  */
27
- const typedT = (key: string, vars?: Record<string, string>): string =>
29
+ const typedT = (key: string, vars?: Record<string, string | number>): string =>
28
30
  t(key, vars) as string;
29
31
 
30
32
  return { t: typedT, i18n };