@carlonicora/nextjs-jsonapi 6.10.2 → 6.12.0

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 (113) hide show
  1. package/dist/{BlockNoteEditor-LX7NMBV6.js → BlockNoteEditor-R2SLOYS6.js} +84 -43
  2. package/dist/BlockNoteEditor-R2SLOYS6.js.map +1 -0
  3. package/dist/{BlockNoteEditor-7GQWHXBC.mjs → BlockNoteEditor-ZJPGGUQQ.mjs} +61 -20
  4. package/dist/BlockNoteEditor-ZJPGGUQQ.mjs.map +1 -0
  5. package/dist/{BlockNoteViewer-NXV5PKR4.js → BlockNoteViewer-FLUHEXE6.js} +3 -3
  6. package/dist/{BlockNoteViewer-NXV5PKR4.js.map → BlockNoteViewer-FLUHEXE6.js.map} +1 -1
  7. package/dist/{BlockNoteViewer-D3Y4FUST.mjs → BlockNoteViewer-UCA3LV3N.mjs} +2 -2
  8. package/dist/billing/index.js +491 -491
  9. package/dist/billing/index.mjs +3 -3
  10. package/dist/{chunk-QB2XU64X.mjs → chunk-6RLATJ2L.mjs} +2 -2
  11. package/dist/{chunk-62P4KIR2.mjs → chunk-BGLOFZ3K.mjs} +282 -2
  12. package/dist/chunk-BGLOFZ3K.mjs.map +1 -0
  13. package/dist/{chunk-3I5JCFPY.js → chunk-BXR36J25.js} +1647 -969
  14. package/dist/chunk-BXR36J25.js.map +1 -0
  15. package/dist/{chunk-62VVBUIL.js → chunk-FNPXCYHO.js} +8 -8
  16. package/dist/{chunk-62VVBUIL.js.map → chunk-FNPXCYHO.js.map} +1 -1
  17. package/dist/{chunk-TOPGUZSX.js → chunk-OHJSI6L2.js} +4 -4
  18. package/dist/{chunk-TOPGUZSX.js.map → chunk-OHJSI6L2.js.map} +1 -1
  19. package/dist/{chunk-LDUTS7SJ.mjs → chunk-SQUMPLNN.mjs} +1281 -603
  20. package/dist/chunk-SQUMPLNN.mjs.map +1 -0
  21. package/dist/{chunk-Z4OVEA3N.js → chunk-YGEOX2XX.js} +283 -3
  22. package/dist/chunk-YGEOX2XX.js.map +1 -0
  23. package/dist/{chunk-GHAZOQTT.mjs → chunk-YP6W4AB5.mjs} +2 -2
  24. package/dist/client/index.js +4 -4
  25. package/dist/client/index.mjs +3 -3
  26. package/dist/components/index.d.mts +88 -29
  27. package/dist/components/index.d.ts +88 -29
  28. package/dist/components/index.js +10 -4
  29. package/dist/components/index.js.map +1 -1
  30. package/dist/components/index.mjs +9 -3
  31. package/dist/contexts/index.js +4 -4
  32. package/dist/contexts/index.mjs +3 -3
  33. package/dist/core/index.d.mts +159 -3
  34. package/dist/core/index.d.ts +159 -3
  35. package/dist/core/index.js +14 -2
  36. package/dist/core/index.js.map +1 -1
  37. package/dist/core/index.mjs +13 -1
  38. package/dist/email-automations/index.js +93 -93
  39. package/dist/email-automations/index.mjs +3 -3
  40. package/dist/features/analytics/index.js +120 -122
  41. package/dist/features/analytics/index.js.map +1 -1
  42. package/dist/features/analytics/index.mjs +7 -9
  43. package/dist/features/analytics/index.mjs.map +1 -1
  44. package/dist/features/analytics/server.js +4 -4
  45. package/dist/features/analytics/server.mjs +2 -2
  46. package/dist/features/blog/index.js +150 -150
  47. package/dist/features/blog/index.mjs +3 -3
  48. package/dist/features/blog/server.js +16 -16
  49. package/dist/features/blog/server.mjs +1 -1
  50. package/dist/features/help/index.js +37 -37
  51. package/dist/features/help/index.mjs +3 -3
  52. package/dist/features/tokenusage/index.js +126 -128
  53. package/dist/features/tokenusage/index.js.map +1 -1
  54. package/dist/features/tokenusage/index.mjs +14 -16
  55. package/dist/features/tokenusage/index.mjs.map +1 -1
  56. package/dist/index.d.mts +2 -2
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.js +15 -3
  59. package/dist/index.js.map +1 -1
  60. package/dist/index.mjs +14 -2
  61. package/dist/{HandbookSectionInterface-5I57rC1b.d.mts → referral-admin.interface-C87ApGMR.d.mts} +41 -1
  62. package/dist/{HandbookSectionInterface-IDYEzQMi.d.ts → referral-admin.interface-CMwl5NiV.d.ts} +41 -1
  63. package/dist/server/index.js +3 -3
  64. package/dist/server/index.mjs +2 -2
  65. package/messages/en.json +78 -1
  66. package/messages/it.json +78 -1
  67. package/package.json +8 -3
  68. package/src/components/editors/BlockNoteEditor.tsx +52 -20
  69. package/src/components/editors/__tests__/blocknote-editor-options.spec.ts +81 -0
  70. package/src/components/editors/blockNoteEditorOptions.ts +58 -0
  71. package/src/core/index.ts +2 -0
  72. package/src/core/registry/ModuleRegistry.ts +5 -0
  73. package/src/features/referral/__tests__/referral-admin-models.spec.ts +122 -0
  74. package/src/features/referral/__tests__/referral-admin-service.spec.ts +70 -0
  75. package/src/features/referral/__tests__/referral-code-capture.spec.tsx +105 -0
  76. package/src/features/referral/__tests__/referral-invite-errors.spec.tsx +82 -0
  77. package/src/features/referral/__tests__/referral-visit-service.spec.ts +47 -0
  78. package/src/features/referral/__tests__/referrals-admin-container.spec.tsx +230 -0
  79. package/src/features/referral/components/ReferralCodeCapture.tsx +3 -0
  80. package/src/features/referral/components/ReferralDialog.tsx +9 -13
  81. package/src/features/referral/components/ReferralWidget.tsx +75 -45
  82. package/src/features/referral/components/ReferralsAdminFunnel.tsx +74 -0
  83. package/src/features/referral/components/ReferralsAdminTiles.tsx +128 -0
  84. package/src/features/referral/components/containers/ReferralsAdminContainer.tsx +205 -0
  85. package/src/features/referral/components/index.ts +3 -0
  86. package/src/features/referral/data/ReferralAdminReferral.ts +65 -0
  87. package/src/features/referral/data/ReferralAdminReferrer.ts +65 -0
  88. package/src/features/referral/data/ReferralAdminService.ts +51 -0
  89. package/src/features/referral/data/ReferralAdminSummary.ts +60 -0
  90. package/src/features/referral/data/ReferralService.ts +20 -0
  91. package/src/features/referral/data/ReferralVisit.ts +23 -0
  92. package/src/features/referral/data/index.ts +5 -0
  93. package/src/features/referral/hooks/index.ts +2 -0
  94. package/src/features/referral/hooks/useReferralAdminReferralTableStructure.tsx +127 -0
  95. package/src/features/referral/hooks/useReferralAdminReferrerTableStructure.tsx +229 -0
  96. package/src/features/referral/hooks/useReferralInvite.ts +2 -0
  97. package/src/features/referral/index.ts +1 -0
  98. package/src/features/referral/interfaces/index.ts +1 -0
  99. package/src/features/referral/interfaces/referral-admin.interface.ts +45 -0
  100. package/src/features/referral/referral-admin.modules.ts +32 -0
  101. package/dist/BlockNoteEditor-7GQWHXBC.mjs.map +0 -1
  102. package/dist/BlockNoteEditor-LX7NMBV6.js.map +0 -1
  103. package/dist/chunk-3I5JCFPY.js.map +0 -1
  104. package/dist/chunk-62P4KIR2.mjs.map +0 -1
  105. package/dist/chunk-D6UX37IA.mjs +0 -115
  106. package/dist/chunk-D6UX37IA.mjs.map +0 -1
  107. package/dist/chunk-KEHP2OBQ.js +0 -115
  108. package/dist/chunk-KEHP2OBQ.js.map +0 -1
  109. package/dist/chunk-LDUTS7SJ.mjs.map +0 -1
  110. package/dist/chunk-Z4OVEA3N.js.map +0 -1
  111. /package/dist/{BlockNoteViewer-D3Y4FUST.mjs.map → BlockNoteViewer-UCA3LV3N.mjs.map} +0 -0
  112. /package/dist/{chunk-QB2XU64X.mjs.map → chunk-6RLATJ2L.mjs.map} +0 -0
  113. /package/dist/{chunk-GHAZOQTT.mjs.map → chunk-YP6W4AB5.mjs.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "6.10.2",
3
+ "version": "6.12.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -143,7 +143,8 @@
143
143
  "next-intl": ">=4.0.0",
144
144
  "react": ">=19.3.0",
145
145
  "react-dom": ">=19.3.0",
146
- "react-hook-form": ">=7.0.0"
146
+ "react-hook-form": ">=7.0.0",
147
+ "yjs": "^13.6.33"
147
148
  },
