@carlonicora/nextjs-jsonapi 5.1.3 → 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.
- package/dist/{BlockNoteEditor-66FJUDL4.mjs → BlockNoteEditor-2QJVK2HH.mjs} +4 -4
- package/dist/{BlockNoteEditor-KO6YFNIU.js → BlockNoteEditor-EGY4ZAAM.js} +19 -19
- package/dist/{BlockNoteEditor-KO6YFNIU.js.map → BlockNoteEditor-EGY4ZAAM.js.map} +1 -1
- package/dist/{BlockNoteViewer-SUK7OMWH.mjs → BlockNoteViewer-6BQXJ4TB.mjs} +2 -2
- package/dist/{BlockNoteViewer-LK22AE7Z.js → BlockNoteViewer-I2MMFH3S.js} +3 -3
- package/dist/{BlockNoteViewer-LK22AE7Z.js.map → BlockNoteViewer-I2MMFH3S.js.map} +1 -1
- package/dist/auth.interface-CSzJ1oP1.d.ts +64 -0
- package/dist/auth.interface-DD4uQv2K.d.mts +64 -0
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-5JADF7ZA.mjs → chunk-2Z3ZCYXX.mjs} +171 -95
- package/dist/chunk-2Z3ZCYXX.mjs.map +1 -0
- package/dist/{chunk-SZQNUGDG.js → chunk-3VEQ5JZJ.js} +1005 -929
- package/dist/chunk-3VEQ5JZJ.js.map +1 -0
- package/dist/{chunk-TO6IYGML.js → chunk-75MLT74B.js} +126 -8
- package/dist/chunk-75MLT74B.js.map +1 -0
- package/dist/{chunk-67NJY7SM.js → chunk-EXICQC4A.js} +3 -3
- package/dist/{chunk-67NJY7SM.js.map → chunk-EXICQC4A.js.map} +1 -1
- package/dist/{chunk-OCJEXAGE.mjs → chunk-HNOWSFM5.mjs} +55 -4
- package/dist/chunk-HNOWSFM5.mjs.map +1 -0
- package/dist/{chunk-OKUGTXDN.mjs → chunk-JEZQTZQ4.mjs} +121 -3
- package/dist/chunk-JEZQTZQ4.mjs.map +1 -0
- package/dist/{chunk-KL7CAQP3.mjs → chunk-NPKFMRH5.mjs} +2 -2
- package/dist/{chunk-VVJDWQEI.js → chunk-S5MQDWWC.js} +55 -4
- package/dist/chunk-S5MQDWWC.js.map +1 -0
- package/dist/{chunk-XCHXOCEJ.js → chunk-THKYH65N.js} +4 -4
- package/dist/{chunk-XCHXOCEJ.js.map → chunk-THKYH65N.js.map} +1 -1
- package/dist/{chunk-XLVRAHUK.mjs → chunk-YMUPTXOT.mjs} +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +17 -3
- package/dist/components/index.d.ts +17 -3
- package/dist/components/index.js +6 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +5 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +27 -7
- package/dist/core/index.d.ts +27 -7
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +13 -3
- package/dist/{s3.service-C-4YpEMu.d.ts → s3.service-CY-jgOAM.d.ts} +1 -1
- package/dist/{s3.service-dVj-nxMl.d.mts → s3.service-Sri0QP7Z.d.mts} +1 -1
- package/dist/server/index.d.mts +2 -2
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/{waitlist.config-kPfjImle.d.mts → waitlist.config-Cagmgm7-.d.mts} +7 -0
- package/dist/{waitlist.config-kPfjImle.d.ts → waitlist.config-Cagmgm7-.d.ts} +7 -0
- package/messages/en.json +15 -1
- package/messages/it.json +15 -1
- package/package.json +1 -1
- package/scripts/validate-messages.mjs +1 -0
- package/src/components/index.ts +1 -0
- package/src/features/auth/components/buttons/GoogleSignInButton.tsx +8 -4
- package/src/features/auth/components/forms/Register.tsx +20 -8
- package/src/features/auth/data/auth.interface.ts +2 -0
- package/src/features/auth/data/auth.ts +10 -0
- package/src/features/signup-source/SignupSourceCapture.tsx +27 -0
- package/src/features/signup-source/__tests__/signup-source.spec.ts +161 -0
- package/src/features/signup-source/index.ts +9 -0
- package/src/features/signup-source/signup-source.ts +135 -0
- package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -0
- package/src/features/waitlist/components/forms/WaitlistQuestionnaireRenderer.tsx +15 -13
- package/src/features/waitlist/components/lists/WaitlistList.tsx +60 -22
- package/src/features/waitlist/components/sections/WaitlistHeroSection.tsx +6 -5
- package/src/features/waitlist/config/waitlist.config.ts +23 -0
- package/src/features/waitlist/data/Waitlist.ts +51 -3
- package/src/features/waitlist/data/WaitlistInterface.ts +9 -1
- package/src/features/waitlist/data/WaitlistService.ts +3 -2
- package/src/features/waitlist/data/__tests__/Waitlist.spec.ts +82 -0
- package/src/features/waitlist/data/__tests__/WaitlistService.spec.ts +48 -0
- package/src/features/waitlist/data/waitlist.fields.ts +1 -0
- package/src/features/waitlist/hooks/__tests__/formatSignupSource.spec.ts +24 -0
- package/src/features/waitlist/hooks/formatSignupSource.ts +14 -0
- package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +26 -14
- package/src/hooks/__tests__/usePageTracker.spec.tsx +16 -10
- package/src/index.ts +3 -0
- package/dist/auth.interface-CeFCz9NU.d.mts +0 -34
- package/dist/auth.interface-D1R7VVky.d.ts +0 -34
- package/dist/chunk-5JADF7ZA.mjs.map +0 -1
- package/dist/chunk-OCJEXAGE.mjs.map +0 -1
- package/dist/chunk-OKUGTXDN.mjs.map +0 -1
- package/dist/chunk-SZQNUGDG.js.map +0 -1
- package/dist/chunk-TO6IYGML.js.map +0 -1
- package/dist/chunk-VVJDWQEI.js.map +0 -1
- /package/dist/{BlockNoteEditor-66FJUDL4.mjs.map → BlockNoteEditor-2QJVK2HH.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-SUK7OMWH.mjs.map → BlockNoteViewer-6BQXJ4TB.mjs.map} +0 -0
- /package/dist/{chunk-KL7CAQP3.mjs.map → chunk-NPKFMRH5.mjs.map} +0 -0
- /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
|
-
|
|
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; //
|
|
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.
|
|
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
|
+
});
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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);
|
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 };
|