@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.
- package/dist/{BlockNoteEditor-LX7NMBV6.js → BlockNoteEditor-R2SLOYS6.js} +84 -43
- package/dist/BlockNoteEditor-R2SLOYS6.js.map +1 -0
- package/dist/{BlockNoteEditor-7GQWHXBC.mjs → BlockNoteEditor-ZJPGGUQQ.mjs} +61 -20
- package/dist/BlockNoteEditor-ZJPGGUQQ.mjs.map +1 -0
- package/dist/{BlockNoteViewer-NXV5PKR4.js → BlockNoteViewer-FLUHEXE6.js} +3 -3
- package/dist/{BlockNoteViewer-NXV5PKR4.js.map → BlockNoteViewer-FLUHEXE6.js.map} +1 -1
- package/dist/{BlockNoteViewer-D3Y4FUST.mjs → BlockNoteViewer-UCA3LV3N.mjs} +2 -2
- package/dist/billing/index.js +491 -491
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-QB2XU64X.mjs → chunk-6RLATJ2L.mjs} +2 -2
- package/dist/{chunk-62P4KIR2.mjs → chunk-BGLOFZ3K.mjs} +282 -2
- package/dist/chunk-BGLOFZ3K.mjs.map +1 -0
- package/dist/{chunk-3I5JCFPY.js → chunk-BXR36J25.js} +1647 -969
- package/dist/chunk-BXR36J25.js.map +1 -0
- package/dist/{chunk-62VVBUIL.js → chunk-FNPXCYHO.js} +8 -8
- package/dist/{chunk-62VVBUIL.js.map → chunk-FNPXCYHO.js.map} +1 -1
- package/dist/{chunk-TOPGUZSX.js → chunk-OHJSI6L2.js} +4 -4
- package/dist/{chunk-TOPGUZSX.js.map → chunk-OHJSI6L2.js.map} +1 -1
- package/dist/{chunk-LDUTS7SJ.mjs → chunk-SQUMPLNN.mjs} +1281 -603
- package/dist/chunk-SQUMPLNN.mjs.map +1 -0
- package/dist/{chunk-Z4OVEA3N.js → chunk-YGEOX2XX.js} +283 -3
- package/dist/chunk-YGEOX2XX.js.map +1 -0
- package/dist/{chunk-GHAZOQTT.mjs → chunk-YP6W4AB5.mjs} +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +88 -29
- package/dist/components/index.d.ts +88 -29
- package/dist/components/index.js +10 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +9 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +159 -3
- package/dist/core/index.d.ts +159 -3
- package/dist/core/index.js +14 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +13 -1
- package/dist/email-automations/index.js +93 -93
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.js +120 -122
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +7 -9
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.js +16 -16
- package/dist/features/blog/server.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +126 -128
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +14 -16
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -2
- package/dist/{HandbookSectionInterface-5I57rC1b.d.mts → referral-admin.interface-C87ApGMR.d.mts} +41 -1
- package/dist/{HandbookSectionInterface-IDYEzQMi.d.ts → referral-admin.interface-CMwl5NiV.d.ts} +41 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/messages/en.json +78 -1
- package/messages/it.json +78 -1
- package/package.json +8 -3
- package/src/components/editors/BlockNoteEditor.tsx +52 -20
- package/src/components/editors/__tests__/blocknote-editor-options.spec.ts +81 -0
- package/src/components/editors/blockNoteEditorOptions.ts +58 -0
- package/src/core/index.ts +2 -0
- package/src/core/registry/ModuleRegistry.ts +5 -0
- package/src/features/referral/__tests__/referral-admin-models.spec.ts +122 -0
- package/src/features/referral/__tests__/referral-admin-service.spec.ts +70 -0
- package/src/features/referral/__tests__/referral-code-capture.spec.tsx +105 -0
- package/src/features/referral/__tests__/referral-invite-errors.spec.tsx +82 -0
- package/src/features/referral/__tests__/referral-visit-service.spec.ts +47 -0
- package/src/features/referral/__tests__/referrals-admin-container.spec.tsx +230 -0
- package/src/features/referral/components/ReferralCodeCapture.tsx +3 -0
- package/src/features/referral/components/ReferralDialog.tsx +9 -13
- package/src/features/referral/components/ReferralWidget.tsx +75 -45
- package/src/features/referral/components/ReferralsAdminFunnel.tsx +74 -0
- package/src/features/referral/components/ReferralsAdminTiles.tsx +128 -0
- package/src/features/referral/components/containers/ReferralsAdminContainer.tsx +205 -0
- package/src/features/referral/components/index.ts +3 -0
- package/src/features/referral/data/ReferralAdminReferral.ts +65 -0
- package/src/features/referral/data/ReferralAdminReferrer.ts +65 -0
- package/src/features/referral/data/ReferralAdminService.ts +51 -0
- package/src/features/referral/data/ReferralAdminSummary.ts +60 -0
- package/src/features/referral/data/ReferralService.ts +20 -0
- package/src/features/referral/data/ReferralVisit.ts +23 -0
- package/src/features/referral/data/index.ts +5 -0
- package/src/features/referral/hooks/index.ts +2 -0
- package/src/features/referral/hooks/useReferralAdminReferralTableStructure.tsx +127 -0
- package/src/features/referral/hooks/useReferralAdminReferrerTableStructure.tsx +229 -0
- package/src/features/referral/hooks/useReferralInvite.ts +2 -0
- package/src/features/referral/index.ts +1 -0
- package/src/features/referral/interfaces/index.ts +1 -0
- package/src/features/referral/interfaces/referral-admin.interface.ts +45 -0
- package/src/features/referral/referral-admin.modules.ts +32 -0
- package/dist/BlockNoteEditor-7GQWHXBC.mjs.map +0 -1
- package/dist/BlockNoteEditor-LX7NMBV6.js.map +0 -1
- package/dist/chunk-3I5JCFPY.js.map +0 -1
- package/dist/chunk-62P4KIR2.mjs.map +0 -1
- package/dist/chunk-D6UX37IA.mjs +0 -115
- package/dist/chunk-D6UX37IA.mjs.map +0 -1
- package/dist/chunk-KEHP2OBQ.js +0 -115
- package/dist/chunk-KEHP2OBQ.js.map +0 -1
- package/dist/chunk-LDUTS7SJ.mjs.map +0 -1
- package/dist/chunk-Z4OVEA3N.js.map +0 -1
- /package/dist/{BlockNoteViewer-D3Y4FUST.mjs.map → BlockNoteViewer-UCA3LV3N.mjs.map} +0 -0
- /package/dist/{chunk-QB2XU64X.mjs.map → chunk-6RLATJ2L.mjs.map} +0 -0
- /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.
|
|
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
|
-
|
|
705
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|