@carlonicora/nextjs-jsonapi 5.1.2 → 5.2.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 (105) hide show
  1. package/dist/{BlockNoteEditor-AZ627NB7.mjs → BlockNoteEditor-2QJVK2HH.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-OP7LWYOG.js → BlockNoteEditor-EGY4ZAAM.js} +19 -19
  3. package/dist/{BlockNoteEditor-OP7LWYOG.js.map → BlockNoteEditor-EGY4ZAAM.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-SUK7OMWH.mjs → BlockNoteViewer-6BQXJ4TB.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-LK22AE7Z.js → BlockNoteViewer-I2MMFH3S.js} +3 -3
  6. package/dist/{BlockNoteViewer-LK22AE7Z.js.map → BlockNoteViewer-I2MMFH3S.js.map} +1 -1
  7. package/dist/auth.interface-CSzJ1oP1.d.ts +64 -0
  8. package/dist/auth.interface-DD4uQv2K.d.mts +64 -0
  9. package/dist/billing/index.js +441 -441
  10. package/dist/billing/index.mjs +3 -3
  11. package/dist/{chunk-DYITOVR5.mjs → chunk-2Z3ZCYXX.mjs} +173 -96
  12. package/dist/chunk-2Z3ZCYXX.mjs.map +1 -0
  13. package/dist/{chunk-YZN2774P.js → chunk-3VEQ5JZJ.js} +1336 -1259
  14. package/dist/chunk-3VEQ5JZJ.js.map +1 -0
  15. package/dist/{chunk-TO6IYGML.js → chunk-75MLT74B.js} +126 -8
  16. package/dist/chunk-75MLT74B.js.map +1 -0
  17. package/dist/{chunk-67NJY7SM.js → chunk-EXICQC4A.js} +3 -3
  18. package/dist/{chunk-67NJY7SM.js.map → chunk-EXICQC4A.js.map} +1 -1
  19. package/dist/{chunk-OCJEXAGE.mjs → chunk-HNOWSFM5.mjs} +55 -4
  20. package/dist/chunk-HNOWSFM5.mjs.map +1 -0
  21. package/dist/{chunk-OKUGTXDN.mjs → chunk-JEZQTZQ4.mjs} +121 -3
  22. package/dist/chunk-JEZQTZQ4.mjs.map +1 -0
  23. package/dist/{chunk-KL7CAQP3.mjs → chunk-NPKFMRH5.mjs} +2 -2
  24. package/dist/{chunk-VVJDWQEI.js → chunk-S5MQDWWC.js} +55 -4
  25. package/dist/chunk-S5MQDWWC.js.map +1 -0
  26. package/dist/{chunk-XCHXOCEJ.js → chunk-THKYH65N.js} +4 -4
  27. package/dist/{chunk-XCHXOCEJ.js.map → chunk-THKYH65N.js.map} +1 -1
  28. package/dist/{chunk-XLVRAHUK.mjs → chunk-YMUPTXOT.mjs} +2 -2
  29. package/dist/client/index.js +4 -4
  30. package/dist/client/index.mjs +3 -3
  31. package/dist/components/index.d.mts +17 -3
  32. package/dist/components/index.d.ts +17 -3
  33. package/dist/components/index.js +6 -4
  34. package/dist/components/index.js.map +1 -1
  35. package/dist/components/index.mjs +5 -3
  36. package/dist/contexts/index.js +4 -4
  37. package/dist/contexts/index.mjs +3 -3
  38. package/dist/core/index.d.mts +27 -7
  39. package/dist/core/index.d.ts +27 -7
  40. package/dist/core/index.js +2 -2
  41. package/dist/core/index.mjs +1 -1
  42. package/dist/features/analytics/index.js +122 -122
  43. package/dist/features/analytics/index.mjs +4 -4
  44. package/dist/features/analytics/server.js +4 -4
  45. package/dist/features/analytics/server.mjs +2 -2
  46. package/dist/features/help/index.js +37 -37
  47. package/dist/features/help/index.mjs +3 -3
  48. package/dist/features/tokenusage/index.js +125 -125
  49. package/dist/features/tokenusage/index.mjs +4 -4
  50. package/dist/index.d.mts +3 -3
  51. package/dist/index.d.ts +3 -3
  52. package/dist/index.js +13 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +13 -3
  55. package/dist/{s3.service-C-4YpEMu.d.ts → s3.service-CY-jgOAM.d.ts} +1 -1
  56. package/dist/{s3.service-dVj-nxMl.d.mts → s3.service-Sri0QP7Z.d.mts} +1 -1
  57. package/dist/server/index.d.mts +2 -2
  58. package/dist/server/index.d.ts +2 -2
  59. package/dist/server/index.js +3 -3
  60. package/dist/server/index.mjs +2 -2
  61. package/dist/{waitlist.config-kPfjImle.d.mts → waitlist.config-Cagmgm7-.d.mts} +7 -0
  62. package/dist/{waitlist.config-kPfjImle.d.ts → waitlist.config-Cagmgm7-.d.ts} +7 -0
  63. package/messages/en.json +15 -1
  64. package/messages/it.json +15 -1
  65. package/package.json +1 -1
  66. package/scripts/validate-messages.mjs +1 -0
  67. package/src/components/index.ts +1 -0
  68. package/src/features/auth/components/buttons/GoogleSignInButton.tsx +8 -4
  69. package/src/features/auth/components/forms/Register.tsx +20 -8
  70. package/src/features/auth/data/auth.interface.ts +2 -0
  71. package/src/features/auth/data/auth.ts +10 -0
  72. package/src/features/signup-source/SignupSourceCapture.tsx +27 -0
  73. package/src/features/signup-source/__tests__/signup-source.spec.ts +161 -0
  74. package/src/features/signup-source/index.ts +9 -0
  75. package/src/features/signup-source/signup-source.ts +135 -0
  76. package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -0
  77. package/src/features/waitlist/components/forms/WaitlistQuestionnaireRenderer.tsx +15 -13
  78. package/src/features/waitlist/components/lists/WaitlistList.tsx +60 -22
  79. package/src/features/waitlist/components/sections/WaitlistHeroSection.tsx +6 -5
  80. package/src/features/waitlist/config/waitlist.config.ts +23 -0
  81. package/src/features/waitlist/data/Waitlist.ts +51 -3
  82. package/src/features/waitlist/data/WaitlistInterface.ts +9 -1
  83. package/src/features/waitlist/data/WaitlistService.ts +3 -2
  84. package/src/features/waitlist/data/__tests__/Waitlist.spec.ts +82 -0
  85. package/src/features/waitlist/data/__tests__/WaitlistService.spec.ts +48 -0
  86. package/src/features/waitlist/data/waitlist.fields.ts +1 -0
  87. package/src/features/waitlist/hooks/__tests__/formatSignupSource.spec.ts +24 -0
  88. package/src/features/waitlist/hooks/formatSignupSource.ts +14 -0
  89. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +26 -14
  90. package/src/hooks/__tests__/useDataListRetriever.spec.ts +69 -0
  91. package/src/hooks/__tests__/usePageTracker.spec.tsx +16 -10
  92. package/src/hooks/useDataListRetriever.ts +6 -2
  93. package/src/index.ts +3 -0
  94. package/dist/auth.interface-CeFCz9NU.d.mts +0 -34
  95. package/dist/auth.interface-D1R7VVky.d.ts +0 -34
  96. package/dist/chunk-DYITOVR5.mjs.map +0 -1
  97. package/dist/chunk-OCJEXAGE.mjs.map +0 -1
  98. package/dist/chunk-OKUGTXDN.mjs.map +0 -1
  99. package/dist/chunk-TO6IYGML.js.map +0 -1
  100. package/dist/chunk-VVJDWQEI.js.map +0 -1
  101. package/dist/chunk-YZN2774P.js.map +0 -1
  102. /package/dist/{BlockNoteEditor-AZ627NB7.mjs.map → BlockNoteEditor-2QJVK2HH.mjs.map} +0 -0
  103. /package/dist/{BlockNoteViewer-SUK7OMWH.mjs.map → BlockNoteViewer-6BQXJ4TB.mjs.map} +0 -0
  104. /package/dist/{chunk-KL7CAQP3.mjs.map → chunk-NPKFMRH5.mjs.map} +0 -0
  105. /package/dist/{chunk-XLVRAHUK.mjs.map → chunk-YMUPTXOT.mjs.map} +0 -0
