@carlonicora/nextjs-jsonapi 6.10.2 → 6.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/{BlockNoteEditor-7GQWHXBC.mjs → BlockNoteEditor-5WM5DX3J.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-LX7NMBV6.js → BlockNoteEditor-GY5R25YI.js} +19 -19
  3. package/dist/{BlockNoteEditor-LX7NMBV6.js.map → BlockNoteEditor-GY5R25YI.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-NXV5PKR4.js → BlockNoteViewer-FLUHEXE6.js} +3 -3
  5. package/dist/{BlockNoteViewer-NXV5PKR4.js.map → BlockNoteViewer-FLUHEXE6.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-D3Y4FUST.mjs → BlockNoteViewer-UCA3LV3N.mjs} +2 -2
  7. package/dist/billing/index.js +491 -491
  8. package/dist/billing/index.mjs +3 -3
  9. package/dist/{chunk-LDUTS7SJ.mjs → chunk-3PRLRCMF.mjs} +1281 -603
  10. package/dist/chunk-3PRLRCMF.mjs.map +1 -0
  11. package/dist/{chunk-QB2XU64X.mjs → chunk-6RLATJ2L.mjs} +2 -2
  12. package/dist/{chunk-62P4KIR2.mjs → chunk-BGLOFZ3K.mjs} +282 -2
  13. package/dist/chunk-BGLOFZ3K.mjs.map +1 -0
  14. package/dist/{chunk-62VVBUIL.js → chunk-FNPXCYHO.js} +8 -8
  15. package/dist/{chunk-62VVBUIL.js.map → chunk-FNPXCYHO.js.map} +1 -1
  16. package/dist/{chunk-3I5JCFPY.js → chunk-MP3DV2I5.js} +1647 -969
  17. package/dist/chunk-MP3DV2I5.js.map +1 -0
  18. package/dist/{chunk-TOPGUZSX.js → chunk-OHJSI6L2.js} +4 -4
  19. package/dist/{chunk-TOPGUZSX.js.map → chunk-OHJSI6L2.js.map} +1 -1
  20. package/dist/{chunk-Z4OVEA3N.js → chunk-YGEOX2XX.js} +283 -3
  21. package/dist/chunk-YGEOX2XX.js.map +1 -0
  22. package/dist/{chunk-GHAZOQTT.mjs → chunk-YP6W4AB5.mjs} +2 -2
  23. package/dist/client/index.js +4 -4
  24. package/dist/client/index.mjs +3 -3
  25. package/dist/components/index.d.mts +67 -29
  26. package/dist/components/index.d.ts +67 -29
  27. package/dist/components/index.js +10 -4
  28. package/dist/components/index.js.map +1 -1
  29. package/dist/components/index.mjs +9 -3
  30. package/dist/contexts/index.js +4 -4
  31. package/dist/contexts/index.mjs +3 -3
  32. package/dist/core/index.d.mts +159 -3
  33. package/dist/core/index.d.ts +159 -3
  34. package/dist/core/index.js +14 -2
  35. package/dist/core/index.js.map +1 -1
  36. package/dist/core/index.mjs +13 -1
  37. package/dist/email-automations/index.js +93 -93
  38. package/dist/email-automations/index.mjs +3 -3
  39. package/dist/features/analytics/index.js +120 -122
  40. package/dist/features/analytics/index.js.map +1 -1
  41. package/dist/features/analytics/index.mjs +7 -9
  42. package/dist/features/analytics/index.mjs.map +1 -1
  43. package/dist/features/analytics/server.js +4 -4
  44. package/dist/features/analytics/server.mjs +2 -2
  45. package/dist/features/blog/index.js +150 -150
  46. package/dist/features/blog/index.mjs +3 -3
  47. package/dist/features/blog/server.js +16 -16
  48. package/dist/features/blog/server.mjs +1 -1
  49. package/dist/features/help/index.js +37 -37
  50. package/dist/features/help/index.mjs +3 -3
  51. package/dist/features/tokenusage/index.js +126 -128
  52. package/dist/features/tokenusage/index.js.map +1 -1
  53. package/dist/features/tokenusage/index.mjs +14 -16
  54. package/dist/features/tokenusage/index.mjs.map +1 -1
  55. package/dist/index.d.mts +2 -2
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.js +15 -3
  58. package/dist/index.js.map +1 -1
  59. package/dist/index.mjs +14 -2
  60. package/dist/{HandbookSectionInterface-5I57rC1b.d.mts → referral-admin.interface-C87ApGMR.d.mts} +41 -1
  61. package/dist/{HandbookSectionInterface-IDYEzQMi.d.ts → referral-admin.interface-CMwl5NiV.d.ts} +41 -1
  62. package/dist/server/index.js +3 -3
  63. package/dist/server/index.mjs +2 -2
  64. package/messages/en.json +78 -1
  65. package/messages/it.json +78 -1
  66. package/package.json +1 -1
  67. package/src/core/index.ts +2 -0
  68. package/src/core/registry/ModuleRegistry.ts +5 -0
  69. package/src/features/referral/__tests__/referral-admin-models.spec.ts +122 -0
  70. package/src/features/referral/__tests__/referral-admin-service.spec.ts +70 -0
  71. package/src/features/referral/__tests__/referral-code-capture.spec.tsx +105 -0
  72. package/src/features/referral/__tests__/referral-invite-errors.spec.tsx +82 -0
  73. package/src/features/referral/__tests__/referral-visit-service.spec.ts +47 -0
  74. package/src/features/referral/__tests__/referrals-admin-container.spec.tsx +230 -0
  75. package/src/features/referral/components/ReferralCodeCapture.tsx +3 -0
  76. package/src/features/referral/components/ReferralDialog.tsx +9 -13
  77. package/src/features/referral/components/ReferralWidget.tsx +75 -45
  78. package/src/features/referral/components/ReferralsAdminFunnel.tsx +74 -0
  79. package/src/features/referral/components/ReferralsAdminTiles.tsx +128 -0
  80. package/src/features/referral/components/containers/ReferralsAdminContainer.tsx +205 -0
  81. package/src/features/referral/components/index.ts +3 -0
  82. package/src/features/referral/data/ReferralAdminReferral.ts +65 -0
  83. package/src/features/referral/data/ReferralAdminReferrer.ts +65 -0
  84. package/src/features/referral/data/ReferralAdminService.ts +51 -0
  85. package/src/features/referral/data/ReferralAdminSummary.ts +60 -0
  86. package/src/features/referral/data/ReferralService.ts +20 -0
  87. package/src/features/referral/data/ReferralVisit.ts +23 -0
  88. package/src/features/referral/data/index.ts +5 -0
  89. package/src/features/referral/hooks/index.ts +2 -0
  90. package/src/features/referral/hooks/useReferralAdminReferralTableStructure.tsx +127 -0
  91. package/src/features/referral/hooks/useReferralAdminReferrerTableStructure.tsx +229 -0
  92. package/src/features/referral/hooks/useReferralInvite.ts +2 -0
  93. package/src/features/referral/index.ts +1 -0
  94. package/src/features/referral/interfaces/index.ts +1 -0
  95. package/src/features/referral/interfaces/referral-admin.interface.ts +45 -0
  96. package/src/features/referral/referral-admin.modules.ts +32 -0
  97. package/dist/chunk-3I5JCFPY.js.map +0 -1
  98. package/dist/chunk-62P4KIR2.mjs.map +0 -1
  99. package/dist/chunk-D6UX37IA.mjs +0 -115
  100. package/dist/chunk-D6UX37IA.mjs.map +0 -1
  101. package/dist/chunk-KEHP2OBQ.js +0 -115
  102. package/dist/chunk-KEHP2OBQ.js.map +0 -1
  103. package/dist/chunk-LDUTS7SJ.mjs.map +0 -1
  104. package/dist/chunk-Z4OVEA3N.js.map +0 -1
  105. /package/dist/{BlockNoteEditor-7GQWHXBC.mjs.map → BlockNoteEditor-5WM5DX3J.mjs.map} +0 -0
  106. /package/dist/{BlockNoteViewer-D3Y4FUST.mjs.map → BlockNoteViewer-UCA3LV3N.mjs.map} +0 -0
  107. /package/dist/{chunk-QB2XU64X.mjs.map → chunk-6RLATJ2L.mjs.map} +0 -0
  108. /package/dist/{chunk-GHAZOQTT.mjs.map → chunk-YP6W4AB5.mjs.map} +0 -0
@@ -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
  )}
@@ -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
+ }