@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
@@ -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
+ });
@@ -0,0 +1,230 @@
1
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import type { ReactNode } from "react";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
5
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
6
+ import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ getSummary: vi.fn(),
10
+ getReferrers: vi.fn(),
11
+ getReferrals: vi.fn(),
12
+ showToast: vi.fn(),
13
+ showError: vi.fn(),
14
+ }));
15
+
16
+ // Echoes the key, so an assertion names the message the user would read.
17
+ vi.mock("next-intl", () => ({
18
+ useTranslations: () => (key: string) => key,
19
+ useLocale: () => "en",
20
+ }));
21
+
22
+ vi.mock("../data/ReferralAdminService", () => ({
23
+ ReferralAdminService: {
24
+ getSummary: mocks.getSummary,
25
+ getReferrers: mocks.getReferrers,
26
+ getReferrals: mocks.getReferrals,
27
+ },
28
+ }));
29
+
30
+ vi.mock("../../../utils", async (importOriginal) => ({
31
+ ...(await importOriginal<object>()),
32
+ showToast: mocks.showToast,
33
+ showError: mocks.showError,
34
+ }));
35
+
36
+ // The real page chrome needs the whole app shell; the stand-in renders the
37
+ // title bar functions (the period buttons) and the body. The table stand-in
38
+ // runs the registered table generator so the cells under test render, without
39
+ // the TanStack and responsive machinery of the real ContentListTable.
40
+ vi.mock("../../../components", async (importOriginal) => {
41
+ const actual = await importOriginal<typeof import("../../../components")>();
42
+ const { useSharedContext } = await import("../../../contexts/SharedContext");
43
+ const { useTableGenerator } = await import("../../../hooks");
44
+
45
+ const RoundPageContainer = ({ children, testId }: { children?: ReactNode; testId?: string }) => {
46
+ const { title } = useSharedContext();
47
+ return (
48
+ <div data-testid={testId}>
49
+ <div>{title.functions}</div>
50
+ {children}
51
+ </div>
52
+ );
53
+ };
54
+
55
+ const ContentListTable = (props: any) => {
56
+ const { data, columns } = useTableGenerator(props.tableGeneratorType, {
57
+ data: props.data.data ?? [],
58
+ fields: props.fields,
59
+ context: props.context,
60
+ dataRetriever: props.data,
61
+ });
62
+ const render = (def: unknown, ctx: unknown) => (typeof def === "function" ? def(ctx) : def);
63
+ return (
64
+ <div>
65
+ <div>
66
+ {props.titleActions}
67
+ {props.title}
68
+ </div>
69
+ <table>
70
+ <thead>
71
+ <tr>
72
+ {columns.map((column: any) => (
73
+ <th key={column.id}>{render(column.header, {})}</th>
74
+ ))}
75
+ </tr>
76
+ </thead>
77
+ <tbody>
78
+ {data.map((row: any, index: number) => (
79
+ <tr key={index}>
80
+ {columns.map((column: any) => (
81
+ <td key={column.id}>
82
+ {render(column.cell, { row: { original: row, getValue: (key: string) => row[key] } })}
83
+ </td>
84
+ ))}
85
+ </tr>
86
+ ))}
87
+ </tbody>
88
+ </table>
89
+ </div>
90
+ );
91
+ };
92
+
93
+ return { ...actual, RoundPageContainer, ContentListTable };
94
+ });
95
+
96
+ import { ReferralsAdminContainer } from "../components/containers/ReferralsAdminContainer";
97
+ import { referralAdminModules } from "../referral-admin.modules";
98
+
99
+ const moduleFactory: ModuleFactory = (params) =>
100
+ ({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
101
+
102
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
103
+ try {
104
+ ModuleRegistry.get(key as any);
105
+ } catch {
106
+ ModuleRegistry.register(key, module);
107
+ }
108
+ };
109
+
110
+ beforeAll(() => {
111
+ for (const [key, module] of Object.entries(referralAdminModules(moduleFactory))) registerIfAbsent(key, module);
112
+ });
113
+
114
+ const summaryRow = (window: string, values: Record<string, number>) => ({
115
+ id: window,
116
+ window,
117
+ visits: 0,
118
+ invitesSent: 0,
119
+ signups: 0,
120
+ paying: 0,
121
+ creditsGiven: 0,
122
+ sharingCompanies: 0,
123
+ eligibleCompanies: 0,
124
+ ...values,
125
+ });
126
+
127
+ const referrer = (id: string, email: string) => ({
128
+ id,
129
+ name: `Name ${id}`,
130
+ email,
131
+ visits: 1,
132
+ invitesSent: 2,
133
+ signupsLink: 1,
134
+ signupsDirect: 1,
135
+ paying: 1,
136
+ creditsEarned: 200,
137
+ lastInviteAt: new Date("2026-10-27T10:00:00.000Z"),
138
+ });
139
+
140
+ const writeText = vi.fn();
141
+
142
+ describe("ReferralsAdminContainer", () => {
143
+ beforeEach(() => {
144
+ vi.clearAllMocks();
145
+ vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
146
+ writeText.mockResolvedValue(undefined);
147
+ mocks.getSummary.mockImplementation(({ period }: { period: string }) =>
148
+ Promise.resolve(
149
+ period === "all"
150
+ ? [summaryRow("current", { visits: 40 })]
151
+ : [summaryRow("current", { visits: 12 }), summaryRow("previous", { visits: 6 })],
152
+ ),
153
+ );
154
+ mocks.getReferrers.mockResolvedValue([referrer("c1", "a@x.test"), referrer("c2", "b@x.test")]);
155
+ mocks.getReferrals.mockResolvedValue([]);
156
+ });
157
+
158
+ it("renders the tiles from the current window", async () => {
159
+ render(<ReferralsAdminContainer />);
160
+
161
+ await waitFor(() => expect(screen.getByTestId("referrals-tile-visits-value")).toHaveTextContent("12"));
162
+ expect(mocks.getSummary).toHaveBeenCalledWith({ period: "30d" });
163
+ expect(screen.getByTestId("referrals-tile-visits-change")).toBeInTheDocument();
164
+ });
165
+
166
+ it("switching period refetches", async () => {
167
+ render(<ReferralsAdminContainer />);
168
+ await waitFor(() => expect(screen.getByTestId("referrals-tile-visits-value")).toHaveTextContent("12"));
169
+
170
+ await act(async () => fireEvent.click(screen.getByTestId("referrals-period-all")));
171
+
172
+ await waitFor(() => expect(screen.getByTestId("referrals-tile-visits-value")).toHaveTextContent("40"));
173
+ expect(mocks.getSummary).toHaveBeenCalledWith({ period: "all" });
174
+ expect(screen.getByTestId("referrals-period-all")).toHaveAttribute("aria-pressed", "true");
175
+ expect(screen.queryByTestId("referrals-tile-visits-change")).not.toBeInTheDocument();
176
+ });
177
+
178
+ it("copy all emails copies the shown referrers", async () => {
179
+ render(<ReferralsAdminContainer />);
180
+ await waitFor(() => expect(screen.getByTestId("referrals-copy-email-c2")).toBeInTheDocument());
181
+
182
+ await act(async () => fireEvent.click(screen.getByTestId("referrals-copy-all-emails")));
183
+
184
+ expect(writeText).toHaveBeenCalledWith("a@x.test, b@x.test");
185
+ expect(mocks.showToast).toHaveBeenCalledWith("referral.admin.referrers.copied");
186
+ });
187
+
188
+ it("per-row copy copies one email", async () => {
189
+ render(<ReferralsAdminContainer />);
190
+ await waitFor(() => expect(screen.getByTestId("referrals-copy-email-c2")).toBeInTheDocument());
191
+
192
+ await act(async () => fireEvent.click(screen.getByTestId("referrals-copy-email-c2")));
193
+
194
+ expect(writeText).toHaveBeenCalledWith("b@x.test");
195
+ expect(mocks.showToast).toHaveBeenCalledWith("referral.admin.referrers.copied");
196
+ });
197
+
198
+ it("a referral with no recorded source shows no source pill", async () => {
199
+ mocks.getReferrals.mockResolvedValue([
200
+ {
201
+ id: "r1",
202
+ name: "Old account",
203
+ email: "old@x.test",
204
+ referrerName: "Name c1",
205
+ referrerEmail: "a@x.test",
206
+ source: "",
207
+ status: "pending",
208
+ referredAt: new Date("2026-10-20T10:00:00.000Z"),
209
+ completedAt: undefined,
210
+ tokensAwarded: undefined,
211
+ },
212
+ ]);
213
+ render(<ReferralsAdminContainer />);
214
+ await waitFor(() => expect(screen.getByText("Old account")).toBeInTheDocument());
215
+
216
+ expect(screen.queryByText("referral.admin.source.direct")).not.toBeInTheDocument();
217
+ expect(screen.queryByText("referral.admin.source.link")).not.toBeInTheDocument();
218
+ });
219
+
220
+ it("a refused clipboard write shows an error", async () => {
221
+ writeText.mockRejectedValue(new Error("denied"));
222
+ render(<ReferralsAdminContainer />);
223
+ await waitFor(() => expect(screen.getByTestId("referrals-copy-email-c1")).toBeInTheDocument());
224
+
225
+ await act(async () => fireEvent.click(screen.getByTestId("referrals-copy-email-c1")));
226
+
227
+ expect(mocks.showError).toHaveBeenCalled();
228
+ expect(mocks.showToast).not.toHaveBeenCalled();
229
+ });
230
+ });
@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation";
4
4
  import { useEffect } from "react";
5
5
 
6
6
  import { getReferralConfig, isReferralEnabled } from "../config";
7
+ import { ReferralService } from "../data/ReferralService";
7
8
  import { getReferralCode, setReferralCode } from "../utils/referral-cookie";
8
9
 
9
10
  /**
@@ -34,6 +35,8 @@ export function ReferralCodeCapture(): null {
34
35
  // First referrer wins - only set if no existing cookie
35
36
  if (!existingCode) {
36
37
  setReferralCode(refCode);
38
+ // Counted once per browser; a failed call never reaches the visitor.
39
+ ReferralService.recordVisit({ referralCode: refCode }).catch(() => {});
37
40
  }
38
41
  }
39
42
  }, [searchParams]);
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
4
+
3
5
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../../shadcnui";
4
6
  import { isReferralEnabled } from "../config";
5
7
  import { ReferralWidget, ReferralWidgetProps, ReferralWidgetTranslations } from "./ReferralWidget";
@@ -9,20 +11,12 @@ import { ReferralWidget, ReferralWidgetProps, ReferralWidgetTranslations } from
9
11
  * Extends ReferralWidgetTranslations with dialog-specific strings.
10
12
  */
11
13
  export interface ReferralDialogTranslations extends ReferralWidgetTranslations {
12
- /** Dialog title */
14
+ /** Dialog title (default: `referral.title`) */
13
15
  dialogTitle?: string;
14
- /** Dialog description */
16
+ /** Dialog description (default: `referral.description`) */
15
17
  dialogDescription?: string;
16
18
  }
17
19
 
18
- /**
19
- * Default translations for ReferralDialog.
20
- */
21
- const DEFAULT_DIALOG_TRANSLATIONS: Required<Pick<ReferralDialogTranslations, "dialogTitle" | "dialogDescription">> = {
22
- dialogTitle: "Invite Friends",
23
- dialogDescription: "Share your referral link and earn rewards when your friends subscribe.",
24
- };
25
-
26
20
  /**
27
21
  * Props for the ReferralDialog component.
28
22
  */
@@ -73,19 +67,21 @@ export function ReferralDialog({
73
67
  dialogClassName,
74
68
  ...widgetProps
75
69
  }: ReferralDialogProps) {
70
+ const t = useTranslations();
71
+
76
72
  // Render nothing when disabled
77
73
  if (!isReferralEnabled()) {
78
74
  return null;
79
75
  }
80
76
 
81
- const t = { ...DEFAULT_DIALOG_TRANSLATIONS, ...translations };
77
+ const text = { dialogTitle: t("referral.title"), dialogDescription: t("referral.description"), ...translations };
82
78
 
83
79
  return (
84
80
  <Dialog open={open} onOpenChange={onOpenChange}>
85
81
  <DialogContent className={dialogClassName ?? "max-w-md"}>
86
82
  <DialogHeader>
87
- <DialogTitle>{t.dialogTitle}</DialogTitle>
88
- <DialogDescription>{t.dialogDescription}</DialogDescription>
83
+ <DialogTitle>{text.dialogTitle}</DialogTitle>
84
+ <DialogDescription>{text.dialogDescription}</DialogDescription>
89
85
  </DialogHeader>
90
86
  <ReferralWidget {...widgetProps} translations={translations} isDialog />
91
87
  </DialogContent>
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Copy, Loader2, Mail, Users } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
4
5
  import { useCallback, useRef, useState } from "react";
5
6
 
6
7
  import { Button, Card, Input, Label } from "../../../shadcnui";
@@ -10,33 +11,10 @@ import { getReferralConfig, isReferralEnabled } from "../config";
10
11
  import { useReferralInvite } from "../hooks/useReferralInvite";
11
12
  import { useReferralStats } from "../hooks/useReferralStats";
12
13
 
13
- /**
14
- * Default translations for ReferralWidget.
15
- * These can be overridden via the translations prop.
16
- */
17
- const DEFAULT_TRANSLATIONS: Required<ReferralWidgetTranslations> = {
18
- title: "Invite Friends",
19
- description: "Share your referral link and earn rewards when your friends subscribe.",
20
- yourLink: "Your referral link",
21
- copyButton: "Copy",
22
- copiedMessage: "Link copied to clipboard!",
23
- statsLabel: "Your referral stats",
24
- completedLabel: "Successful referrals",
25
- tokensLabel: "Tokens earned",
26
- inviteTitle: "Invite by email",
27
- inviteDescription: "Send an invitation directly to a friend's email.",
28
- emailPlaceholder: "friend@example.com",
29
- sendButton: "Send",
30
- sendingButton: "Sending...",
31
- inviteSent: "Invitation sent successfully!",
32
- inviteError: "Failed to send invitation",
33
- copyError: "Failed to copy link",
34
- invalidEmail: "Please enter a valid email address",
35
- };
36
-
37
14
  /**
38
15
  * Translation strings for the ReferralWidget component.
39
- * All strings are optional and have defaults.
16
+ * All strings are optional: each defaults to its `referral.*` key in the
17
+ * library's messages, which the app can also override in its own messages file.
40
18
  */
41
19
  export interface ReferralWidgetTranslations {
42
20
  /** Widget title */
@@ -73,6 +51,19 @@ export interface ReferralWidgetTranslations {
73
51
  copyError?: string;
74
52
  /** Error message for invalid email */
75
53
  invalidEmail?: string;
54
+ /** Error message when the referral link and stats cannot be loaded */
55
+ loadError?: string;
56
+ /** Error message when the email was already invited recently (API code REFERRAL_ALREADY_INVITED) */
57
+ alreadyInvited?: string;
58
+ /** Error message when the email already has an account (API code REFERRAL_ALREADY_REGISTERED) */
59
+ alreadyRegistered?: string;
60
+ }
61
+
62
+ /**
63
+ * The `code` the API puts on a refused invite's JSON:API error.
64
+ */
65
+ function inviteErrorCode(err: unknown): string | undefined {
66
+ return (err as { body?: { errors?: { code?: string }[] } })?.body?.errors?.[0]?.code;
76
67
  }
77
68
 
78
69
  /**
@@ -162,7 +153,28 @@ export function ReferralWidget({
162
153
  onInviteSent,
163
154
  onInviteError,
164
155
  }: ReferralWidgetProps) {
165
- const t = { ...DEFAULT_TRANSLATIONS, ...translations };
156
+ const t = useTranslations();
157
+ const text = {
158
+ title: t("referral.title"),
159
+ description: t("referral.description"),
160
+ yourLink: t("referral.yourLink"),
161
+ copyButton: t("referral.copy"),
162
+ copiedMessage: t("referral.linkCopied"),
163
+ completedLabel: t("referral.completedLabel"),
164
+ inviteTitle: t("referral.inviteTitle"),
165
+ inviteDescription: t("referral.inviteDescription"),
166
+ emailPlaceholder: t("referral.emailPlaceholder"),
167
+ sendButton: t("referral.send"),
168
+ sendingButton: t("referral.sending"),
169
+ inviteSent: t("referral.inviteSent"),
170
+ inviteError: t("referral.inviteFailed"),
171
+ copyError: t("referral.copyFailed"),
172
+ invalidEmail: t("referral.invalidEmail"),
173
+ loadError: t("referral.loadFailed"),
174
+ alreadyInvited: t("referral.alreadyInvited"),
175
+ alreadyRegistered: t("referral.alreadyRegistered"),
176
+ ...translations,
177
+ };
166
178
 
167
179
  // Call ALL hooks unconditionally at the top (React Rules of Hooks)
168
180
  const { stats, loading, error } = useReferralStats();
@@ -189,31 +201,49 @@ export function ReferralWidget({
189
201
  const success = await copyToClipboard(referralUrl);
190
202
  if (success) {
191
203
  setCopied(true);
192
- showToast(t.copiedMessage);
204
+ showToast(text.copiedMessage);
193
205
  onLinkCopied?.();
194
206
  setTimeout(() => setCopied(false), 2000);
195
207
  } else {
196
- showError(t.copyError);
208
+ showError(text.copyError);
197
209
  }
198
- }, [referralUrl, t.copiedMessage, t.copyError, onLinkCopied]);
210
+ }, [referralUrl, text.copiedMessage, text.copyError, onLinkCopied]);
199
211
 
200
212
  const handleSendInvite = useCallback(async () => {
201
213
  if (!email || !email.includes("@")) {
202
- showError(t.invalidEmail);
214
+ showError(text.invalidEmail);
203
215
  return;
204
216
  }
205
217
 
206
218
  try {
207
219
  await sendInvite(email);
208
- showToast(t.inviteSent);
220
+ showToast(text.inviteSent);
209
221
  onInviteSent?.(email);
210
222
  setEmail("");
211
223
  } catch (err) {
212
- const error = err instanceof Error ? err : new Error(t.inviteError);
213
- showError(error.message);
214
- onInviteError?.(error);
224
+ // The API's English text never reaches the user: a known refusal has its own
225
+ // translated message, anything else the generic one.
226
+ const code = inviteErrorCode(err);
227
+ showError(
228
+ code === "REFERRAL_ALREADY_INVITED"
229
+ ? text.alreadyInvited
230
+ : code === "REFERRAL_ALREADY_REGISTERED"
231
+ ? text.alreadyRegistered
232
+ : text.inviteError,
233
+ );
234
+ onInviteError?.(err instanceof Error ? err : new Error(text.inviteError));
215
235
  }
216
- }, [email, sendInvite, t.inviteSent, t.inviteError, t.invalidEmail, onInviteSent, onInviteError]);
236
+ }, [
237
+ email,
238
+ sendInvite,
239
+ text.inviteSent,
240
+ text.inviteError,
241
+ text.invalidEmail,
242
+ text.alreadyInvited,
243
+ text.alreadyRegistered,
244
+ onInviteSent,
245
+ onInviteError,
246
+ ]);
217
247
 
218
248
  const handleEmailKeyDown = useCallback(
219
249
  (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -245,7 +275,7 @@ export function ReferralWidget({
245
275
 
246
276
  // Error state
247
277
  if (error) {
248
- const errorMessage = error instanceof Error ? error.message : "Failed to load referral data";
278
+ const errorMessage = text.loadError;
249
279
  if (isDialog) {
250
280
  return <div className="text-destructive text-center text-sm">{errorMessage}</div>;
251
281
  }
@@ -266,15 +296,15 @@ export function ReferralWidget({
266
296
  <Users className="text-primary h-5 w-5" />
267
297
  </div>
268
298
  <div className="flex flex-col gap-1">
269
- <SectionHeader>{t.title}</SectionHeader>
270
- <p className="text-muted-foreground text-sm">{t.description}</p>
299
+ <SectionHeader>{text.title}</SectionHeader>
300
+ <p className="text-muted-foreground text-sm">{text.description}</p>
271
301
  </div>
272
302
  </div>
273
303
  )}
274
304
 
275
305
  {/* Referral Link */}
276
306
  <div className="flex flex-col gap-2">
277
- <Label>{t.yourLink}</Label>
307
+ <Label>{text.yourLink}</Label>
278
308
  <div className="flex gap-2">
279
309
  <Input ref={linkInputRef} value={referralUrl} readOnly className="tabular-nums text-sm" />
280
310
  <Button
@@ -283,7 +313,7 @@ export function ReferralWidget({
283
313
  size="icon"
284
314
  onClick={handleCopyLink}
285
315
  disabled={!referralUrl}
286
- aria-label={t.copyButton}
316
+ aria-label={text.copyButton}
287
317
  >
288
318
  <Copy className={`h-4 w-4 ${copied ? "text-success" : ""}`} />
289
319
  </Button>
@@ -292,15 +322,15 @@ export function ReferralWidget({
292
322
 
293
323
  {/* Email Invite */}
294
324
  <div className="flex flex-col gap-2">
295
- <Label>{t.inviteTitle}</Label>
296
- <p className="text-muted-foreground text-xs/relaxed">{t.inviteDescription}</p>
325
+ <Label>{text.inviteTitle}</Label>
326
+ <p className="text-muted-foreground text-xs/relaxed">{text.inviteDescription}</p>
297
327
  <div className="flex gap-2">
298
328
  <Input
299
329
  type="email"
300
330
  value={email}
301
331
  onChange={(e) => setEmail(e.target.value)}
302
332
  onKeyDown={handleEmailKeyDown}
303
- placeholder={t.emailPlaceholder}
333
+ placeholder={text.emailPlaceholder}
304
334
  disabled={sending}
305
335
  />
306
336
  <Button
@@ -309,7 +339,7 @@ export function ReferralWidget({
309
339
  size="icon"
310
340
  onClick={handleSendInvite}
311
341
  disabled={sending || !email}
312
- aria-label={sending ? t.sendingButton : t.sendButton}
342
+ aria-label={sending ? text.sendingButton : text.sendButton}
313
343
  >
314
344
  {sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Mail className="h-4 w-4" />}
315
345
  </Button>
@@ -320,7 +350,7 @@ export function ReferralWidget({
320
350
  {stats && stats.completedReferrals > 0 && (
321
351
  <div className="border-border border-t pt-3">
322
352
  <p className="text-muted-foreground text-sm">
323
- {t.completedLabel}: {stats.completedReferrals}
353
+ {text.completedLabel}: {stats.completedReferrals}
324
354
  </p>
325
355
  </div>
326
356
  )}