148
149
  "peerDependenciesMeta": {
149
150
  "@stripe/react-stripe-js": {
@@ -163,6 +164,9 @@
163
164
  },
164
165
  "react-hook-form": {
165
166
  "optional": true
167
+ },
168
+ "yjs": {
169
+ "optional": true
166
170
  }
167
171
  },
168
172
  "dependencies": {
@@ -264,7 +268,8 @@
264
268
  "tsup": "^8.5.1",
265
269
  "typescript": "^6.0.3",
266
270
  "vite": "^8.3.4",
267
- "vitest": "^5.0.3"
271
+ "vitest": "^5.0.3",
272
+ "yjs": "^13.6.33"
268
273
  },
269
274
  "keywords": [
270
275
  "nextjs",
@@ -42,6 +42,7 @@ import { BlockNoteDiffUtil, BlockNoteWordDiffRendererUtil, cn } from "../../util
42
42
  import { errorToast } from "../errors";
43
43
  import { BlockNoteAiGenerateAction, generateActionItems } from "./BlockNoteEditorAiActions";
44
44
  import { BlockNoteEditorFormattingToolbar } from "./BlockNoteEditorFormattingToolbar";
45
+ import { type BlockNoteCollaboration, buildBlockNoteEditorOptions, resolveEditable } from "./blockNoteEditorOptions";
45
46
  import { BlockNoteEditorMentionHoverCard } from "./BlockNoteEditorMentionHoverCard";
46
47
  import {
47
48
  createMentionInlineContentSpec,
@@ -115,6 +116,19 @@ export type BlockNoteEditorProps = {
115
116
  // is on, so a control outside the editor can run a whole-document AI action.
116
117
  // Cleared on unmount and whenever AI is unavailable.
117
118
  aiHandleRef?: React.MutableRefObject<BlockNoteAiHandle | null>;
119
+ /**
120
+ * Live co-editing (Yjs). When set, the Yjs document is the content: used
121
+ * instead of `initialContent`/`onChange`. Changing `collaboration.fragment`
122
+ * rebuilds the editor.
123
+ */
124
+ collaboration?: BlockNoteCollaboration;
125
+ /** true: image uploads are off (no paste/drop upload, no upload tab). */
126
+ disableUploads?: boolean;
127
+ /**
128
+ * Overrides the default `onChange !== undefined` editability, e.g. for a
129
+ * collaborative editor, which has no `onChange`.
130
+ */
131
+ editable?: boolean;
118
132
  };
119
133
 
120
134
  function isBlockEmpty(block: any): boolean {
@@ -429,7 +443,11 @@ export default function BlockNoteEditor({
429
443
  stretch,
430
444
  aiEnabled = true,
431
445
  aiHandleRef,
446
+ collaboration,
447
+ disableUploads,
448
+ editable,
432
449
  }: BlockNoteEditorProps): React.JSX.Element {
450
+ const isEditable = resolveEditable({ editable, onChange });
433
451
  const t = useTranslations();
434
452
  const locale = useI18nLocale();
435
453
  const { resolvedTheme } = useTheme();
@@ -700,21 +718,30 @@ export default function BlockNoteEditor({
700
718
 
701
719
  const editor = useCreateBlockNote(
702
720
  useMemo(
703
- () => ({
704
- placeholders: {
705
- emptyDocument: placeholder || t(`common.blocknote.placeholder`),
706
- },
721
+ () =>
722
+ buildBlockNoteEditorOptions({
723
+ placeholder: placeholder || t(`common.blocknote.placeholder`),
724
+ schema,
725
+ initialContent: validatedInitialContent,
726
+ uploadFile: uploadImage,
727
+ disableUploads,
728
+ aiExtension,
729
+ coreDictionary,
730
+ aiDictionary,
731
+ collaboration,
732
+ }),
733
+ [
734
+ placeholder,
735
+ t,
707
736
  schema,
708
- initialContent: validatedInitialContent,
709
- uploadFile: uploadImage,
710
- extensions: aiExtension ? [aiExtension] : undefined,
711
- dictionary: aiExtension
712
- ? { ...coreDictionary, ai: aiDictionary }
713
- : coreDictionary === coreLocales.en
714
- ? undefined
715
- : coreDictionary,
716
- }),
717
- [placeholder, t, schema, validatedInitialContent, uploadImage, aiExtension, coreDictionary, aiDictionary],
737
+ validatedInitialContent,
738
+ uploadImage,
739
+ disableUploads,
740
+ aiExtension,
741
+ coreDictionary,
742
+ aiDictionary,
743
+ collaboration,
744
+ ],
718
745
  ),
719
746
  // useCreateBlockNote memoises on THIS list, not on the options' identity
720
747
  // (@blocknote/react: useMemo(() => BlockNoteEditor.create(options), deps),
@@ -723,7 +750,8 @@ export default function BlockNoteEditor({
723
750
  // rendered the AI UI over an editor with no AIExtension, and BlockNote's
724
751
  // useExtension threw "Extension not found". The other options keep their
725
752
  // first-render values, as before. `aiEnabled` is deliberately not here.
726
- [aiExtension],
753
+ // A new Yjs fragment (another document) needs a new editor bound to it.
754
+ [aiExtension, collaboration?.fragment],
727
755
  );
728
756
 
729
757
  // Every AI surface keys off the editor actually rendered, never off the
@@ -878,6 +906,10 @@ export default function BlockNoteEditor({
878
906
  const previousContentHashRef = useRef<string | null>(null);
879
907
  useEffect(() => {
880
908
  if (!processedContent || !editor) return;
909
+ // A collaborative editor's content IS the shared Yjs document: it has no initialContent,
910
+ // so processedContent is [] and replaceBlocks would wipe the document it just loaded,
911
+ // and that wipe would be synced to every other writer and stored.
912
+ if (collaboration) return;
881
913
  const hash = JSON.stringify(processedContent);
882
914
  if (previousContentHashRef.current === hash) return; // no changes
883
915
  // Skip replaceBlocks when the new initialContent is just an echo of what
@@ -896,7 +928,7 @@ export default function BlockNoteEditor({
896
928
  }
897
929
  editor.replaceBlocks(editor.document, processedContent as PartialBlock[]);
898
930
  previousContentHashRef.current = hash;
899
- }, [processedContent, editor]);
931
+ }, [processedContent, editor, collaboration]);
900
932
 
901
933
  useEffect(() => {
902
934
  if (!onWarmMentions || !initialContent || !Array.isArray(initialContent) || initialContent.length === 0) return;
@@ -956,7 +988,7 @@ export default function BlockNoteEditor({
956
988
  // Phone: an editable field types at 16px, or iPhone Safari zooms the
957
989
  // page in on focus. The two ranges never overlap, so neither depends on
958
990
  // CSS source order to win.
959
- onChange
991
+ isEditable
960
992
  ? "md:[&_.bn-default-styles]:!text-sm max-md:[&_.bn-default-styles]:!text-base"
961
993
  : "[&_.bn-default-styles]:!text-sm",
962
994
  className,
@@ -965,7 +997,7 @@ export default function BlockNoteEditor({
965
997
  <BlockNoteView
966
998
  editor={editor}
967
999
  onChange={handleChange}
968
- editable={onChange !== undefined}
1000
+ editable={isEditable}
969
1001
  formattingToolbar={false}
970
1002
  slashMenu={!aiActive}
971
1003
  // Follow the app theme: hardcoding "light" leaves dark (light-theme)
@@ -977,13 +1009,13 @@ export default function BlockNoteEditor({
977
1009
  // element and produce a phantom scrollbar on the wrapper.
978
1010
  className={cn(
979
1011
  "BlockNoteView flex-1",
980
- onChange && "[&.bn-container]:p-4",
1012
+ isEditable && "[&.bn-container]:p-4",
981
1013
  // In stretch mode the parent chain caps our height via flex; without
982
1014
  // these two classes the `.bn-container` keeps `min-height: auto`
983
1015
  // (its content's intrinsic height) and pushes the bordered wrapper
984
1016
  // — and the surrounding EditorSheet form — to scroll instead of
985
1017
  // scrolling internally.
986
- onChange && stretch && "[&.bn-container]:min-h-0 [&.bn-container]:overflow-y-auto",
1018
+ isEditable && stretch && "[&.bn-container]:min-h-0 [&.bn-container]:overflow-y-auto",
987
1019
  // Phone — these reach the floating menus because BlockNote puts this
988
1020
  // className on its portal root too. The formatting toolbar (~450px)
989
1021
  // scrolls sideways instead of losing alignment and link off-screen;
@@ -0,0 +1,81 @@
1
+ import * as coreLocales from "@blocknote/core/locales";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import * as Y from "yjs";
4
+ import { buildBlockNoteEditorOptions, resolveEditable } from "../blockNoteEditorOptions";
5
+
6
+ const content = [{ type: "paragraph", content: "hello" }] as any;
7
+ const baseParams = () => ({
8
+ placeholder: "Write here",
9
+ schema: { schema: true } as any,
10
+ initialContent: content,
11
+ uploadFile: vi.fn(async () => "https://s3/image.png"),
12
+ aiExtension: undefined,
13
+ coreDictionary: coreLocales.en,
14
+ aiDictionary: {} as any,
15
+ });
16
+
17
+ describe("buildBlockNoteEditorOptions", () => {
18
+ it("without collaboration: passes the validated content and sets no collaboration", () => {
19
+ const options = buildBlockNoteEditorOptions(baseParams());
20
+ expect(options.initialContent).toBe(content);
21
+ expect("collaboration" in options).toBe(false);
22
+ expect(options.extensions).toBeUndefined();
23
+ expect(options.disableExtensions).toBeUndefined();
24
+ expect(options.placeholders).toEqual({ emptyDocument: "Write here" });
25
+ expect(options.dictionary).toBeUndefined();
26
+ });
27
+
28
+ it("with collaboration: the Yjs document is the content, never the validated content", () => {
29
+ const doc = new Y.Doc();
30
+ const collaboration = { fragment: doc.getXmlFragment("document-store"), user: { name: "Ann", color: "#f00" } };
31
+ const options = buildBlockNoteEditorOptions({ ...baseParams(), collaboration });
32
+ expect((options as any).collaboration).toBe(collaboration);
33
+ expect(options.initialContent).not.toBe(content);
34
+ // BlockNote's fixed single-block placeholder for collaborative documents.
35
+ expect(options.initialContent).toEqual([{ type: "paragraph", id: "initialBlockId" }]);
36
+ expect(options.extensions).toHaveLength(1);
37
+ expect(options.disableExtensions).toContain("history");
38
+ });
39
+
40
+ it("with collaboration and AI: keeps the AI extension next to the collaboration one", () => {
41
+ const doc = new Y.Doc();
42
+ const aiExtension = { ai: true } as any;
43
+ const collaboration = { fragment: doc.getXmlFragment("document-store"), user: { name: "Ann", color: "#f00" } };
44
+ const options = buildBlockNoteEditorOptions({ ...baseParams(), aiExtension, collaboration });
45
+ expect(options.extensions).toHaveLength(2);
46
+ expect(options.extensions![0]).toBe(aiExtension);
47
+ });
48
+
49
+ it("disableUploads: true leaves uploadFile undefined", () => {
50
+ const options = buildBlockNoteEditorOptions({ ...baseParams(), disableUploads: true });
51
+ expect(options.uploadFile).toBeUndefined();
52
+ });
53
+
54
+ it("uploads on by default: uploadFile is the upload function", () => {
55
+ const params = baseParams();
56
+ expect(buildBlockNoteEditorOptions(params).uploadFile).toBe(params.uploadFile);
57
+ expect(buildBlockNoteEditorOptions({ ...params, disableUploads: false }).uploadFile).toBe(params.uploadFile);
58
+ });
59
+
60
+ it("keeps today's dictionary rule: AI adds the AI dictionary, a non-English core dictionary is passed", () => {
61
+ const aiExtension = { ai: true } as any;
62
+ const aiDictionary = { ai: "dict" } as any;
63
+ const withAi = buildBlockNoteEditorOptions({ ...baseParams(), aiExtension, aiDictionary });
64
+ expect(withAi.extensions).toEqual([aiExtension]);
65
+ expect(withAi.dictionary).toEqual({ ...coreLocales.en, ai: aiDictionary });
66
+ const italian = buildBlockNoteEditorOptions({ ...baseParams(), coreDictionary: coreLocales.it });
67
+ expect(italian.dictionary).toBe(coreLocales.it);
68
+ });
69
+ });
70
+
71
+ describe("resolveEditable", () => {
72
+ it("editable wins when defined", () => {
73
+ expect(resolveEditable({ editable: true, onChange: undefined })).toBe(true);
74
+ expect(resolveEditable({ editable: false, onChange: () => {} })).toBe(false);
75
+ });
76
+
77
+ it("falls back to onChange !== undefined", () => {
78
+ expect(resolveEditable({ editable: undefined, onChange: () => {} })).toBe(true);
79
+ expect(resolveEditable({ editable: undefined, onChange: undefined })).toBe(false);
80
+ });
81
+ });
@@ -0,0 +1,58 @@
1
+ import type { BlockNoteEditorOptions, PartialBlock } from "@blocknote/core";
2
+ import * as coreLocales from "@blocknote/core/locales";
3
+ import { type CollaborationOptions, withCollaboration } from "@blocknote/core/yjs";
4
+
5
+ /**
6
+ * Live co-editing options for the shared editor: `{ fragment, user: { name, color }, provider?, showCursorLabels? }`.
7
+ * BlockNote 0.55 no longer reads `collaboration` from the editor options; it is applied through
8
+ * `withCollaboration()` from `@blocknote/core/yjs`, which this module does.
9
+ */
10
+ export type BlockNoteCollaboration = CollaborationOptions;
11
+
12
+ export type BuildBlockNoteEditorOptionsParams = {
13
+ placeholder: string;
14
+ schema: any;
15
+ /** The validated initial content. Ignored when `collaboration` is set: the Yjs document is the content. */
16
+ initialContent: PartialBlock[] | undefined;
17
+ uploadFile: (file: File) => Promise<string>;
18
+ /** true: no image uploads (no `uploadFile`, so BlockNote offers no upload). */
19
+ disableUploads?: boolean;
20
+ aiExtension: any;
21
+ coreDictionary: typeof coreLocales.en;
22
+ aiDictionary: any;
23
+ collaboration?: BlockNoteCollaboration;
24
+ };
25
+
26
+ /**
27
+ * The options handed to `useCreateBlockNote`. Without `collaboration` they are exactly the options the
28
+ * editor has always built; with it, BlockNote's `withCollaboration` adds the collaboration extension,
29
+ * disables the local history (Yjs owns undo) and replaces `initialContent` with its fixed placeholder.
30
+ */
31
+ export function buildBlockNoteEditorOptions(
32
+ params: BuildBlockNoteEditorOptionsParams,
33
+ ): Partial<BlockNoteEditorOptions<any, any, any>> {
34
+ const { aiExtension, coreDictionary, aiDictionary, collaboration } = params;
35
+
36
+ const options: Partial<BlockNoteEditorOptions<any, any, any>> = {
37
+ placeholders: {
38
+ emptyDocument: params.placeholder,
39
+ },
40
+ schema: params.schema,
41
+ uploadFile: params.disableUploads ? undefined : params.uploadFile,
42
+ extensions: aiExtension ? [aiExtension] : undefined,
43
+ dictionary: aiExtension
44
+ ? { ...coreDictionary, ai: aiDictionary }
45
+ : coreDictionary === coreLocales.en
46
+ ? undefined
47
+ : coreDictionary,
48
+ };
49
+
50
+ if (collaboration) return withCollaboration({ ...options, collaboration });
51
+
52
+ return { ...options, initialContent: params.initialContent };
53
+ }
54
+
55
+ /** An explicit `editable` wins; otherwise the editor is editable when it reports changes. */
56
+ export function resolveEditable(params: { editable?: boolean; onChange?: unknown }): boolean {
57
+ return params.editable !== undefined ? params.editable : params.onChange !== undefined;
58
+ }
package/src/core/index.ts CHANGED
@@ -129,6 +129,8 @@ export * from "../features/rbac/rbac.module";
129
129
  export * from "../features/referral/data";
130
130
  export * from "../features/referral/referral.module";
131
131
  export * from "../features/referral/referral-stats.module";
132
+ export * from "../features/referral/referral-admin.modules";
133
+ export * from "../features/referral/interfaces/referral-admin.interface";
132
134
  // Administrative token-usage reporting modules. Exported from core (not only
133
135
  // from the client-marked ./tokenusage barrel) so a consuming app can register
134
136
  // them in its bootstrapper without pulling the feature's client bundle —
@@ -62,6 +62,11 @@ export interface FoundationModuleDefinitions {
62
62
  // Referral modules
63
63
  Referral: ModuleWithPermissions;
64
64
  ReferralStats: ModuleWithPermissions;
65
+ // Referral link visits and the administrative referral report (opt-in: registered by apps that spread referralAdminModules)
66
+ ReferralVisit: ModuleWithPermissions;
67
+ ReferralAdminSummary: ModuleWithPermissions;
68
+ ReferralAdminReferrer: ModuleWithPermissions;
69
+ ReferralAdminReferral: ModuleWithPermissions;
65
70
  // Two-factor authentication modules
66
71
  TotpAuthenticator: ModuleWithPermissions;
67
72
  TotpSetup: ModuleWithPermissions;
@@ -0,0 +1,122 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
4
+ import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
5
+ import { ReferralAdminReferral } from "../data/ReferralAdminReferral";
6
+ import { ReferralAdminReferrer } from "../data/ReferralAdminReferrer";
7
+ import { ReferralAdminSummary } from "../data/ReferralAdminSummary";
8
+ import { referralAdminModules } from "../referral-admin.modules";
9
+
10
+ const moduleFactory: ModuleFactory = (params) =>
11
+ ({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
12
+
13
+ // ModuleRegistry is shared across test files in a worker, hence the
14
+ // register-if-absent guard (same idiom as the analytics models spec).
15
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
16
+ try {
17
+ ModuleRegistry.get(key as any);
18
+ } catch {
19
+ ModuleRegistry.register(key, module);
20
+ }
21
+ };
22
+
23
+ beforeAll(() => {
24
+ for (const [key, module] of Object.entries(referralAdminModules(moduleFactory))) registerIfAbsent(key, module);
25
+ });
26
+
27
+ const hydrate = (id: string, attributes: Record<string, unknown>) =>
28
+ ({ jsonApi: { id, type: "t", attributes }, included: [] }) as any;
29
+
30
+ describe("referral admin models — rehydrate", () => {
31
+ it("rehydrates every summary attribute", () => {
32
+ const m = new ReferralAdminSummary().rehydrate(
33
+ hydrate("current", {
34
+ window: "current",
35
+ visits: 12,
36
+ invitesSent: 8,
37
+ signups: 3,
38
+ paying: 2,
39
+ creditsGiven: 800,
40
+ sharingCompanies: 4,
41
+ eligibleCompanies: 40,
42
+ }),
43
+ );
44
+ expect(m.id).toBe("current");
45
+ expect(m.window).toBe("current");
46
+ expect(m.visits).toBe(12);
47
+ expect(m.invitesSent).toBe(8);
48
+ expect(m.signups).toBe(3);
49
+ expect(m.paying).toBe(2);
50
+ expect(m.creditsGiven).toBe(800);
51
+ expect(m.sharingCompanies).toBe(4);
52
+ expect(m.eligibleCompanies).toBe(40);
53
+ });
54
+
55
+ it("rehydrates every referrer attribute and parses lastInviteAt into a Date", () => {
56
+ const m = new ReferralAdminReferrer().rehydrate(
57
+ hydrate("c1", {
58
+ name: "Luca",
59
+ email: "luca@x.test",
60
+ visits: 5,
61
+ invitesSent: 4,
62
+ signupsLink: 2,
63
+ signupsDirect: 1,
64
+ paying: 1,
65
+ creditsEarned: 200,
66
+ lastInviteAt: "2026-10-27T10:00:00.000Z",
67
+ }),
68
+ );
69
+ expect(m.id).toBe("c1");
70
+ expect(m.name).toBe("Luca");
71
+ expect(m.email).toBe("luca@x.test");
72
+ expect(m.visits).toBe(5);
73
+ expect(m.invitesSent).toBe(4);
74
+ expect(m.signupsLink).toBe(2);
75
+ expect(m.signupsDirect).toBe(1);
76
+ expect(m.paying).toBe(1);
77
+ expect(m.creditsEarned).toBe(200);
78
+ expect(m.lastInviteAt).toBeInstanceOf(Date);
79
+ expect(m.lastInviteAt?.toISOString()).toBe("2026-10-27T10:00:00.000Z");
80
+ });
81
+
82
+ it("leaves a missing lastInviteAt undefined", () => {
83
+ const m = new ReferralAdminReferrer().rehydrate(hydrate("c2", { name: "Sara", email: "sara@x.test" }));
84
+ expect(m.lastInviteAt).toBeUndefined();
85
+ });
86
+
87
+ it("rehydrates every referral attribute and parses the datetimes into Dates", () => {
88
+ const m = new ReferralAdminReferral().rehydrate(
89
+ hydrate("r1", {
90
+ name: "Marta",
91
+ email: "marta@x.test",
92
+ referrerName: "Luca",
93
+ referrerEmail: "luca@x.test",
94
+ source: "link",
95
+ status: "completed",
96
+ referredAt: "2026-10-20T08:00:00.000Z",
97
+ completedAt: "2026-10-27T08:00:00.000Z",
98
+ tokensAwarded: 200,
99
+ }),
100
+ );
101
+ expect(m.id).toBe("r1");
102
+ expect(m.name).toBe("Marta");
103
+ expect(m.email).toBe("marta@x.test");
104
+ expect(m.referrerName).toBe("Luca");
105
+ expect(m.referrerEmail).toBe("luca@x.test");
106
+ expect(m.source).toBe("link");
107
+ expect(m.status).toBe("completed");
108
+ expect(m.referredAt).toBeInstanceOf(Date);
109
+ expect(m.referredAt.toISOString()).toBe("2026-10-20T08:00:00.000Z");
110
+ expect(m.completedAt).toBeInstanceOf(Date);
111
+ expect(m.completedAt?.toISOString()).toBe("2026-10-27T08:00:00.000Z");
112
+ expect(m.tokensAwarded).toBe(200);
113
+ });
114
+
115
+ it("leaves a missing completedAt and tokensAwarded undefined", () => {
116
+ const m = new ReferralAdminReferral().rehydrate(
117
+ hydrate("r2", { name: "Tom", status: "pending", source: "direct", referredAt: "2026-10-24T08:00:00.000Z" }),
118
+ );
119
+ expect(m.completedAt).toBeUndefined();
120
+ expect(m.tokensAwarded).toBeUndefined();
121
+ });
122
+ });
@@ -0,0 +1,70 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AbstractService } from "../../../core/abstracts/AbstractService";
3
+ import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
4
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
5
+ import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
6
+ import { ReferralAdminService } from "../data/ReferralAdminService";
7
+ import { referralAdminModules } from "../referral-admin.modules";
8
+
9
+ const moduleFactory: ModuleFactory = (params) =>
10
+ ({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
11
+
12
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
13
+ try {
14
+ ModuleRegistry.get(key as any);
15
+ } catch {
16
+ ModuleRegistry.register(key, module);
17
+ }
18
+ };
19
+
20
+ beforeAll(() => {
21
+ for (const [key, module] of Object.entries(referralAdminModules(moduleFactory))) registerIfAbsent(key, module);
22
+ });
23
+
24
+ type CallApiParams = { type: ApiRequestDataTypeInterface; method: string; endpoint: string };
25
+
26
+ // `callApi` is a protected static on AbstractService; spying on the base class
27
+ // intercepts every call without touching the HTTP layer.
28
+ const callApi = () => vi.spyOn(AbstractService as any, "callApi").mockResolvedValue([]);
29
+ const firstCall = (spy: ReturnType<typeof callApi>): CallApiParams => spy.mock.calls[0][0] as CallApiParams;
30
+
31
+ describe("ReferralAdminService", () => {
32
+ beforeEach(() => vi.restoreAllMocks());
33
+
34
+ it("getSummary sends the period to the summary endpoint", async () => {
35
+ const spy = callApi();
36
+
37
+ await ReferralAdminService.getSummary({ period: "30d" });
38
+
39
+ const call = firstCall(spy);
40
+ expect(call.type).toBe(ModuleRegistry.get("ReferralAdminSummary" as any));
41
+ expect(call.method).toBe("GET");
42
+ expect(call.endpoint).toContain("referrals/administration/summary");
43
+ expect(call.endpoint).toContain("period=30d");
44
+ });
45
+
46
+ it("getReferrers sends the period and the sort", async () => {
47
+ const spy = callApi();
48
+
49
+ await ReferralAdminService.getReferrers({ period: "90d", sort: "invites" });
50
+
51
+ const call = firstCall(spy);
52
+ expect(call.type).toBe(ModuleRegistry.get("ReferralAdminReferrer" as any));
53
+ expect(call.method).toBe("GET");
54
+ expect(call.endpoint).toContain("referrals/administration/referrers");
55
+ expect(call.endpoint).toContain("period=90d");
56
+ expect(call.endpoint).toContain("sort=invites");
57
+ });
58
+
59
+ it("getReferrals sends the period", async () => {
60
+ const spy = callApi();
61
+
62
+ await ReferralAdminService.getReferrals({ period: "all" });
63
+
64
+ const call = firstCall(spy);
65
+ expect(call.type).toBe(ModuleRegistry.get("ReferralAdminReferral" as any));
66
+ expect(call.method).toBe("GET");
67
+ expect(call.endpoint).toContain("referrals/administration/referrals");
68
+ expect(call.endpoint).toContain("period=all");
69
+ });
70
+ });
@@ -0,0 +1,105 @@
1
+ import { act, render } from "@testing-library/react";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
4
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
5
+
6
+ const REF = "11111111-1111-4111-8111-111111111111";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ searchParams: new URLSearchParams(),
10
+ enabled: true,
11
+ existingCode: null as string | null,
12
+ setReferralCode: vi.fn(),
13
+ recordVisit: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("next/navigation", () => ({ useSearchParams: () => mocks.searchParams }));
17
+ vi.mock("../config", () => ({
18
+ isReferralEnabled: () => mocks.enabled,
19
+ getReferralConfig: () => ({ urlParamName: "ref" }),
20
+ }));
21
+ vi.mock("../utils/referral-cookie", () => ({
22
+ getReferralCode: () => mocks.existingCode,
23
+ setReferralCode: mocks.setReferralCode,
24
+ }));
25
+ vi.mock("../data/ReferralService", () => ({ ReferralService: { recordVisit: mocks.recordVisit } }));
26
+
27
+ import { ReferralCodeCapture } from "../components/ReferralCodeCapture";
28
+ import { ReferralVisit } from "../data/ReferralVisit";
29
+
30
+ // ReferralVisit.createJsonApi() resolves Modules.ReferralVisit through the real ModuleRegistry.
31
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
32
+ try {
33
+ ModuleRegistry.get(key as any);
34
+ } catch {
35
+ ModuleRegistry.register(key, module);
36
+ }
37
+ };
38
+
39
+ beforeAll(() => {
40
+ registerIfAbsent("ReferralVisit", { name: "referrals/visits", model: ReferralVisit } as any);
41
+ });
42
+
43
+ describe("ReferralCodeCapture", () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ mocks.searchParams = new URLSearchParams({ ref: REF });
47
+ mocks.enabled = true;
48
+ mocks.existingCode = null;
49
+ mocks.recordVisit.mockResolvedValue(undefined);
50
+ });
51
+
52
+ it("records a visit when it sets the cookie", () => {
53
+ render(<ReferralCodeCapture />);
54
+
55
+ expect(mocks.setReferralCode).toHaveBeenCalledTimes(1);
56
+ expect(mocks.setReferralCode).toHaveBeenCalledWith(REF);
57
+ expect(mocks.recordVisit).toHaveBeenCalledTimes(1);
58
+ expect(mocks.recordVisit).toHaveBeenCalledWith({ referralCode: REF });
59
+ });
60
+
61
+ it("does not record a visit when a cookie already exists", () => {
62
+ mocks.existingCode = "22222222-2222-4222-8222-222222222222";
63
+
64
+ render(<ReferralCodeCapture />);
65
+
66
+ expect(mocks.setReferralCode).not.toHaveBeenCalled();
67
+ expect(mocks.recordVisit).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("does nothing when the feature is disabled", () => {
71
+ mocks.enabled = false;
72
+
73
+ render(<ReferralCodeCapture />);
74
+
75
+ expect(mocks.setReferralCode).not.toHaveBeenCalled();
76
+ expect(mocks.recordVisit).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("a failed visit call is swallowed", async () => {
80
+ const unhandled = vi.fn();
81
+ process.on("unhandledRejection", unhandled);
82
+ mocks.recordVisit.mockRejectedValue(new Error("network down"));
83
+
84
+ try {
85
+ render(<ReferralCodeCapture />);
86
+ await act(async () => {
87
+ await new Promise((resolve) => setTimeout(resolve, 0));
88
+ });
89
+
90
+ expect(mocks.setReferralCode).toHaveBeenCalledWith(REF);
91
+ expect(mocks.recordVisit).toHaveBeenCalledTimes(1);
92
+ expect(unhandled).not.toHaveBeenCalled();
93
+ } finally {
94
+ process.off("unhandledRejection", unhandled);
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("ReferralVisit.createJsonApi()", () => {
100
+ it("builds the visit body with no id", () => {
101
+ expect(new ReferralVisit().createJsonApi({ referralCode: "x" })).toEqual({
102
+ data: { type: "referrals/visits", attributes: { referralCode: "x" } },
103
+ });
104
+ });
105
+ });