@carlonicora/nextjs-jsonapi 6.10.2 → 6.11.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 (108) hide show
  1. package/dist/{BlockNoteEditor-7GQWHXBC.mjs → BlockNoteEditor-5WM5DX3J.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-LX7NMBV6.js → BlockNoteEditor-GY5R25YI.js} +19 -19
  3. package/dist/{BlockNoteEditor-LX7NMBV6.js.map → BlockNoteEditor-GY5R25YI.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-NXV5PKR4.js → BlockNoteViewer-FLUHEXE6.js} +3 -3
  5. package/dist/{BlockNoteViewer-NXV5PKR4.js.map → BlockNoteViewer-FLUHEXE6.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-D3Y4FUST.mjs → BlockNoteViewer-UCA3LV3N.mjs} +2 -2
  7. package/dist/billing/index.js +491 -491
  8. package/dist/billing/index.mjs +3 -3
  9. package/dist/{chunk-LDUTS7SJ.mjs → chunk-3PRLRCMF.mjs} +1281 -603
  10. package/dist/chunk-3PRLRCMF.mjs.map +1 -0
  11. package/dist/{chunk-QB2XU64X.mjs → chunk-6RLATJ2L.mjs} +2 -2
  12. package/dist/{chunk-62P4KIR2.mjs → chunk-BGLOFZ3K.mjs} +282 -2
  13. package/dist/chunk-BGLOFZ3K.mjs.map +1 -0
  14. package/dist/{chunk-62VVBUIL.js → chunk-FNPXCYHO.js} +8 -8
  15. package/dist/{chunk-62VVBUIL.js.map → chunk-FNPXCYHO.js.map} +1 -1
  16. package/dist/{chunk-3I5JCFPY.js → chunk-MP3DV2I5.js} +1647 -969
  17. package/dist/chunk-MP3DV2I5.js.map +1 -0
  18. package/dist/{chunk-TOPGUZSX.js → chunk-OHJSI6L2.js} +4 -4
  19. package/dist/{chunk-TOPGUZSX.js.map → chunk-OHJSI6L2.js.map} +1 -1
  20. package/dist/{chunk-Z4OVEA3N.js → chunk-YGEOX2XX.js} +283 -3
  21. package/dist/chunk-YGEOX2XX.js.map +1 -0
  22. package/dist/{chunk-GHAZOQTT.mjs → chunk-YP6W4AB5.mjs} +2 -2
  23. package/dist/client/index.js +4 -4
  24. package/dist/client/index.mjs +3 -3
  25. package/dist/components/index.d.mts +67 -29
  26. package/dist/components/index.d.ts +67 -29
  27. package/dist/components/index.js +10 -4
  28. package/dist/components/index.js.map +1 -1
  29. package/dist/components/index.mjs +9 -3
  30. package/dist/contexts/index.js +4 -4
  31. package/dist/contexts/index.mjs +3 -3
  32. package/dist/core/index.d.mts +159 -3
  33. package/dist/core/index.d.ts +159 -3
  34. package/dist/core/index.js +14 -2
  35. package/dist/core/index.js.map +1 -1
  36. package/dist/core/index.mjs +13 -1
  37. package/dist/email-automations/index.js +93 -93
  38. package/dist/email-automations/index.mjs +3 -3
  39. package/dist/features/analytics/index.js +120 -122
  40. package/dist/features/analytics/index.js.map +1 -1
  41. package/dist/features/analytics/index.mjs +7 -9
  42. package/dist/features/analytics/index.mjs.map +1 -1
  43. package/dist/features/analytics/server.js +4 -4
  44. package/dist/features/analytics/server.mjs +2 -2
  45. package/dist/features/blog/index.js +150 -150
  46. package/dist/features/blog/index.mjs +3 -3
  47. package/dist/features/blog/server.js +16 -16
  48. package/dist/features/blog/server.mjs +1 -1
  49. package/dist/features/help/index.js +37 -37
  50. package/dist/features/help/index.mjs +3 -3
  51. package/dist/features/tokenusage/index.js +126 -128
  52. package/dist/features/tokenusage/index.js.map +1 -1
  53. package/dist/features/tokenusage/index.mjs +14 -16
  54. package/dist/features/tokenusage/index.mjs.map +1 -1
  55. package/dist/index.d.mts +2 -2
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.js +15 -3
  58. package/dist/index.js.map +1 -1
  59. package/dist/index.mjs +14 -2
  60. package/dist/{HandbookSectionInterface-5I57rC1b.d.mts → referral-admin.interface-C87ApGMR.d.mts} +41 -1
  61. package/dist/{HandbookSectionInterface-IDYEzQMi.d.ts → referral-admin.interface-CMwl5NiV.d.ts} +41 -1
  62. package/dist/server/index.js +3 -3
  63. package/dist/server/index.mjs +2 -2
  64. package/messages/en.json +78 -1
  65. package/messages/it.json +78 -1
  66. package/package.json +1 -1
  67. package/src/core/index.ts +2 -0
  68. package/src/core/registry/ModuleRegistry.ts +5 -0
  69. package/src/features/referral/__tests__/referral-admin-models.spec.ts +122 -0
  70. package/src/features/referral/__tests__/referral-admin-service.spec.ts +70 -0
  71. package/src/features/referral/__tests__/referral-code-capture.spec.tsx +105 -0
  72. package/src/features/referral/__tests__/referral-invite-errors.spec.tsx +82 -0
  73. package/src/features/referral/__tests__/referral-visit-service.spec.ts +47 -0
  74. package/src/features/referral/__tests__/referrals-admin-container.spec.tsx +230 -0
  75. package/src/features/referral/components/ReferralCodeCapture.tsx +3 -0
  76. package/src/features/referral/components/ReferralDialog.tsx +9 -13
  77. package/src/features/referral/components/ReferralWidget.tsx +75 -45
  78. package/src/features/referral/components/ReferralsAdminFunnel.tsx +74 -0
  79. package/src/features/referral/components/ReferralsAdminTiles.tsx +128 -0
  80. package/src/features/referral/components/containers/ReferralsAdminContainer.tsx +205 -0
  81. package/src/features/referral/components/index.ts +3 -0
  82. package/src/features/referral/data/ReferralAdminReferral.ts +65 -0
  83. package/src/features/referral/data/ReferralAdminReferrer.ts +65 -0
  84. package/src/features/referral/data/ReferralAdminService.ts +51 -0
  85. package/src/features/referral/data/ReferralAdminSummary.ts +60 -0
  86. package/src/features/referral/data/ReferralService.ts +20 -0
  87. package/src/features/referral/data/ReferralVisit.ts +23 -0
  88. package/src/features/referral/data/index.ts +5 -0
  89. package/src/features/referral/hooks/index.ts +2 -0
  90. package/src/features/referral/hooks/useReferralAdminReferralTableStructure.tsx +127 -0
  91. package/src/features/referral/hooks/useReferralAdminReferrerTableStructure.tsx +229 -0
  92. package/src/features/referral/hooks/useReferralInvite.ts +2 -0
  93. package/src/features/referral/index.ts +1 -0
  94. package/src/features/referral/interfaces/index.ts +1 -0
  95. package/src/features/referral/interfaces/referral-admin.interface.ts +45 -0
  96. package/src/features/referral/referral-admin.modules.ts +32 -0
  97. package/dist/chunk-3I5JCFPY.js.map +0 -1
  98. package/dist/chunk-62P4KIR2.mjs.map +0 -1
  99. package/dist/chunk-D6UX37IA.mjs +0 -115
  100. package/dist/chunk-D6UX37IA.mjs.map +0 -1
  101. package/dist/chunk-KEHP2OBQ.js +0 -115
  102. package/dist/chunk-KEHP2OBQ.js.map +0 -1
  103. package/dist/chunk-LDUTS7SJ.mjs.map +0 -1
  104. package/dist/chunk-Z4OVEA3N.js.map +0 -1
  105. /package/dist/{BlockNoteEditor-7GQWHXBC.mjs.map → BlockNoteEditor-5WM5DX3J.mjs.map} +0 -0
  106. /package/dist/{BlockNoteViewer-D3Y4FUST.mjs.map → BlockNoteViewer-UCA3LV3N.mjs.map} +0 -0
  107. /package/dist/{chunk-QB2XU64X.mjs.map → chunk-6RLATJ2L.mjs.map} +0 -0
  108. /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.11.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",
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
+ });
@@ -0,0 +1,82 @@
1
+ import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ sendReferralEmail: vi.fn(),
6
+ sendInvite: vi.fn(),
7
+ showError: vi.fn(),
8
+ showToast: vi.fn(),
9
+ }));
10
+
11
+ // Echoes the key, so an assertion names the message the user would read.
12
+ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
13
+ vi.mock("../../../utils", async () => ({
14
+ ...(await vi.importActual<object>("../../../utils")),
15
+ showError: mocks.showError,
16
+ showToast: mocks.showToast,
17
+ }));
18
+ vi.mock("../data/ReferralService", () => ({ ReferralService: { sendReferralEmail: mocks.sendReferralEmail } }));
19
+ vi.mock("../hooks/useReferralStats", () => ({
20
+ useReferralStats: () => ({ stats: { referralCode: "abc", completedReferrals: 0 }, loading: false, error: null }),
21
+ }));
22
+ vi.mock("../hooks/useReferralInvite", async () => ({
23
+ ...(await vi.importActual<object>("../hooks/useReferralInvite")),
24
+ useReferralInvite: () => ({ sendInvite: mocks.sendInvite, sending: false }),
25
+ }));
26
+
27
+ import { ReferralWidget } from "../components/ReferralWidget";
28
+ import { configureReferral } from "../config";
29
+
30
+ /** The error callApi throws when suppressGlobalError is set: the JSON:API body rides on `body`. */
31
+ const apiError = (code?: string) =>
32
+ Object.assign(new Error("400:English text from the API"), {
33
+ status: 400,
34
+ body: { errors: [{ status: "400", ...(code ? { code } : {}), detail: "English text from the API" }] },
35
+ });
36
+
37
+ const sendTo = (email: string) => {
38
+ fireEvent.change(screen.getByPlaceholderText("referral.emailPlaceholder"), { target: { value: email } });
39
+ fireEvent.click(screen.getByRole("button", { name: "referral.send" }));
40
+ };
41
+
42
+ describe("referral invite errors", () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ configureReferral({ enabled: true });
46
+ });
47
+
48
+ it.each([
49
+ ["REFERRAL_ALREADY_INVITED", "referral.alreadyInvited"],
50
+ ["REFERRAL_ALREADY_REGISTERED", "referral.alreadyRegistered"],
51
+ [undefined, "referral.inviteFailed"],
52
+ ])("code %s shows %s, never the API text", async (code, message) => {
53
+ mocks.sendInvite.mockRejectedValue(apiError(code));
54
+ render(<ReferralWidget isDialog />);
55
+
56
+ await act(async () => sendTo("gm@example.com"));
57
+
58
+ expect(mocks.showError).toHaveBeenCalledWith(message);
59
+ expect(mocks.showToast).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("a sent invite shows the translated confirmation", async () => {
63
+ mocks.sendInvite.mockResolvedValue(undefined);
64
+ render(<ReferralWidget isDialog />);
65
+
66
+ await act(async () => sendTo("gm@example.com"));
67
+
68
+ expect(mocks.showToast).toHaveBeenCalledWith("referral.inviteSent");
69
+ expect(mocks.showError).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("useReferralInvite rethrows a refused invite", async () => {
73
+ const { useReferralInvite } =
74
+ await vi.importActual<typeof import("../hooks/useReferralInvite")>("../hooks/useReferralInvite");
75
+ mocks.sendReferralEmail.mockRejectedValue(apiError("REFERRAL_ALREADY_INVITED"));
76
+ const { result } = renderHook(() => useReferralInvite());
77
+
78
+ await act(async () => {
79
+ await expect(result.current.sendInvite("gm@example.com")).rejects.toMatchObject({ status: 400 });
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,47 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AbstractService } from "../../../core/abstracts/AbstractService";
3
+ import { JsonApiDataFactory } from "../../../core/factories/JsonApiDataFactory";
4
+ import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
5
+ import { DataClassRegistry } from "../../../core/registry/DataClassRegistry";
6
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
7
+ import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
8
+ import { ReferralService } from "../data/ReferralService";
9
+ import { ReferralVisit } from "../data/ReferralVisit";
10
+ import { referralAdminModules } from "../referral-admin.modules";
11
+
12
+ const moduleFactory: ModuleFactory = (params) =>
13
+ ({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
14
+
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
+ // bootstrap() does this in an app: the factory builds the body through the registered model.
26
+ DataClassRegistry.registerObjectClass(ModuleRegistry.get("ReferralVisit" as any), ReferralVisit);
27
+ });
28
+
29
+ type CallApiParams = { type: ApiRequestDataTypeInterface; method: string; input: unknown };
30
+
31
+ describe("ReferralService.recordVisit", () => {
32
+ beforeEach(() => vi.restoreAllMocks());
33
+
34
+ // JsonApiPost runs the model's createJsonApi() on `input` itself, so the body the
35
+ // API receives is JsonApiDataFactory.create(type, input), never `input` as is.
36
+ it("the body the API receives carries the referral code", async () => {
37
+ const spy = vi.spyOn(AbstractService as any, "callApi").mockResolvedValue(undefined);
38
+
39
+ await ReferralService.recordVisit({ referralCode: "code-1" });
40
+
41
+ const call = spy.mock.calls[0][0] as CallApiParams;
42
+ expect(call.method).toBe("POST");
43
+ expect(JsonApiDataFactory.create(call.type, call.input)).toEqual({
44
+ data: { type: "referrals/visits", attributes: { referralCode: "code-1" } },
45
+ });
46
+ });
47
+ });