@@ -7,11 +7,17 @@ export class Waitlist extends AbstractApiData implements WaitlistInterface {
7
7
  private _gdprConsentAt?: string;
8
8
  private _marketingConsent?: boolean;
9
9
  private _marketingConsentAt?: string;
10
- private _questionnaire?: string;
10
+ private _questionnaire?: Record<string, any>;
11
11
  private _status: WaitlistStatus = "pending";
12
12
  private _confirmedAt?: string;
13
13
  private _invitedAt?: string;
14
14
  private _registeredAt?: string;
15
+ private _sourceRef?: string;
16
+ private _sourceUtmSource?: string;
17
+ private _sourceUtmMedium?: string;
18
+ private _sourceUtmCampaign?: string;
19
+ private _sourceReferrer?: string;
20
+ private _sourceLandingPath?: string;
15
21
 
16
22
  get email(): string {
17
23
  return this._email ?? "";
@@ -33,7 +39,7 @@ export class Waitlist extends AbstractApiData implements WaitlistInterface {
33
39
  return this._marketingConsentAt;
34
40
  }
35
41
 
36
- get questionnaire(): string | undefined {
42
+ get questionnaire(): Record<string, any> | undefined {
37
43
  return this._questionnaire;
38
44
  }
39
45
 
@@ -53,6 +59,30 @@ export class Waitlist extends AbstractApiData implements WaitlistInterface {
53
59
  return this._registeredAt;
54
60
  }
55
61
 
62
+ get sourceRef(): string | undefined {
63
+ return this._sourceRef;
64
+ }
65
+
66
+ get sourceUtmSource(): string | undefined {
67
+ return this._sourceUtmSource;
68
+ }
69
+
70
+ get sourceUtmMedium(): string | undefined {
71
+ return this._sourceUtmMedium;
72
+ }
73
+
74
+ get sourceUtmCampaign(): string | undefined {
75
+ return this._sourceUtmCampaign;
76
+ }
77
+
78
+ get sourceReferrer(): string | undefined {
79
+ return this._sourceReferrer;
80
+ }
81
+
82
+ get sourceLandingPath(): string | undefined {
83
+ return this._sourceLandingPath;
84
+ }
85
+
56
86
  rehydrate(data: JsonApiHydratedDataInterface): this {
57
87
  super.rehydrate(data);
58
88
 
@@ -61,11 +91,19 @@ export class Waitlist extends AbstractApiData implements WaitlistInterface {
61
91
  this._gdprConsentAt = data.jsonApi.attributes.gdprConsentAt;
62
92
  this._marketingConsent = data.jsonApi.attributes.marketingConsent;
63
93
  this._marketingConsentAt = data.jsonApi.attributes.marketingConsentAt;
64
- this._questionnaire = data.jsonApi.attributes.questionnaire;
94
+ // The API's descriptor transform parses the stored JSON string into an object
95
+ const questionnaire = data.jsonApi.attributes.questionnaire;
96
+ this._questionnaire = questionnaire && typeof questionnaire === "object" ? questionnaire : undefined;
65
97
  this._status = data.jsonApi.attributes.status;
66
98
  this._confirmedAt = data.jsonApi.attributes.confirmedAt;
67
99
  this._invitedAt = data.jsonApi.attributes.invitedAt;
68
100
  this._registeredAt = data.jsonApi.attributes.registeredAt;
101
+ this._sourceRef = data.jsonApi.attributes.sourceRef;
102
+ this._sourceUtmSource = data.jsonApi.attributes.sourceUtmSource;
103
+ this._sourceUtmMedium = data.jsonApi.attributes.sourceUtmMedium;
104
+ this._sourceUtmCampaign = data.jsonApi.attributes.sourceUtmCampaign;
105
+ this._sourceReferrer = data.jsonApi.attributes.sourceReferrer;
106
+ this._sourceLandingPath = data.jsonApi.attributes.sourceLandingPath;
69
107
 
70
108
  return this;
71
109
  }
@@ -99,6 +137,16 @@ export class Waitlist extends AbstractApiData implements WaitlistInterface {
99
137
  response.data.attributes.questionnaire = JSON.stringify(data.questionnaire);
100
138
  }
101
139
 
140
+ if (data.source !== undefined) {
141
+ const attributes = response.data.attributes;
142
+ if (data.source.sourceRef !== undefined) attributes.sourceRef = data.source.sourceRef;
143
+ if (data.source.sourceUtmSource !== undefined) attributes.sourceUtmSource = data.source.sourceUtmSource;
144
+ if (data.source.sourceUtmMedium !== undefined) attributes.sourceUtmMedium = data.source.sourceUtmMedium;
145
+ if (data.source.sourceUtmCampaign !== undefined) attributes.sourceUtmCampaign = data.source.sourceUtmCampaign;
146
+ if (data.source.sourceReferrer !== undefined) attributes.sourceReferrer = data.source.sourceReferrer;
147
+ if (data.source.sourceLandingPath !== undefined) attributes.sourceLandingPath = data.source.sourceLandingPath;
148
+ }
149
+
102
150
  return response;
103
151
  }
104
152
  }
@@ -1,4 +1,5 @@
1
1
  import { ApiDataInterface } from "../../../core";
2
+ import { SignupSource } from "../../signup-source/signup-source";
2
3
 
3
4
  export type WaitlistStatus = "pending" | "confirmed" | "invited" | "registered";
4
5
 
@@ -10,6 +11,7 @@ export type WaitlistInput = {
10
11
  marketingConsent?: boolean;
11
12
  marketingConsentAt?: string; // ISO timestamp when marketing consent was given
12
13
  questionnaire?: Record<string, any>; // Object for form handling, will be JSON.stringify'd by service
14
+ source?: SignupSource;
13
15
  };
14
16
 
15
17
  export interface WaitlistInterface extends ApiDataInterface {
@@ -18,11 +20,17 @@ export interface WaitlistInterface extends ApiDataInterface {
18
20
  get gdprConsentAt(): string;
19
21
  get marketingConsent(): boolean | undefined;
20
22
  get marketingConsentAt(): string | undefined;
21
- get questionnaire(): string | undefined; // JSON string from backend
23
+ get questionnaire(): Record<string, any> | undefined; // the API sends the answers already parsed
22
24
  get status(): WaitlistStatus;
23
25
  get confirmedAt(): string | undefined;
24
26
  get invitedAt(): string | undefined;
25
27
  get registeredAt(): string | undefined;
28
+ get sourceRef(): string | undefined;
29
+ get sourceUtmSource(): string | undefined;
30
+ get sourceUtmMedium(): string | undefined;
31
+ get sourceUtmCampaign(): string | undefined;
32
+ get sourceReferrer(): string | undefined;
33
+ get sourceLandingPath(): string | undefined;
26
34
  // createdAt and updatedAt inherited from ApiDataInterface
27
35
  }
28
36
 
@@ -101,11 +101,12 @@ export class WaitlistService extends AbstractService {
101
101
  }
102
102
 
103
103
  /**
104
- * Get statistics (admin)
104
+ * Get statistics (admin): GET /waitlists/stats, rehydrated as waitlist-stats
105
105
  */
106
106
  static async getStats(): Promise<WaitlistStatsInterface> {
107
107
  const endpoint = new EndpointCreator({
108
- endpoint: Modules.WaitlistStats,
108
+ endpoint: Modules.Waitlist,
109
+ id: "stats",
109
110
  });
110
111
 
111
112
  return this.callApi<WaitlistStatsInterface>({
@@ -0,0 +1,82 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
4
+ import { Waitlist } from "../Waitlist";
5
+ import { WaitlistInput } from "../WaitlistInterface";
6
+
7
+ // Waitlist.createJsonApi() resolves Modules.Waitlist through the real ModuleRegistry.
8
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
9
+ try {
10
+ ModuleRegistry.get(key as any);
11
+ } catch {
12
+ ModuleRegistry.register(key, module);
13
+ }
14
+ };
15
+
16
+ beforeAll(() => {
17
+ registerIfAbsent("Waitlist", { name: "waitlists", model: Waitlist } as any);
18
+ });
19
+
20
+ const baseInput: WaitlistInput = {
21
+ id: "11111111-1111-4111-8111-111111111111",
22
+ email: "gm@example.com",
23
+ gdprConsent: true,
24
+ gdprConsentAt: "2026-10-21T10:00:00.000Z",
25
+ };
26
+
27
+ const SOURCE_KEYS = [
28
+ "sourceRef",
29
+ "sourceUtmSource",
30
+ "sourceUtmMedium",
31
+ "sourceUtmCampaign",
32
+ "sourceReferrer",
33
+ "sourceLandingPath",
34
+ ];
35
+
36
+ describe("Waitlist.createJsonApi()", () => {
37
+ it("createJsonApi writes the six source attributes when present", () => {
38
+ const payload = new Waitlist().createJsonApi({
39
+ ...baseInput,
40
+ source: {
41
+ sourceRef: "discord-embed",
42
+ sourceUtmSource: "reddit",
43
+ sourceUtmMedium: "social",
44
+ sourceUtmCampaign: "r_rpg",
45
+ sourceReferrer: "www.enworld.org",
46
+ sourceLandingPath: "/it/pricing",
47
+ },
48
+ });
49
+
50
+ expect(payload.data.attributes).toMatchObject({
51
+ sourceRef: "discord-embed",
52
+ sourceUtmSource: "reddit",
53
+ sourceUtmMedium: "social",
54
+ sourceUtmCampaign: "r_rpg",
55
+ sourceReferrer: "www.enworld.org",
56
+ sourceLandingPath: "/it/pricing",
57
+ });
58
+ });
59
+
60
+ it("omits them when source is undefined", () => {
61
+ const payload = new Waitlist().createJsonApi(baseInput);
62
+
63
+ for (const key of SOURCE_KEYS) {
64
+ expect(payload.data.attributes).not.toHaveProperty(key);
65
+ }
66
+ });
67
+ });
68
+
69
+ describe("Waitlist.rehydrate()", () => {
70
+ const hydrate = (attributes: Record<string, any>) =>
71
+ new Waitlist().rehydrate({ jsonApi: { type: "waitlists", id: "w-1", attributes }, included: [] });
72
+
73
+ it("keeps the questionnaire object the API sends", () => {
74
+ const waitlist = hydrate({ questionnaire: { system: "dnd2024", headache: "Too many plot threads" } });
75
+
76
+ expect(waitlist.questionnaire).toEqual({ system: "dnd2024", headache: "Too many plot threads" });
77
+ });
78
+
79
+ it("leaves the questionnaire undefined when the API sends none", () => {
80
+ expect(hydrate({ questionnaire: null }).questionnaire).toBeUndefined();
81
+ });
82
+ });
@@ -0,0 +1,48 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AbstractService } from "../../../../core/abstracts/AbstractService";
3
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
4
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
5
+ import { ModuleFactory, ModuleWithPermissions } from "../../../../permissions/types";
6
+ import { WaitlistStatsModule } from "../../waitlist-stats.module";
7
+ import { WaitlistModule } from "../../waitlist.module";
8
+ import { WaitlistService } from "../WaitlistService";
9
+
10
+ const moduleFactory: ModuleFactory = (params) =>
11
+ ({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
12
+
13
+ // ModuleRegistry is shared across test files in a worker, hence the
14
+ // register-if-absent guard (same idiom as AnalyticsAdminService.spec.ts).
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
+ registerIfAbsent("Waitlist", WaitlistModule(moduleFactory));
25
+ registerIfAbsent("WaitlistStats", WaitlistStatsModule(moduleFactory));
26
+ });
27
+
28
+ type CallApiParams = { type: ApiRequestDataTypeInterface; method: string; endpoint: string };
29
+
30
+ // `callApi` is a protected static on AbstractService; spying on the base class
31
+ // intercepts every call without touching the HTTP layer.
32
+ const callApi = () => vi.spyOn(AbstractService as any, "callApi").mockResolvedValue({});
33
+ const firstCall = (spy: ReturnType<typeof callApi>): CallApiParams => spy.mock.calls[0][0] as CallApiParams;
34
+
35
+ describe("WaitlistService", () => {
36
+ beforeEach(() => vi.restoreAllMocks());
37
+
38
+ it("getStats calls waitlists/stats with type WaitlistStats", async () => {
39
+ const spy = callApi();
40
+
41
+ await WaitlistService.getStats();
42
+
43
+ const call = firstCall(spy);
44
+ expect(call.type).toBe(ModuleRegistry.get("WaitlistStats" as any));
45
+ expect(call.method).toBe("GET");
46
+ expect(call.endpoint).toMatch(/waitlists\/stats$/);
47
+ });
48
+ });
@@ -3,6 +3,7 @@ export enum WaitlistFields {
3
3
  email = "email",
4
4
  status = "status",
5
5
  createdAt = "createdAt",
6
+ source = "source",
6
7
  questionnaire = "questionnaire",
7
8
  actions = "actions",
8
9
  }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatSignupSource } from "../formatSignupSource";
3
+
4
+ describe("formatSignupSource", () => {
5
+ it("prefers ref over utm source", () => {
6
+ expect(formatSignupSource({ sourceRef: "discord-embed", sourceUtmSource: "reddit" })).toBe("discord-embed");
7
+ });
8
+
9
+ it("joins utm source and campaign", () => {
10
+ expect(formatSignupSource({ sourceUtmSource: "reddit", sourceUtmCampaign: "r_rpg" })).toBe("reddit / r_rpg");
11
+ });
12
+
13
+ it("shows utm source alone when there is no campaign", () => {
14
+ expect(formatSignupSource({ sourceUtmSource: "reddit" })).toBe("reddit");
15
+ });
16
+
17
+ it("falls back to the referring host", () => {
18
+ expect(formatSignupSource({ sourceReferrer: "enworld.org" })).toBe("enworld.org");
19
+ });
20
+
21
+ it("returns undefined for a direct visit", () => {
22
+ expect(formatSignupSource({})).toBeUndefined();
23
+ });
24
+ });
@@ -0,0 +1,14 @@
1
+ import { SignupSource } from "../../signup-source/signup-source";
2
+
3
+ /**
4
+ * The one-line source shown in the waitlist admin table, first rule that applies:
5
+ * ref; utm source (with " / utm campaign" when present); referring host.
6
+ * Undefined means a direct visit.
7
+ */
8
+ export function formatSignupSource(entry: SignupSource): string | undefined {
9
+ if (entry.sourceRef) return entry.sourceRef;
10
+ if (entry.sourceUtmSource)
11
+ return entry.sourceUtmCampaign ? `${entry.sourceUtmSource} / ${entry.sourceUtmCampaign}` : entry.sourceUtmSource;
12
+ if (entry.sourceReferrer) return entry.sourceReferrer;
13
+ return undefined;
14
+ }
@@ -9,18 +9,8 @@ import { useI18nLocale } from "../../../i18n";
9
9
  import { Badge, Button } from "../../../shadcnui";
10
10
  import { WaitlistFields } from "../data/waitlist.fields";
11
11
  import { WaitlistInterface, WaitlistStatus } from "../data/WaitlistInterface";
12
-
13
- /**
14
- * Parse questionnaire JSON string safely
15
- */
16
- function parseQuestionnaire(questionnaire: string | undefined): Record<string, any> | null {
17
- if (!questionnaire) return null;
18
- try {
19
- return JSON.parse(questionnaire);
20
- } catch {
21
- return null;
22
- }
23
- }
12
+ import { SIGNUP_SOURCE_FIELDS } from "../../signup-source/signup-source";
13
+ import { formatSignupSource } from "./formatSignupSource";
24
14
 
25
15
  /**
26
16
  * Columns for the administrative waitlist. The invite action needs the list's
@@ -86,13 +76,35 @@ export const useWaitlistTableStructure: UseTableStructureHook<WaitlistInterface,
86
76
  enableSorting: false,
87
77
  enableHiding: false,
88
78
  }),
79
+ [WaitlistFields.source]: () => ({
80
+ id: "source",
81
+ header: t("waitlist.admin.columns.source"),
82
+ cell: ({ row }: { row: TableContent<WaitlistInterface> }) => {
83
+ const entry: WaitlistInterface = row.original.jsonApiData;
84
+ const details = SIGNUP_SOURCE_FIELDS.filter((field) => !!entry[field])
85
+ .map((field) => `${t(`waitlist.admin.source.fields.${field}`)}: ${entry[field]}`)
86
+ .join("\n");
87
+ const source = formatSignupSource(entry);
88
+
89
+ if (!source) {
90
+ return (
91
+ <span className="text-muted-foreground" title={details || undefined}>
92
+ {t("waitlist.admin.source.direct")}
93
+ </span>
94
+ );
95
+ }
96
+
97
+ return <span title={details}>{source}</span>;
98
+ },
99
+ enableSorting: false,
100
+ enableHiding: false,
101
+ }),
89
102
  [WaitlistFields.questionnaire]: () => ({
90
103
  id: "questionnaire",
91
104
  accessorKey: "questionnaire",
92
105
  header: t("waitlist.admin.columns.questionnaire"),
93
106
  cell: ({ row }: { row: TableContent<WaitlistInterface> }) => {
94
- // IMPORTANT: Parse JSON string from backend
95
- const questionnaire = parseQuestionnaire(row.original.jsonApiData.questionnaire);
107
+ const questionnaire = row.original.jsonApiData.questionnaire;
96
108
  if (!questionnaire || Object.keys(questionnaire).length === 0) {
97
109
  return <span className="text-muted-foreground">-</span>;
98
110
  }
@@ -346,6 +346,75 @@ describe("useDataListRetriever", () => {
346
346
  });
347
347
  });
348
348
 
349
+ describe("concurrent fetches", () => {
350
+ function deferred<V>() {
351
+ let resolve!: (value: V) => void;
352
+ const promise = new Promise<V>((r) => (resolve = r));
353
+ return { promise, resolve };
354
+ }
355
+
356
+ it("issues a parameter change made while a fetch is in flight", async () => {
357
+ const retriever = vi.fn().mockResolvedValueOnce([createMockApiData({ type: "articles", id: "1" })]);
358
+
359
+ const { result } = renderHook(() => useDataListRetriever({ retriever, module: mockModule }));
360
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
361
+
362
+ const slow = deferred<any[]>();
363
+ retriever.mockReturnValueOnce(slow.promise).mockResolvedValue([createMockApiData({ type: "articles", id: "2" })]);
364
+
365
+ act(() => {
366
+ result.current.addAdditionalParameter("completed", "completed");
367
+ });
368
+ act(() => {
369
+ result.current.removeAdditionalParameter("completed");
370
+ });
371
+
372
+ expect(retriever).toHaveBeenCalledTimes(3);
373
+ expect(retriever.mock.calls[2][0].completed).toBeUndefined();
374
+
375
+ await act(async () => {
376
+ slow.resolve([]);
377
+ });
378
+ });
379
+
380
+ it("keeps the newer response when the superseded one arrives last", async () => {
381
+ const retriever = vi.fn().mockResolvedValueOnce([createMockApiData({ type: "articles", id: "1" })]);
382
+
383
+ const { result } = renderHook(() => useDataListRetriever({ retriever, module: mockModule }));
384
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
385
+
386
+ const older = deferred<any[]>();
387
+ const newer = deferred<any[]>();
388
+ retriever.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
389
+
390
+ act(() => {
391
+ result.current.addAdditionalParameter("completed", "completed");
392
+ });
393
+ act(() => {
394
+ result.current.removeAdditionalParameter("completed");
395
+ });
396
+
397
+ await act(async () => {
398
+ newer.resolve([createMockApiData({ type: "articles", id: "open" })]);
399
+ });
400
+ await act(async () => {
401
+ older.resolve([createMockApiData({ type: "articles", id: "done" })]);
402
+ });
403
+
404
+ expect(result.current.data?.map((item: any) => item.id)).toEqual(["open"]);
405
+ expect(result.current.isLoaded).toBe(true);
406
+ });
407
+
408
+ it("still sends one request for the first load", async () => {
409
+ const retriever = vi.fn().mockResolvedValue([createMockApiData({ type: "articles", id: "1" })]);
410
+
411
+ const { result } = renderHook(() => useDataListRetriever({ retriever, module: mockModule }));
412
+ await waitFor(() => expect(result.current.isLoaded).toBe(true));
413
+
414
+ expect(retriever).toHaveBeenCalledTimes(1);
415
+ });
416
+ });
417
+
349
418
  describe("skipped calls", () => {
350
419
  it("still fetches a later search after a call the already-loaded guard skipped", async () => {
351
420
  const retriever = vi.fn().mockResolvedValue([createMockApiData({ type: "articles", id: "1" })]);
@@ -245,16 +245,22 @@ describe("usePageTracker", () => {
245
245
  const view = mount(store);
246
246
  await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
247
247
  const firstTimestamp = pages(store)[0].timestamp;
248
-
249
- pathnameRef.current = "/campaigns/camp-1//npcs";
250
- document.title = "[Campaign] NPCs Venezia Obscura | App";
251
- view.rerender(
252
- <Provider store={store}>
253
- <Harness />
254
- </Provider>,
255
- );
256
-
257
- await waitFor(() => expect(pages(store)[0]?.timestamp).not.toBe(firstTimestamp));
248
+ // Both visits can land in the same millisecond; move the clock so the refresh is observable.
249
+ const now = vi.spyOn(Date, "now").mockReturnValue(firstTimestamp + 1000);
250
+
251
+ try {
252
+ pathnameRef.current = "/campaigns/camp-1//npcs";
253
+ document.title = "[Campaign] NPCs Venezia Obscura | App";
254
+ view.rerender(
255
+ <Provider store={store}>
256
+ <Harness />
257
+ </Provider>,
258
+ );
259
+
260
+ await waitFor(() => expect(pages(store)[0]?.timestamp).toBe(firstTimestamp + 1000));
261
+ } finally {
262
+ now.mockRestore();
263
+ }
258
264
  expect(pages(store)[0].title).toBe("Venezia Obscura");
259
265
  expect(pages(store)[0].url).toBe("/campaigns/camp-1");
260
266
  expect(pages(store)).toHaveLength(1);
@@ -136,8 +136,12 @@ export function useDataListRetriever<T>(params: {
136
136
  return;
137
137
  }
138
138
 
139
- // Prevent concurrent fetches (unless it's a pagination call)
140
- if (isFetchingRef.current && !fetchParams?.callNext && !fetchParams?.callPrevious) return;
139
+ // Prevent concurrent fetches, except a refine (search / parameter change) or pagination.
140
+ // A refine has already written its term or parameter into the refs, so dropping it lost
141
+ // the user's filter for good; it supersedes the in-flight call instead, whose response the
142
+ // request-id check below discards.
143
+ if (isFetchingRef.current && !fetchParams?.isRefine && !fetchParams?.callNext && !fetchParams?.callPrevious)
144
+ return;
141
145
 
142
146
  if (stableParams.requiresSearch === true && fetchParams?.isRefine !== true && fetchParams?.isRefresh !== true) {
143
147
  return;
package/src/index.ts CHANGED
@@ -47,6 +47,9 @@ export type {
47
47
  export { configureReferral, getReferralConfig, isReferralEnabled } from "./features/referral/config";
48
48
  export type { ReferralConfig } from "./features/referral/config";
49
49
 
50
+ // Sign-up source capture (utilities; SignupSourceCapture component via /components)
51
+ export * from "./features/signup-source";
52
+
50
53
  // Toast utilities
51
54
  export { showToast, showError, dismissToast, showCustomToast, type ToastOptions } from "./utils/toast";
52
55
 
@@ -1,34 +0,0 @@
1
- import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.mjs';
2
- import { U as UserInterface } from './user.interface-CIWGlbqM.mjs';
3
-
4
- type AuthInput = {
5
- id?: string;
6
- email?: string | undefined | null;
7
- password?: string | undefined;
8
- name?: string;
9
- companyName?: string;
10
- partitaIva?: string;
11
- codiceFiscale?: string;
12
- termsAcceptedAt?: string;
13
- marketingConsent?: boolean;
14
- marketingConsentAt?: string | null;
15
- inviteCode?: string;
16
- referralCode?: string;
17
- };
18
- type AuthQuery = {
19
- userId?: string;
20
- tokenCode?: string;
21
- refreshToken?: string;
22
- login?: boolean;
23
- forgot?: boolean;
24
- code?: string;
25
- activationCode?: string;
26
- checkUsername?: string;
27
- };
28
- interface AuthInterface extends ApiDataInterface {
29
- get token(): string;
30
- get refreshToken(): string;
31
- get user(): UserInterface;
32
- }
33
-
34
- export type { AuthInput as A, AuthQuery as a, AuthInterface as b };
@@ -1,34 +0,0 @@
1
- import { A as ApiDataInterface } from './ApiDataInterface-BcZeXy5X.js';
2
- import { U as UserInterface } from './user.interface-C0ZdsBZj.js';
3
-
4
- type AuthInput = {
5
- id?: string;
6
- email?: string | undefined | null;
7
- password?: string | undefined;
8
- name?: string;
9
- companyName?: string;
10
- partitaIva?: string;
11
- codiceFiscale?: string;
12
- termsAcceptedAt?: string;
13
- marketingConsent?: boolean;
14
- marketingConsentAt?: string | null;
15
- inviteCode?: string;
16
- referralCode?: string;
17
- };
18
- type AuthQuery = {
19
- userId?: string;
20
- tokenCode?: string;
21
- refreshToken?: string;
22
- login?: boolean;
23
- forgot?: boolean;
24
- code?: string;
25
- activationCode?: string;
26
- checkUsername?: string;
27
- };
28
- interface AuthInterface extends ApiDataInterface {
29
- get token(): string;
30
- get refreshToken(): string;
31
- get user(): UserInterface;
32
- }
33
-
34
- export type { AuthInput as A, AuthQuery as a, AuthInterface as b };