@carlonicora/nextjs-jsonapi 6.10.1 → 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.
- package/dist/{AbstractApiData-XLhBP5Tl.d.mts → AbstractApiData-CKy8tbde.d.mts} +6 -0
- package/dist/{AbstractApiData-DtitRWPz.d.ts → AbstractApiData-rgvs0cdY.d.ts} +6 -0
- package/dist/{BlockNoteEditor-GK3DRTOP.mjs → BlockNoteEditor-5WM5DX3J.mjs} +4 -4
- package/dist/{BlockNoteEditor-YOKJOVV3.js → BlockNoteEditor-GY5R25YI.js} +19 -19
- package/dist/{BlockNoteEditor-YOKJOVV3.js.map → BlockNoteEditor-GY5R25YI.js.map} +1 -1
- package/dist/{BlockNoteViewer-EKAWVLIR.js → BlockNoteViewer-FLUHEXE6.js} +3 -3
- package/dist/{BlockNoteViewer-EKAWVLIR.js.map → BlockNoteViewer-FLUHEXE6.js.map} +1 -1
- package/dist/{BlockNoteViewer-RWRI3QO2.mjs → BlockNoteViewer-UCA3LV3N.mjs} +2 -2
- package/dist/billing/index.js +491 -491
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-THZAK7S3.mjs → chunk-3PRLRCMF.mjs} +1281 -603
- package/dist/chunk-3PRLRCMF.mjs.map +1 -0
- package/dist/{chunk-AWLTSKMM.mjs → chunk-6RLATJ2L.mjs} +2 -2
- package/dist/{chunk-6HFRXEF5.mjs → chunk-BGLOFZ3K.mjs} +296 -6
- package/dist/chunk-BGLOFZ3K.mjs.map +1 -0
- package/dist/{chunk-KRQRJA4J.js → chunk-FNPXCYHO.js} +8 -8
- package/dist/{chunk-KRQRJA4J.js.map → chunk-FNPXCYHO.js.map} +1 -1
- package/dist/{chunk-4UFMLTL2.js → chunk-MP3DV2I5.js} +1647 -969
- package/dist/chunk-MP3DV2I5.js.map +1 -0
- package/dist/{chunk-RNZ3IK5J.js → chunk-OHJSI6L2.js} +4 -4
- package/dist/{chunk-RNZ3IK5J.js.map → chunk-OHJSI6L2.js.map} +1 -1
- package/dist/{chunk-SQTXJPRE.js → chunk-YGEOX2XX.js} +297 -7
- package/dist/chunk-YGEOX2XX.js.map +1 -0
- package/dist/{chunk-DPAM7BCE.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 +68 -30
- package/dist/components/index.d.ts +68 -30
- 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 +160 -4
- package/dist/core/index.d.ts +160 -4
- 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.d.mts +1 -1
- package/dist/features/analytics/index.d.ts +1 -1
- 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.d.mts +1 -1
- package/dist/features/tokenusage/index.d.ts +1 -1
- 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 +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -2
- package/dist/{HandbookSectionInterface-BPgqqwgw.d.mts → referral-admin.interface-C87ApGMR.d.mts} +42 -2
- package/dist/{HandbookSectionInterface-_51oA3om.d.ts → referral-admin.interface-CMwl5NiV.d.ts} +42 -2
- 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 +1 -1
- package/src/core/abstracts/AbstractApiData.ts +14 -3
- package/src/core/abstracts/__tests__/readIncludedWithMeta.spec.ts +103 -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/chunk-3TNGKMJU.mjs +0 -115
- package/dist/chunk-3TNGKMJU.mjs.map +0 -1
- package/dist/chunk-4UFMLTL2.js.map +0 -1
- package/dist/chunk-6HFRXEF5.mjs.map +0 -1
- package/dist/chunk-G3YKDIAL.js +0 -115
- package/dist/chunk-G3YKDIAL.js.map +0 -1
- package/dist/chunk-SQTXJPRE.js.map +0 -1
- package/dist/chunk-THZAK7S3.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-GK3DRTOP.mjs.map → BlockNoteEditor-5WM5DX3J.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-RWRI3QO2.mjs.map → BlockNoteViewer-UCA3LV3N.mjs.map} +0 -0
- /package/dist/{chunk-AWLTSKMM.mjs.map → chunk-6RLATJ2L.mjs.map} +0 -0
- /package/dist/{chunk-DPAM7BCE.mjs.map → chunk-YP6W4AB5.mjs.map} +0 -0
|
@@ -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
|
)}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useUsageFormatters } from "../../tokenusage/lib/formatters";
|
|
5
|
+
import { SEQUENTIAL_RAMP } from "../../tokenusage/lib/palette";
|
|
6
|
+
import { ReferralAdminSummaryInterface } from "../interfaces/referral-admin.interface";
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
summary: ReferralAdminSummaryInterface[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "From visit to paying": visits → invites → sign-ups → paying of the current
|
|
14
|
+
* window as plain proportional bars, each with its share of the previous step.
|
|
15
|
+
* Bars are scaled to the largest step, since invites can outnumber visits.
|
|
16
|
+
* The rows copy TokenUsageRankedBar: same track, ramp, sizes and columns.
|
|
17
|
+
*/
|
|
18
|
+
export function ReferralsAdminFunnel({ summary }: Props) {
|
|
19
|
+
const t = useTranslations();
|
|
20
|
+
const { decimal } = useUsageFormatters();
|
|
21
|
+
|
|
22
|
+
const current = summary.find((row) => row.window === "current");
|
|
23
|
+
|
|
24
|
+
const steps = [
|
|
25
|
+
{ label: t("referral.admin.tiles.visits"), value: current?.visits ?? 0 },
|
|
26
|
+
{ label: t("referral.admin.tiles.invites"), value: current?.invitesSent ?? 0 },
|
|
27
|
+
{ label: t("referral.admin.tiles.signups"), value: current?.signups ?? 0 },
|
|
28
|
+
{ label: t("referral.admin.tiles.paying"), value: current?.paying ?? 0 },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const max = Math.max(...steps.map((step) => step.value), 0);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<section data-testid="referrals-funnel" className="grid gap-2">
|
|
35
|
+
<h2 className="text-muted-foreground flex items-center gap-x-2 text-sm font-light whitespace-nowrap">
|
|
36
|
+
{t("referral.admin.funnel.title")}
|
|
37
|
+
</h2>
|
|
38
|
+
<div className="grid gap-0.5">
|
|
39
|
+
{steps.map((step, index) => {
|
|
40
|
+
const width = max > 0 ? (step.value / max) * 100 : 0;
|
|
41
|
+
const before = index > 0 ? steps[index - 1].value : undefined;
|
|
42
|
+
const share =
|
|
43
|
+
before === undefined ? "" : before ? `${decimal(Math.round((step.value / before) * 100), 0)}%` : "—";
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
key={step.label}
|
|
47
|
+
className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-0.5 py-0.5"
|
|
48
|
+
>
|
|
49
|
+
<div className="grid min-w-0 gap-0.5">
|
|
50
|
+
<span className="text-muted-foreground truncate text-xs">{step.label}</span>
|
|
51
|
+
{/* The bar is a magnitude drawn on an axis, not prose: it always grows
|
|
52
|
+
rightward and only its trailing corner is rounded, so the track and its fill
|
|
53
|
+
stay an LTR island. The label, value and share around it mirror normally.
|
|
54
|
+
rtl-ok: deliberate LTR island (chart) */}
|
|
55
|
+
<div className="bg-muted h-2 w-full overflow-hidden rounded-r-[4px]" dir="ltr">
|
|
56
|
+
<div
|
|
57
|
+
/* rtl-ok: inside the LTR island above */
|
|
58
|
+
className="h-full rounded-r-[4px]"
|
|
59
|
+
style={{
|
|
60
|
+
width: `${width}%`,
|
|
61
|
+
backgroundColor: SEQUENTIAL_RAMP[Math.min(index, SEQUENTIAL_RAMP.length - 1)],
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<span className="text-end text-xs tabular-nums">{decimal(step.value, 0)}</span>
|
|
67
|
+
<span className="text-muted-foreground w-16 text-end text-xs tabular-nums">{share}</span>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
);
|
|
74
|
+
}
|