@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
|
@@ -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
|
|
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>{
|
|
88
|
-
<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
|
|
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 =
|
|
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(
|
|
204
|
+
showToast(text.copiedMessage);
|
|
193
205
|
onLinkCopied?.();
|
|
194
206
|
setTimeout(() => setCopied(false), 2000);
|
|
195
207
|
} else {
|
|
196
|
-
showError(
|
|
208
|
+
showError(text.copyError);
|
|
197
209
|
}
|
|
198
|
-
}, [referralUrl,
|
|
210
|
+
}, [referralUrl, text.copiedMessage, text.copyError, onLinkCopied]);
|
|
199
211
|
|
|
200
212
|
const handleSendInvite = useCallback(async () => {
|
|
201
213
|
if (!email || !email.includes("@")) {
|
|
202
|
-
showError(
|
|
214
|
+
showError(text.invalidEmail);
|
|
203
215
|
return;
|
|
204
216
|
}
|
|
205
217
|
|
|
206
218
|
try {
|
|
207
219
|
await sendInvite(email);
|
|
208
|
-
showToast(
|
|
220
|
+
showToast(text.inviteSent);
|
|
209
221
|
onInviteSent?.(email);
|
|
210
222
|
setEmail("");
|
|
211
223
|
} catch (err) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
}, [
|
|
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 =
|
|
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>{
|
|
270
|
-
<p className="text-muted-foreground text-sm">{
|
|
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>{
|
|
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={
|
|
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>{
|
|
296
|
-
<p className="text-muted-foreground text-xs/relaxed">{
|
|
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={
|
|
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 ?
|
|
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
|
-
{
|
|
353
|
+
{text.completedLabel}: {stats.completedReferrals}
|
|
324
354
|
</p>
|
|
325
355
|
</div>
|
|
326
356
|
)}
|