@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
package/messages/it.json
CHANGED
|
@@ -1785,7 +1785,84 @@
|
|
|
1785
1785
|
"referral": {
|
|
1786
1786
|
"description": "Invita i tuoi amici e guadagna ricompense",
|
|
1787
1787
|
"title": "Invita e guadagna",
|
|
1788
|
-
"yourLink": "Il tuo link di invito"
|
|
1788
|
+
"yourLink": "Il tuo link di invito",
|
|
1789
|
+
"copy": "Copia link",
|
|
1790
|
+
"linkCopied": "Link copiato",
|
|
1791
|
+
"completedLabel": "Inviti andati a buon fine",
|
|
1792
|
+
"inviteTitle": "Invita via email",
|
|
1793
|
+
"inviteDescription": "Il tuo nome e la tua email vengono condivisi con la persona che inviti.",
|
|
1794
|
+
"emailPlaceholder": "amico@esempio.com",
|
|
1795
|
+
"send": "Invia invito",
|
|
1796
|
+
"sending": "Invio…",
|
|
1797
|
+
"inviteSent": "Invito inviato",
|
|
1798
|
+
"inviteFailed": "Impossibile inviare l'invito",
|
|
1799
|
+
"copyFailed": "Impossibile copiare il link",
|
|
1800
|
+
"invalidEmail": "Inserisci un indirizzo email valido",
|
|
1801
|
+
"loadFailed": "Impossibile caricare il tuo link di invito",
|
|
1802
|
+
"alreadyInvited": "Hai già invitato questa email di recente",
|
|
1803
|
+
"alreadyRegistered": "Questa email ha già un account",
|
|
1804
|
+
"admin": {
|
|
1805
|
+
"title": "Referral",
|
|
1806
|
+
"description": "Inviti, iscrizioni e referral paganti",
|
|
1807
|
+
"period": {
|
|
1808
|
+
"30d": "30 giorni",
|
|
1809
|
+
"90d": "90 giorni",
|
|
1810
|
+
"all": "Sempre"
|
|
1811
|
+
},
|
|
1812
|
+
"tiles": {
|
|
1813
|
+
"sharing": "Condividono",
|
|
1814
|
+
"sharingOf": "{count} su {total} account abbonati",
|
|
1815
|
+
"visits": "Visite al link",
|
|
1816
|
+
"invites": "Email di invito inviate",
|
|
1817
|
+
"signups": "Iscritti",
|
|
1818
|
+
"paying": "Paganti",
|
|
1819
|
+
"credits": "Crediti assegnati",
|
|
1820
|
+
"change": "{delta} rispetto al periodo precedente"
|
|
1821
|
+
},
|
|
1822
|
+
"funnel": {
|
|
1823
|
+
"title": "Dalla visita al pagamento"
|
|
1824
|
+
},
|
|
1825
|
+
"referrers": {
|
|
1826
|
+
"title": "Chi invita di più",
|
|
1827
|
+
"sortBy": "Ordina per",
|
|
1828
|
+
"copyAll": "Copia tutte le email",
|
|
1829
|
+
"copied": "Email copiate",
|
|
1830
|
+
"copyEmail": "Copia email",
|
|
1831
|
+
"copyFailed": "Impossibile copiare le email",
|
|
1832
|
+
"empty": "Nessuna attività di referral in questo periodo",
|
|
1833
|
+
"split": "{link} via link, {direct} dirette",
|
|
1834
|
+
"columns": {
|
|
1835
|
+
"referrer": "Referente",
|
|
1836
|
+
"visits": "Visite",
|
|
1837
|
+
"invites": "Email inviate",
|
|
1838
|
+
"signups": "Iscritti",
|
|
1839
|
+
"paying": "Paganti",
|
|
1840
|
+
"conversion": "Conversione",
|
|
1841
|
+
"credits": "Crediti guadagnati",
|
|
1842
|
+
"lastInvite": "Ultimo invito"
|
|
1843
|
+
}
|
|
1844
|
+
},
|
|
1845
|
+
"referrals": {
|
|
1846
|
+
"title": "Tutti i referral",
|
|
1847
|
+
"empty": "Nessun referral in questo periodo",
|
|
1848
|
+
"columns": {
|
|
1849
|
+
"account": "Nuovo account",
|
|
1850
|
+
"referrer": "Portato da",
|
|
1851
|
+
"source": "Come",
|
|
1852
|
+
"date": "Data",
|
|
1853
|
+
"status": "Stato",
|
|
1854
|
+
"credits": "Crediti assegnati"
|
|
1855
|
+
}
|
|
1856
|
+
},
|
|
1857
|
+
"source": {
|
|
1858
|
+
"link": "Link di invito",
|
|
1859
|
+
"direct": "Diretto"
|
|
1860
|
+
},
|
|
1861
|
+
"status": {
|
|
1862
|
+
"pending": "In attesa",
|
|
1863
|
+
"completed": "Pagante"
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1789
1866
|
},
|
|
1790
1867
|
"role": {
|
|
1791
1868
|
"fields": {
|
package/package.json
CHANGED
|
@@ -227,7 +227,7 @@ export abstract class AbstractApiData implements ApiDataInterface {
|
|
|
227
227
|
|
|
228
228
|
// Merge per-item meta from relationships[type].data[].meta
|
|
229
229
|
if (item.meta) {
|
|
230
|
-
|
|
230
|
+
AbstractApiData._mergeMeta(entity, item.meta);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
result.push(entity as T & M);
|
|
@@ -256,7 +256,7 @@ export abstract class AbstractApiData implements ApiDataInterface {
|
|
|
256
256
|
// Merge relationship-level meta for single relationships
|
|
257
257
|
const relationshipMeta = data.jsonApi.relationships[type].meta;
|
|
258
258
|
if (relationshipMeta) {
|
|
259
|
-
|
|
259
|
+
AbstractApiData._mergeMeta(entity, relationshipMeta);
|
|
260
260
|
}
|
|
261
261
|
return entity as T & M;
|
|
262
262
|
}
|
|
@@ -272,12 +272,23 @@ export abstract class AbstractApiData implements ApiDataInterface {
|
|
|
272
272
|
// Merge relationship-level meta for single relationships
|
|
273
273
|
const relationshipMeta = data.jsonApi.relationships[type].meta;
|
|
274
274
|
if (relationshipMeta) {
|
|
275
|
-
|
|
275
|
+
AbstractApiData._mergeMeta(entity, relationshipMeta);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
return entity as T & M;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Copies relationship meta onto a rehydrated entity as own data properties. Object.assign throws
|
|
283
|
+
* when the model declares a getter-only accessor of the same name (an app's `Chunk` with
|
|
284
|
+
* `get relevance()`); an own property shadows that getter, so the model reads the meta value.
|
|
285
|
+
*/
|
|
286
|
+
private static _mergeMeta(entity: object, meta: Record<string, any>): void {
|
|
287
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
288
|
+
Object.defineProperty(entity, key, { value, writable: true, enumerable: true, configurable: true });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
281
292
|
dehydrate(): JsonApiHydratedDataInterface {
|
|
282
293
|
return {
|
|
283
294
|
jsonApi: this._jsonApi,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { AbstractApiData } from "../AbstractApiData";
|
|
3
|
+
import { DataClassRegistry } from "../../registry/DataClassRegistry";
|
|
4
|
+
import { ApiDataInterface } from "../../interfaces/ApiDataInterface";
|
|
5
|
+
import { JsonApiHydratedDataInterface } from "../../interfaces/JsonApiHydratedDataInterface";
|
|
6
|
+
import { ApiRequestDataTypeInterface } from "../../interfaces/ApiRequestDataTypeInterface";
|
|
7
|
+
|
|
8
|
+
// Concrete test class to access the protected method
|
|
9
|
+
class TestModel extends AbstractApiData {
|
|
10
|
+
public testReadIncludedWithMeta<T extends ApiDataInterface, M extends Record<string, any>>(
|
|
11
|
+
data: JsonApiHydratedDataInterface,
|
|
12
|
+
type: string,
|
|
13
|
+
dataType: ApiRequestDataTypeInterface,
|
|
14
|
+
): (T & M) | (T & M)[] | undefined {
|
|
15
|
+
return this._readIncludedWithMeta<T, M>(data, type, dataType);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Mirrors an app model that exposes the relationship meta through a getter only (a360ai's Chunk)
|
|
20
|
+
class MockChunk extends AbstractApiData {
|
|
21
|
+
private _relevance?: number;
|
|
22
|
+
|
|
23
|
+
get relevance(): number {
|
|
24
|
+
return this._relevance ?? 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
28
|
+
super.rehydrate(data);
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const chunkModule: ApiRequestDataTypeInterface = {
|
|
34
|
+
name: "chunks",
|
|
35
|
+
model: MockChunk,
|
|
36
|
+
} as any;
|
|
37
|
+
|
|
38
|
+
describe("_readIncludedWithMeta", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
DataClassRegistry.clear();
|
|
41
|
+
DataClassRegistry.registerObjectClass(chunkModule, MockChunk);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("merges per-item meta onto a model whose property is a getter only", () => {
|
|
45
|
+
// GIVEN: an assistant message citing two chunks with relevance meta on the relationship
|
|
46
|
+
const data: JsonApiHydratedDataInterface = {
|
|
47
|
+
jsonApi: {
|
|
48
|
+
id: "message-1",
|
|
49
|
+
type: "assistant-messages",
|
|
50
|
+
attributes: {},
|
|
51
|
+
relationships: {
|
|
52
|
+
citations: {
|
|
53
|
+
data: [
|
|
54
|
+
{ id: "chunk-1", type: "chunks", meta: { relevance: 0.9, reason: "names the deadline" } },
|
|
55
|
+
{ id: "chunk-2", type: "chunks", meta: { relevance: 0.4 } },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
included: [
|
|
61
|
+
{ id: "chunk-1", type: "chunks", attributes: { content: "a" } },
|
|
62
|
+
{ id: "chunk-2", type: "chunks", attributes: { content: "b" } },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// WHEN: the citations are read with their meta
|
|
67
|
+
const result = new TestModel().testReadIncludedWithMeta<MockChunk, { relevance: number; reason?: string }>(
|
|
68
|
+
data,
|
|
69
|
+
"citations",
|
|
70
|
+
chunkModule,
|
|
71
|
+
) as (MockChunk & { reason?: string })[];
|
|
72
|
+
|
|
73
|
+
// THEN: no TypeError, and the getter-only property reads the meta value
|
|
74
|
+
expect(result).toHaveLength(2);
|
|
75
|
+
expect(result[0]).toBeInstanceOf(MockChunk);
|
|
76
|
+
expect(result[0].relevance).toBe(0.9);
|
|
77
|
+
expect(result[0].reason).toBe("names the deadline");
|
|
78
|
+
expect(result[1].relevance).toBe(0.4);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("merges relationship-level meta on a single relationship", () => {
|
|
82
|
+
const data: JsonApiHydratedDataInterface = {
|
|
83
|
+
jsonApi: {
|
|
84
|
+
id: "message-1",
|
|
85
|
+
type: "assistant-messages",
|
|
86
|
+
attributes: {},
|
|
87
|
+
relationships: {
|
|
88
|
+
topCitation: { data: { id: "chunk-1", type: "chunks" }, meta: { relevance: 0.7 } },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
included: [{ id: "chunk-1", type: "chunks", attributes: { content: "a" } }],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = new TestModel().testReadIncludedWithMeta<MockChunk, { relevance: number }>(
|
|
95
|
+
data,
|
|
96
|
+
"topCitation",
|
|
97
|
+
chunkModule,
|
|
98
|
+
) as MockChunk;
|
|
99
|
+
|
|
100
|
+
expect(result).toBeInstanceOf(MockChunk);
|
|
101
|
+
expect(result.relevance).toBe(0.7);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/core/index.ts
CHANGED
|
@@ -129,6 +129,8 @@ export * from "../features/rbac/rbac.module";
|
|
|
129
129
|
export * from "../features/referral/data";
|
|
130
130
|
export * from "../features/referral/referral.module";
|
|
131
131
|
export * from "../features/referral/referral-stats.module";
|
|
132
|
+
export * from "../features/referral/referral-admin.modules";
|
|
133
|
+
export * from "../features/referral/interfaces/referral-admin.interface";
|
|
132
134
|
// Administrative token-usage reporting modules. Exported from core (not only
|
|
133
135
|
// from the client-marked ./tokenusage barrel) so a consuming app can register
|
|
134
136
|
// them in its bootstrapper without pulling the feature's client bundle —
|
|
@@ -62,6 +62,11 @@ export interface FoundationModuleDefinitions {
|
|
|
62
62
|
// Referral modules
|
|
63
63
|
Referral: ModuleWithPermissions;
|
|
64
64
|
ReferralStats: ModuleWithPermissions;
|
|
65
|
+
// Referral link visits and the administrative referral report (opt-in: registered by apps that spread referralAdminModules)
|
|
66
|
+
ReferralVisit: ModuleWithPermissions;
|
|
67
|
+
ReferralAdminSummary: ModuleWithPermissions;
|
|
68
|
+
ReferralAdminReferrer: ModuleWithPermissions;
|
|
69
|
+
ReferralAdminReferral: ModuleWithPermissions;
|
|
65
70
|
// Two-factor authentication modules
|
|
66
71
|
TotpAuthenticator: ModuleWithPermissions;
|
|
67
72
|
TotpSetup: ModuleWithPermissions;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
3
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
|
|
5
|
+
import { ReferralAdminReferral } from "../data/ReferralAdminReferral";
|
|
6
|
+
import { ReferralAdminReferrer } from "../data/ReferralAdminReferrer";
|
|
7
|
+
import { ReferralAdminSummary } from "../data/ReferralAdminSummary";
|
|
8
|
+
import { referralAdminModules } from "../referral-admin.modules";
|
|
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 the analytics models spec).
|
|
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
|
+
});
|
|
26
|
+
|
|
27
|
+
const hydrate = (id: string, attributes: Record<string, unknown>) =>
|
|
28
|
+
({ jsonApi: { id, type: "t", attributes }, included: [] }) as any;
|
|
29
|
+
|
|
30
|
+
describe("referral admin models — rehydrate", () => {
|
|
31
|
+
it("rehydrates every summary attribute", () => {
|
|
32
|
+
const m = new ReferralAdminSummary().rehydrate(
|
|
33
|
+
hydrate("current", {
|
|
34
|
+
window: "current",
|
|
35
|
+
visits: 12,
|
|
36
|
+
invitesSent: 8,
|
|
37
|
+
signups: 3,
|
|
38
|
+
paying: 2,
|
|
39
|
+
creditsGiven: 800,
|
|
40
|
+
sharingCompanies: 4,
|
|
41
|
+
eligibleCompanies: 40,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
expect(m.id).toBe("current");
|
|
45
|
+
expect(m.window).toBe("current");
|
|
46
|
+
expect(m.visits).toBe(12);
|
|
47
|
+
expect(m.invitesSent).toBe(8);
|
|
48
|
+
expect(m.signups).toBe(3);
|
|
49
|
+
expect(m.paying).toBe(2);
|
|
50
|
+
expect(m.creditsGiven).toBe(800);
|
|
51
|
+
expect(m.sharingCompanies).toBe(4);
|
|
52
|
+
expect(m.eligibleCompanies).toBe(40);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rehydrates every referrer attribute and parses lastInviteAt into a Date", () => {
|
|
56
|
+
const m = new ReferralAdminReferrer().rehydrate(
|
|
57
|
+
hydrate("c1", {
|
|
58
|
+
name: "Luca",
|
|
59
|
+
email: "luca@x.test",
|
|
60
|
+
visits: 5,
|
|
61
|
+
invitesSent: 4,
|
|
62
|
+
signupsLink: 2,
|
|
63
|
+
signupsDirect: 1,
|
|
64
|
+
paying: 1,
|
|
65
|
+
creditsEarned: 200,
|
|
66
|
+
lastInviteAt: "2026-10-27T10:00:00.000Z",
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
expect(m.id).toBe("c1");
|
|
70
|
+
expect(m.name).toBe("Luca");
|
|
71
|
+
expect(m.email).toBe("luca@x.test");
|
|
72
|
+
expect(m.visits).toBe(5);
|
|
73
|
+
expect(m.invitesSent).toBe(4);
|
|
74
|
+
expect(m.signupsLink).toBe(2);
|
|
75
|
+
expect(m.signupsDirect).toBe(1);
|
|
76
|
+
expect(m.paying).toBe(1);
|
|
77
|
+
expect(m.creditsEarned).toBe(200);
|
|
78
|
+
expect(m.lastInviteAt).toBeInstanceOf(Date);
|
|
79
|
+
expect(m.lastInviteAt?.toISOString()).toBe("2026-10-27T10:00:00.000Z");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("leaves a missing lastInviteAt undefined", () => {
|
|
83
|
+
const m = new ReferralAdminReferrer().rehydrate(hydrate("c2", { name: "Sara", email: "sara@x.test" }));
|
|
84
|
+
expect(m.lastInviteAt).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("rehydrates every referral attribute and parses the datetimes into Dates", () => {
|
|
88
|
+
const m = new ReferralAdminReferral().rehydrate(
|
|
89
|
+
hydrate("r1", {
|
|
90
|
+
name: "Marta",
|
|
91
|
+
email: "marta@x.test",
|
|
92
|
+
referrerName: "Luca",
|
|
93
|
+
referrerEmail: "luca@x.test",
|
|
94
|
+
source: "link",
|
|
95
|
+
status: "completed",
|
|
96
|
+
referredAt: "2026-10-20T08:00:00.000Z",
|
|
97
|
+
completedAt: "2026-10-27T08:00:00.000Z",
|
|
98
|
+
tokensAwarded: 200,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
expect(m.id).toBe("r1");
|
|
102
|
+
expect(m.name).toBe("Marta");
|
|
103
|
+
expect(m.email).toBe("marta@x.test");
|
|
104
|
+
expect(m.referrerName).toBe("Luca");
|
|
105
|
+
expect(m.referrerEmail).toBe("luca@x.test");
|
|
106
|
+
expect(m.source).toBe("link");
|
|
107
|
+
expect(m.status).toBe("completed");
|
|
108
|
+
expect(m.referredAt).toBeInstanceOf(Date);
|
|
109
|
+
expect(m.referredAt.toISOString()).toBe("2026-10-20T08:00:00.000Z");
|
|
110
|
+
expect(m.completedAt).toBeInstanceOf(Date);
|
|
111
|
+
expect(m.completedAt?.toISOString()).toBe("2026-10-27T08:00:00.000Z");
|
|
112
|
+
expect(m.tokensAwarded).toBe(200);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("leaves a missing completedAt and tokensAwarded undefined", () => {
|
|
116
|
+
const m = new ReferralAdminReferral().rehydrate(
|
|
117
|
+
hydrate("r2", { name: "Tom", status: "pending", source: "direct", referredAt: "2026-10-24T08:00:00.000Z" }),
|
|
118
|
+
);
|
|
119
|
+
expect(m.completedAt).toBeUndefined();
|
|
120
|
+
expect(m.tokensAwarded).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
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 { ReferralAdminService } from "../data/ReferralAdminService";
|
|
7
|
+
import { referralAdminModules } from "../referral-admin.modules";
|
|
8
|
+
|
|
9
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
10
|
+
({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
|
|
11
|
+
|
|
12
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
13
|
+
try {
|
|
14
|
+
ModuleRegistry.get(key as any);
|
|
15
|
+
} catch {
|
|
16
|
+
ModuleRegistry.register(key, module);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
for (const [key, module] of Object.entries(referralAdminModules(moduleFactory))) registerIfAbsent(key, module);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type CallApiParams = { type: ApiRequestDataTypeInterface; method: string; endpoint: string };
|
|
25
|
+
|
|
26
|
+
// `callApi` is a protected static on AbstractService; spying on the base class
|
|
27
|
+
// intercepts every call without touching the HTTP layer.
|
|
28
|
+
const callApi = () => vi.spyOn(AbstractService as any, "callApi").mockResolvedValue([]);
|
|
29
|
+
const firstCall = (spy: ReturnType<typeof callApi>): CallApiParams => spy.mock.calls[0][0] as CallApiParams;
|
|
30
|
+
|
|
31
|
+
describe("ReferralAdminService", () => {
|
|
32
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
33
|
+
|
|
34
|
+
it("getSummary sends the period to the summary endpoint", async () => {
|
|
35
|
+
const spy = callApi();
|
|
36
|
+
|
|
37
|
+
await ReferralAdminService.getSummary({ period: "30d" });
|
|
38
|
+
|
|
39
|
+
const call = firstCall(spy);
|
|
40
|
+
expect(call.type).toBe(ModuleRegistry.get("ReferralAdminSummary" as any));
|
|
41
|
+
expect(call.method).toBe("GET");
|
|
42
|
+
expect(call.endpoint).toContain("referrals/administration/summary");
|
|
43
|
+
expect(call.endpoint).toContain("period=30d");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("getReferrers sends the period and the sort", async () => {
|
|
47
|
+
const spy = callApi();
|
|
48
|
+
|
|
49
|
+
await ReferralAdminService.getReferrers({ period: "90d", sort: "invites" });
|
|
50
|
+
|
|
51
|
+
const call = firstCall(spy);
|
|
52
|
+
expect(call.type).toBe(ModuleRegistry.get("ReferralAdminReferrer" as any));
|
|
53
|
+
expect(call.method).toBe("GET");
|
|
54
|
+
expect(call.endpoint).toContain("referrals/administration/referrers");
|
|
55
|
+
expect(call.endpoint).toContain("period=90d");
|
|
56
|
+
expect(call.endpoint).toContain("sort=invites");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("getReferrals sends the period", async () => {
|
|
60
|
+
const spy = callApi();
|
|
61
|
+
|
|
62
|
+
await ReferralAdminService.getReferrals({ period: "all" });
|
|
63
|
+
|
|
64
|
+
const call = firstCall(spy);
|
|
65
|
+
expect(call.type).toBe(ModuleRegistry.get("ReferralAdminReferral" as any));
|
|
66
|
+
expect(call.method).toBe("GET");
|
|
67
|
+
expect(call.endpoint).toContain("referrals/administration/referrals");
|
|
68
|
+
expect(call.endpoint).toContain("period=all");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { act, render } from "@testing-library/react";
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
4
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
5
|
+
|
|
6
|
+
const REF = "11111111-1111-4111-8111-111111111111";
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({
|
|
9
|
+
searchParams: new URLSearchParams(),
|
|
10
|
+
enabled: true,
|
|
11
|
+
existingCode: null as string | null,
|
|
12
|
+
setReferralCode: vi.fn(),
|
|
13
|
+
recordVisit: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("next/navigation", () => ({ useSearchParams: () => mocks.searchParams }));
|
|
17
|
+
vi.mock("../config", () => ({
|
|
18
|
+
isReferralEnabled: () => mocks.enabled,
|
|
19
|
+
getReferralConfig: () => ({ urlParamName: "ref" }),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("../utils/referral-cookie", () => ({
|
|
22
|
+
getReferralCode: () => mocks.existingCode,
|
|
23
|
+
setReferralCode: mocks.setReferralCode,
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("../data/ReferralService", () => ({ ReferralService: { recordVisit: mocks.recordVisit } }));
|
|
26
|
+
|
|
27
|
+
import { ReferralCodeCapture } from "../components/ReferralCodeCapture";
|
|
28
|
+
import { ReferralVisit } from "../data/ReferralVisit";
|
|
29
|
+
|
|
30
|
+
// ReferralVisit.createJsonApi() resolves Modules.ReferralVisit through the real ModuleRegistry.
|
|
31
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
32
|
+
try {
|
|
33
|
+
ModuleRegistry.get(key as any);
|
|
34
|
+
} catch {
|
|
35
|
+
ModuleRegistry.register(key, module);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
registerIfAbsent("ReferralVisit", { name: "referrals/visits", model: ReferralVisit } as any);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("ReferralCodeCapture", () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
mocks.searchParams = new URLSearchParams({ ref: REF });
|
|
47
|
+
mocks.enabled = true;
|
|
48
|
+
mocks.existingCode = null;
|
|
49
|
+
mocks.recordVisit.mockResolvedValue(undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("records a visit when it sets the cookie", () => {
|
|
53
|
+
render(<ReferralCodeCapture />);
|
|
54
|
+
|
|
55
|
+
expect(mocks.setReferralCode).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(mocks.setReferralCode).toHaveBeenCalledWith(REF);
|
|
57
|
+
expect(mocks.recordVisit).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(mocks.recordVisit).toHaveBeenCalledWith({ referralCode: REF });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not record a visit when a cookie already exists", () => {
|
|
62
|
+
mocks.existingCode = "22222222-2222-4222-8222-222222222222";
|
|
63
|
+
|
|
64
|
+
render(<ReferralCodeCapture />);
|
|
65
|
+
|
|
66
|
+
expect(mocks.setReferralCode).not.toHaveBeenCalled();
|
|
67
|
+
expect(mocks.recordVisit).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does nothing when the feature is disabled", () => {
|
|
71
|
+
mocks.enabled = false;
|
|
72
|
+
|
|
73
|
+
render(<ReferralCodeCapture />);
|
|
74
|
+
|
|
75
|
+
expect(mocks.setReferralCode).not.toHaveBeenCalled();
|
|
76
|
+
expect(mocks.recordVisit).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("a failed visit call is swallowed", async () => {
|
|
80
|
+
const unhandled = vi.fn();
|
|
81
|
+
process.on("unhandledRejection", unhandled);
|
|
82
|
+
mocks.recordVisit.mockRejectedValue(new Error("network down"));
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
render(<ReferralCodeCapture />);
|
|
86
|
+
await act(async () => {
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(mocks.setReferralCode).toHaveBeenCalledWith(REF);
|
|
91
|
+
expect(mocks.recordVisit).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(unhandled).not.toHaveBeenCalled();
|
|
93
|
+
} finally {
|
|
94
|
+
process.off("unhandledRejection", unhandled);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("ReferralVisit.createJsonApi()", () => {
|
|
100
|
+
it("builds the visit body with no id", () => {
|
|
101
|
+
expect(new ReferralVisit().createJsonApi({ referralCode: "x" })).toEqual({
|
|
102
|
+
data: { type: "referrals/visits", attributes: { referralCode: "x" } },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
sendReferralEmail: vi.fn(),
|
|
6
|
+
sendInvite: vi.fn(),
|
|
7
|
+
showError: vi.fn(),
|
|
8
|
+
showToast: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Echoes the key, so an assertion names the message the user would read.
|
|
12
|
+
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
|
13
|
+
vi.mock("../../../utils", async () => ({
|
|
14
|
+
...(await vi.importActual<object>("../../../utils")),
|
|
15
|
+
showError: mocks.showError,
|
|
16
|
+
showToast: mocks.showToast,
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("../data/ReferralService", () => ({ ReferralService: { sendReferralEmail: mocks.sendReferralEmail } }));
|
|
19
|
+
vi.mock("../hooks/useReferralStats", () => ({
|
|
20
|
+
useReferralStats: () => ({ stats: { referralCode: "abc", completedReferrals: 0 }, loading: false, error: null }),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("../hooks/useReferralInvite", async () => ({
|
|
23
|
+
...(await vi.importActual<object>("../hooks/useReferralInvite")),
|
|
24
|
+
useReferralInvite: () => ({ sendInvite: mocks.sendInvite, sending: false }),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { ReferralWidget } from "../components/ReferralWidget";
|
|
28
|
+
import { configureReferral } from "../config";
|
|
29
|
+
|
|
30
|
+
/** The error callApi throws when suppressGlobalError is set: the JSON:API body rides on `body`. */
|
|
31
|
+
const apiError = (code?: string) =>
|
|
32
|
+
Object.assign(new Error("400:English text from the API"), {
|
|
33
|
+
status: 400,
|
|
34
|
+
body: { errors: [{ status: "400", ...(code ? { code } : {}), detail: "English text from the API" }] },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const sendTo = (email: string) => {
|
|
38
|
+
fireEvent.change(screen.getByPlaceholderText("referral.emailPlaceholder"), { target: { value: email } });
|
|
39
|
+
fireEvent.click(screen.getByRole("button", { name: "referral.send" }));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe("referral invite errors", () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
configureReferral({ enabled: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it.each([
|
|
49
|
+
["REFERRAL_ALREADY_INVITED", "referral.alreadyInvited"],
|
|
50
|
+
["REFERRAL_ALREADY_REGISTERED", "referral.alreadyRegistered"],
|
|
51
|
+
[undefined, "referral.inviteFailed"],
|
|
52
|
+
])("code %s shows %s, never the API text", async (code, message) => {
|
|
53
|
+
mocks.sendInvite.mockRejectedValue(apiError(code));
|
|
54
|
+
render(<ReferralWidget isDialog />);
|
|
55
|
+
|
|
56
|
+
await act(async () => sendTo("gm@example.com"));
|
|
57
|
+
|
|
58
|
+
expect(mocks.showError).toHaveBeenCalledWith(message);
|
|
59
|
+
expect(mocks.showToast).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a sent invite shows the translated confirmation", async () => {
|
|
63
|
+
mocks.sendInvite.mockResolvedValue(undefined);
|
|
64
|
+
render(<ReferralWidget isDialog />);
|
|
65
|
+
|
|
66
|
+
await act(async () => sendTo("gm@example.com"));
|
|
67
|
+
|
|
68
|
+
expect(mocks.showToast).toHaveBeenCalledWith("referral.inviteSent");
|
|
69
|
+
expect(mocks.showError).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("useReferralInvite rethrows a refused invite", async () => {
|
|
73
|
+
const { useReferralInvite } =
|
|
74
|
+
await vi.importActual<typeof import("../hooks/useReferralInvite")>("../hooks/useReferralInvite");
|
|
75
|
+
mocks.sendReferralEmail.mockRejectedValue(apiError("REFERRAL_ALREADY_INVITED"));
|
|
76
|
+
const { result } = renderHook(() => useReferralInvite());
|
|
77
|
+
|
|
78
|
+
await act(async () => {
|
|
79
|
+
await expect(result.current.sendInvite("gm@example.com")).rejects.toMatchObject({ status: 400 });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|