@carlonicora/nextjs-jsonapi 6.7.0 → 6.9.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/{AssistantMessageInterface-0ZQ1EJKO.d.mts → AssistantMessageInterface-BE3QxI54.d.mts} +1 -1
- package/dist/{AssistantMessageInterface-CA-hZ5Wj.d.ts → AssistantMessageInterface-nXpD_d-m.d.ts} +1 -1
- package/dist/{BlockNoteEditor-HWJBQAKG.mjs → BlockNoteEditor-WN73IHXR.mjs} +4 -4
- package/dist/{BlockNoteEditor-BSFR2GPI.js → BlockNoteEditor-XXH3VBZ7.js} +19 -19
- package/dist/{BlockNoteEditor-BSFR2GPI.js.map → BlockNoteEditor-XXH3VBZ7.js.map} +1 -1
- package/dist/{BlockNoteViewer-NHS6CCMS.js → BlockNoteViewer-EKAWVLIR.js} +3 -3
- package/dist/{BlockNoteViewer-NHS6CCMS.js.map → BlockNoteViewer-EKAWVLIR.js.map} +1 -1
- package/dist/{BlockNoteViewer-IIBUU7J7.mjs → BlockNoteViewer-RWRI3QO2.mjs} +2 -2
- package/dist/{BlogPostInterface-CTGfsR1I.d.ts → BlogPostInterface-2sVqvXFd.d.ts} +1 -1
- package/dist/{BlogPostInterface-BN2mXc46.d.mts → BlogPostInterface-BgJLFpaa.d.mts} +1 -1
- package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-BPgqqwgw.d.mts} +20 -1
- package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-_51oA3om.d.ts} +20 -1
- package/dist/{auth.interface-DuX87BZ3.d.mts → auth.interface-BAYLbYjs.d.mts} +1 -1
- package/dist/{auth.interface-B4tRE0k0.d.ts → auth.interface-BjYwwfy9.d.ts} +1 -1
- package/dist/billing/index.js +492 -492
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-EC4PQ44E.mjs → chunk-3TNGKMJU.mjs} +2 -2
- package/dist/{chunk-QNIWATQD.mjs → chunk-6HFRXEF5.mjs} +331 -3
- package/dist/chunk-6HFRXEF5.mjs.map +1 -0
- package/dist/{chunk-OYRJ32KR.mjs → chunk-AWLTSKMM.mjs} +2 -2
- package/dist/{chunk-XYKDBOCE.mjs → chunk-DPAM7BCE.mjs} +2 -2
- package/dist/{chunk-PYDDFFQM.js → chunk-G3YKDIAL.js} +3 -3
- package/dist/{chunk-PYDDFFQM.js.map → chunk-G3YKDIAL.js.map} +1 -1
- package/dist/{chunk-3HOJNAZB.js → chunk-KRQRJA4J.js} +8 -8
- package/dist/{chunk-3HOJNAZB.js.map → chunk-KRQRJA4J.js.map} +1 -1
- package/dist/{chunk-S4NKWMX5.mjs → chunk-Q4XLE3QO.mjs} +1738 -1308
- package/dist/chunk-Q4XLE3QO.mjs.map +1 -0
- package/dist/{chunk-73XWQA6U.js → chunk-RNZ3IK5J.js} +4 -4
- package/dist/{chunk-73XWQA6U.js.map → chunk-RNZ3IK5J.js.map} +1 -1
- package/dist/{chunk-6KE7UBYY.js → chunk-SQTXJPRE.js} +505 -177
- package/dist/chunk-SQTXJPRE.js.map +1 -0
- package/dist/{chunk-PCCUPEBU.js → chunk-ZCM4JLM7.js} +1381 -951
- package/dist/chunk-ZCM4JLM7.js.map +1 -0
- package/dist/client/index.d.mts +3 -3
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +62 -8
- package/dist/components/index.d.ts +62 -8
- package/dist/components/index.js +14 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +13 -3
- package/dist/contexts/index.d.mts +4 -4
- package/dist/contexts/index.d.ts +4 -4
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +146 -13
- package/dist/core/index.d.ts +146 -13
- package/dist/core/index.js +24 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +23 -1
- package/dist/email-automations/index.d.mts +6 -1
- package/dist/email-automations/index.d.ts +6 -1
- package/dist/email-automations/index.js +101 -94
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +12 -5
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.d.mts +1 -1
- package/dist/features/analytics/index.d.ts +1 -1
- package/dist/features/analytics/index.js +121 -121
- 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/blog/index.d.mts +3 -3
- package/dist/features/blog/index.d.ts +3 -3
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.d.mts +3 -3
- package/dist/features/blog/server.d.ts +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.js +127 -127
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +25 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +24 -2
- package/dist/{notification.interface-DQReXAD7.d.ts → notification.interface-DQItyY_Q.d.ts} +1 -1
- package/dist/{notification.interface-CF5h90U2.d.mts → notification.interface-DoVS5RZB.d.mts} +1 -1
- package/dist/{s3.service-CRGJbO-6.d.mts → s3.service-BG136Btz.d.mts} +3 -3
- package/dist/{s3.service-DZG-VyLY.d.ts → s3.service-CsrsRPRn.d.ts} +3 -3
- package/dist/server/index.d.mts +4 -4
- package/dist/server/index.d.ts +4 -4
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/{useSocket-CwSoq6VS.d.mts → useSocket-C8fqU4od.d.mts} +1 -1
- package/dist/{useSocket-BM3q9A0C.d.ts → useSocket-IRHtk2xc.d.ts} +1 -1
- package/dist/{user.interface-BYnGJ7ss.d.mts → user.interface-Bm-0TbsB.d.mts} +6 -0
- package/dist/{user.interface-BqPjVyJI.d.ts → user.interface-CKBIEvNu.d.ts} +6 -0
- package/messages/en.json +62 -0
- package/messages/it.json +62 -0
- package/package.json +1 -1
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +3 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/company/data/company.interface.ts +7 -0
- package/src/features/company/data/company.spec.ts +23 -0
- package/src/features/company/data/company.ts +6 -0
- package/src/features/company/hooks/useSubscriptionStatus.spec.ts +59 -0
- package/src/features/company/hooks/useSubscriptionStatus.ts +5 -3
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +14 -2
- package/src/features/email-blast/EmailBlastAudienceModule.ts +12 -0
- package/src/features/email-blast/EmailBlastDeliveryModule.ts +12 -0
- package/src/features/email-blast/EmailBlastModule.ts +28 -0
- package/src/features/email-blast/components/containers/EmailBlastContainer.tsx +145 -0
- package/src/features/email-blast/components/containers/EmailBlastsListContainer.tsx +36 -0
- package/src/features/email-blast/components/forms/EmailBlastEditor.tsx +132 -0
- package/src/features/email-blast/components/forms/EmailBlastSendDialog.tsx +104 -0
- package/src/features/email-blast/components/index.ts +5 -0
- package/src/features/email-blast/components/lists/EmailBlastsList.tsx +57 -0
- package/src/features/email-blast/data/EmailBlast.ts +89 -0
- package/src/features/email-blast/data/EmailBlastAudience.ts +22 -0
- package/src/features/email-blast/data/EmailBlastAudienceInterface.ts +5 -0
- package/src/features/email-blast/data/EmailBlastDelivery.ts +36 -0
- package/src/features/email-blast/data/EmailBlastDeliveryInterface.ts +7 -0
- package/src/features/email-blast/data/EmailBlastDeliveryService.ts +25 -0
- package/src/features/email-blast/data/EmailBlastInterface.ts +22 -0
- package/src/features/email-blast/data/EmailBlastService.ts +101 -0
- package/src/features/email-blast/data/__tests__/EmailBlast.spec.ts +73 -0
- package/src/features/email-blast/data/email-blast.fields.ts +8 -0
- package/src/features/email-blast/data/email-blast.segments.ts +10 -0
- package/src/features/email-blast/data/index.ts +10 -0
- package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +104 -0
- package/src/features/email-blast/index.ts +4 -0
- package/src/features/index.ts +1 -0
- package/dist/chunk-6KE7UBYY.js.map +0 -1
- package/dist/chunk-PCCUPEBU.js.map +0 -1
- package/dist/chunk-QNIWATQD.mjs.map +0 -1
- package/dist/chunk-S4NKWMX5.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-HWJBQAKG.mjs.map → BlockNoteEditor-WN73IHXR.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-IIBUU7J7.mjs.map → BlockNoteViewer-RWRI3QO2.mjs.map} +0 -0
- /package/dist/{chunk-EC4PQ44E.mjs.map → chunk-3TNGKMJU.mjs.map} +0 -0
- /package/dist/{chunk-OYRJ32KR.mjs.map → chunk-AWLTSKMM.mjs.map} +0 -0
- /package/dist/{chunk-XYKDBOCE.mjs.map → chunk-DPAM7BCE.mjs.map} +0 -0
package/messages/en.json
CHANGED
|
@@ -1269,6 +1269,68 @@
|
|
|
1269
1269
|
"title": "You are unsubscribed"
|
|
1270
1270
|
}
|
|
1271
1271
|
},
|
|
1272
|
+
"emailBlasts": {
|
|
1273
|
+
"admin": {
|
|
1274
|
+
"title": "Email blasts",
|
|
1275
|
+
"empty": "No blasts yet"
|
|
1276
|
+
},
|
|
1277
|
+
"list": {
|
|
1278
|
+
"columns": {
|
|
1279
|
+
"subject": "Subject",
|
|
1280
|
+
"status": "Status",
|
|
1281
|
+
"sentAt": "Sent",
|
|
1282
|
+
"sent": "Sent to",
|
|
1283
|
+
"failed": "Failed",
|
|
1284
|
+
"unsubscribed": "Unsubscribed"
|
|
1285
|
+
},
|
|
1286
|
+
"status": {
|
|
1287
|
+
"draft": "Draft",
|
|
1288
|
+
"sending": "Sending",
|
|
1289
|
+
"sent": "Sent"
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
"editor": {
|
|
1293
|
+
"title": "Email blast",
|
|
1294
|
+
"editTitle": "Edit email blast",
|
|
1295
|
+
"fields": {
|
|
1296
|
+
"subject": {
|
|
1297
|
+
"label": "Subject",
|
|
1298
|
+
"error": "Write a subject"
|
|
1299
|
+
},
|
|
1300
|
+
"body": {
|
|
1301
|
+
"label": "Email (Markdown)",
|
|
1302
|
+
"error": "Write the email"
|
|
1303
|
+
},
|
|
1304
|
+
"replyTo": {
|
|
1305
|
+
"label": "Reply-to address",
|
|
1306
|
+
"error": "Enter a valid email address"
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
"variablesHint": "You can use:",
|
|
1310
|
+
"unsubscribeHint": "Every email ends with a link to stop all marketing emails."
|
|
1311
|
+
},
|
|
1312
|
+
"detail": {
|
|
1313
|
+
"recipients": "Recipients",
|
|
1314
|
+
"sentTo": "Sent to",
|
|
1315
|
+
"failed": "Failed",
|
|
1316
|
+
"unsubscribed": "Unsubscribed",
|
|
1317
|
+
"sentAt": "Sent",
|
|
1318
|
+
"replyTo": "Reply-to",
|
|
1319
|
+
"body": "Email"
|
|
1320
|
+
},
|
|
1321
|
+
"test": {
|
|
1322
|
+
"button": "Send test to me",
|
|
1323
|
+
"sent": "Test sent to your email"
|
|
1324
|
+
},
|
|
1325
|
+
"send": {
|
|
1326
|
+
"button": "Send",
|
|
1327
|
+
"title": "Send this email blast?",
|
|
1328
|
+
"confirm": "This goes to {count, plural, one {# person} other {# people}}. Send?",
|
|
1329
|
+
"nobody": "Nobody can receive this blast right now.",
|
|
1330
|
+
"action": "Send",
|
|
1331
|
+
"cancel": "Cancel"
|
|
1332
|
+
}
|
|
1333
|
+
},
|
|
1272
1334
|
"entities": {
|
|
1273
1335
|
"assistants": "{count, plural, =1 {Assistant} other {Assistants}}",
|
|
1274
1336
|
"companies": "{count, plural, =1 {Company} other {Companies}}",
|
package/messages/it.json
CHANGED
|
@@ -1269,6 +1269,68 @@
|
|
|
1269
1269
|
"title": "Disiscrizione completata"
|
|
1270
1270
|
}
|
|
1271
1271
|
},
|
|
1272
|
+
"emailBlasts": {
|
|
1273
|
+
"admin": {
|
|
1274
|
+
"title": "Email massive",
|
|
1275
|
+
"empty": "Nessuna email massiva"
|
|
1276
|
+
},
|
|
1277
|
+
"list": {
|
|
1278
|
+
"columns": {
|
|
1279
|
+
"subject": "Oggetto",
|
|
1280
|
+
"status": "Stato",
|
|
1281
|
+
"sentAt": "Inviata",
|
|
1282
|
+
"sent": "Inviata a",
|
|
1283
|
+
"failed": "Non riuscite",
|
|
1284
|
+
"unsubscribed": "Disiscritti"
|
|
1285
|
+
},
|
|
1286
|
+
"status": {
|
|
1287
|
+
"draft": "Bozza",
|
|
1288
|
+
"sending": "In invio",
|
|
1289
|
+
"sent": "Inviata"
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
"editor": {
|
|
1293
|
+
"title": "Email massiva",
|
|
1294
|
+
"editTitle": "Modifica email massiva",
|
|
1295
|
+
"fields": {
|
|
1296
|
+
"subject": {
|
|
1297
|
+
"label": "Oggetto",
|
|
1298
|
+
"error": "Scrivi un oggetto"
|
|
1299
|
+
},
|
|
1300
|
+
"body": {
|
|
1301
|
+
"label": "Email (Markdown)",
|
|
1302
|
+
"error": "Scrivi l'email"
|
|
1303
|
+
},
|
|
1304
|
+
"replyTo": {
|
|
1305
|
+
"label": "Indirizzo di risposta",
|
|
1306
|
+
"error": "Inserisci un indirizzo email valido"
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
"variablesHint": "Puoi usare:",
|
|
1310
|
+
"unsubscribeHint": "Ogni email finisce con un link per non ricevere più email di marketing."
|
|
1311
|
+
},
|
|
1312
|
+
"detail": {
|
|
1313
|
+
"recipients": "Destinatari",
|
|
1314
|
+
"sentTo": "Inviata a",
|
|
1315
|
+
"failed": "Non riuscite",
|
|
1316
|
+
"unsubscribed": "Disiscritti",
|
|
1317
|
+
"sentAt": "Inviata",
|
|
1318
|
+
"replyTo": "Risposta a",
|
|
1319
|
+
"body": "Email"
|
|
1320
|
+
},
|
|
1321
|
+
"test": {
|
|
1322
|
+
"button": "Inviami una prova",
|
|
1323
|
+
"sent": "Prova inviata alla tua email"
|
|
1324
|
+
},
|
|
1325
|
+
"send": {
|
|
1326
|
+
"button": "Invia",
|
|
1327
|
+
"title": "Inviare questa email massiva?",
|
|
1328
|
+
"confirm": "Va a {count, plural, one {# persona} other {# persone}}. Inviare?",
|
|
1329
|
+
"nobody": "Nessuno può ricevere questa email adesso.",
|
|
1330
|
+
"action": "Invia",
|
|
1331
|
+
"cancel": "Annulla"
|
|
1332
|
+
}
|
|
1333
|
+
},
|
|
1272
1334
|
"entities": {
|
|
1273
1335
|
"assistants": "{count, plural, =1 {Assistente} other {Assistenti}}",
|
|
1274
1336
|
"companies": "{count, plural, =1 {Azienda} other {Aziende}}",
|
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type { CreditWarningBannerProps } from "../features/billing/components/wi
|
|
|
32
32
|
export { isCreditWarningActive, CREDIT_WARNING_DISMISS_SEGMENT } from "../features/billing/data/credit-warning";
|
|
33
33
|
export * from "../features/company/components";
|
|
34
34
|
export * from "../features/content/components";
|
|
35
|
+
export * from "../features/email-blast/components";
|
|
35
36
|
export * from "../features/how-to/components";
|
|
36
37
|
export * from "../features/handbook/components";
|
|
37
38
|
export * from "../features/assistant/components";
|
package/src/core/index.ts
CHANGED
|
@@ -85,6 +85,9 @@ export * from "../features/email-automation/EmailAutomationTriggerModule";
|
|
|
85
85
|
// server page can wrap EmailAutomationService.findOne in cache(). The client-marked
|
|
86
86
|
// ./email-automations entry carries only components, contexts and hooks.
|
|
87
87
|
export * from "../features/email-automation/data";
|
|
88
|
+
// Email blast modules and data: server-safe, same placement as email automation above.
|
|
89
|
+
// The client components ship through the ./components entry.
|
|
90
|
+
export * from "../features/email-blast";
|
|
88
91
|
// Blog modules and data: server-safe, same placement as email automation above.
|
|
89
92
|
// The client-marked ./blog entry carries only components, contexts and hooks.
|
|
90
93
|
export * from "../features/blog/BlogModules";
|
|
@@ -49,6 +49,10 @@ export interface FoundationModuleDefinitions {
|
|
|
49
49
|
EmailAutomationStep: ModuleWithPermissions;
|
|
50
50
|
EmailAutomationEnrollment: ModuleWithPermissions;
|
|
51
51
|
EmailAutomationTrigger: ModuleWithPermissions;
|
|
52
|
+
// Email blast modules
|
|
53
|
+
EmailBlast: ModuleWithPermissions;
|
|
54
|
+
EmailBlastDelivery: ModuleWithPermissions;
|
|
55
|
+
EmailBlastAudience: ModuleWithPermissions;
|
|
52
56
|
// Blog modules
|
|
53
57
|
BlogPost: ModuleWithPermissions;
|
|
54
58
|
BlogPostTranslation: ModuleWithPermissions;
|
|
@@ -36,6 +36,13 @@ export interface CompanyInterface extends ApiDataInterface {
|
|
|
36
36
|
|
|
37
37
|
get isActiveSubscription(): boolean;
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* When the company's trial ends, written by the server only (never sent by
|
|
41
|
+
* `createJsonApi()`). Absent for companies whose trial end was never stored;
|
|
42
|
+
* callers then derive it from `createdAt`.
|
|
43
|
+
*/
|
|
44
|
+
get trialEndsAt(): Date | undefined;
|
|
45
|
+
|
|
39
46
|
get monthlyCredits(): number;
|
|
40
47
|
get availableMonthlyCredits(): number;
|
|
41
48
|
get availableExtraCredits(): number;
|
|
@@ -67,3 +67,26 @@ describe("Company credit-warning fields", () => {
|
|
|
67
67
|
expect(company.creditWarningOffer).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("Company.trialEndsAt", () => {
|
|
72
|
+
it("rehydrates trialEndsAt as a Date", () => {
|
|
73
|
+
const company = new Company().rehydrate({
|
|
74
|
+
jsonApi: { attributes: { trialEndsAt: "2026-11-09T10:00:00.000Z" } },
|
|
75
|
+
} as any);
|
|
76
|
+
|
|
77
|
+
expect(company.trialEndsAt).toBeInstanceOf(Date);
|
|
78
|
+
expect(company.trialEndsAt?.toISOString()).toBe("2026-11-09T10:00:00.000Z");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is undefined when the attribute is absent", () => {
|
|
82
|
+
const company = new Company().rehydrate({ jsonApi: { attributes: {} } } as any);
|
|
83
|
+
|
|
84
|
+
expect(company.trialEndsAt).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is never sent by createJsonApi", () => {
|
|
88
|
+
const payload = new Company().createJsonApi({ id: "company_1", name: "Acme" });
|
|
89
|
+
|
|
90
|
+
expect(payload.data.attributes).not.toHaveProperty("trialEndsAt");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -11,6 +11,7 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
11
11
|
private _configurations?: any;
|
|
12
12
|
|
|
13
13
|
private _isActiveSubscription: boolean = false;
|
|
14
|
+
private _trialEndsAt?: Date;
|
|
14
15
|
|
|
15
16
|
private _monthlyCredits: number = 0;
|
|
16
17
|
private _availableMonthlyCredits: number = 0;
|
|
@@ -53,6 +54,10 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
53
54
|
return this._isActiveSubscription ?? false;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
get trialEndsAt(): Date | undefined {
|
|
58
|
+
return this._trialEndsAt;
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
get monthlyCredits(): number {
|
|
57
62
|
return this._monthlyCredits ?? 0;
|
|
58
63
|
}
|
|
@@ -147,6 +152,7 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
147
152
|
this._logo = data.jsonApi.attributes.logo;
|
|
148
153
|
this._logoUrl = data.jsonApi.attributes.logoUrl;
|
|
149
154
|
this._isActiveSubscription = data.jsonApi.attributes.isActiveSubscription ?? false;
|
|
155
|
+
this._trialEndsAt = data.jsonApi.attributes.trialEndsAt ? new Date(data.jsonApi.attributes.trialEndsAt) : undefined;
|
|
150
156
|
this._monthlyCredits = data.jsonApi.attributes.monthlyCredits ?? 0;
|
|
151
157
|
this._availableMonthlyCredits = data.jsonApi.attributes.availableMonthlyCredits ?? 0;
|
|
152
158
|
this._availableExtraCredits = data.jsonApi.attributes.availableExtraCredits ?? 0;
|
|
@@ -193,4 +193,63 @@ describe("useSubscriptionStatus", () => {
|
|
|
193
193
|
expect(after.result.current.isBlocked).toBe(true);
|
|
194
194
|
expect(after.result.current.status).toBe("expired");
|
|
195
195
|
});
|
|
196
|
+
describe("stored trialEndsAt", () => {
|
|
197
|
+
const NOW = new Date("2026-10-26T10:00:00.000Z");
|
|
198
|
+
|
|
199
|
+
beforeEach(() => {
|
|
200
|
+
vi.setSystemTime(NOW);
|
|
201
|
+
userMock.currentUser = { roles: [] };
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("not active, trialEndsAt in the future → trial, not blocked", () => {
|
|
205
|
+
const trialEndsAt = new Date("2026-11-05T10:00:00.000Z");
|
|
206
|
+
userMock.company = { isActiveSubscription: false, createdAt: daysAgo(60, NOW), trialEndsAt };
|
|
207
|
+
|
|
208
|
+
const { result } = renderHook(() => useSubscriptionStatus());
|
|
209
|
+
|
|
210
|
+
expect(result.current.status).toBe("trial");
|
|
211
|
+
expect(result.current.daysRemaining).toBe(10);
|
|
212
|
+
expect(result.current.isBlocked).toBe(false);
|
|
213
|
+
expect(result.current.trialEndsAt?.getTime()).toBe(trialEndsAt.getTime());
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("not active, trialEndsAt in the past → expired, blocked", () => {
|
|
217
|
+
userMock.company = {
|
|
218
|
+
isActiveSubscription: false,
|
|
219
|
+
createdAt: daysAgo(60, NOW),
|
|
220
|
+
trialEndsAt: new Date("2026-10-20T10:00:00.000Z"),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const { result } = renderHook(() => useSubscriptionStatus());
|
|
224
|
+
|
|
225
|
+
expect(result.current.status).toBe("expired");
|
|
226
|
+
expect(result.current.isBlocked).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("not active, no trialEndsAt → createdAt + 14 days (unchanged)", () => {
|
|
230
|
+
const createdAt = daysAgo(4, NOW);
|
|
231
|
+
userMock.company = { isActiveSubscription: false, createdAt, trialEndsAt: undefined };
|
|
232
|
+
|
|
233
|
+
const { result } = renderHook(() => useSubscriptionStatus());
|
|
234
|
+
|
|
235
|
+
expect(result.current.status).toBe("trial");
|
|
236
|
+
expect(result.current.daysRemaining).toBe(10);
|
|
237
|
+
expect(result.current.isBlocked).toBe(false);
|
|
238
|
+
expect(result.current.trialEndsAt?.getTime()).toBe(createdAt.getTime() + 14 * DAY_MS);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("active ignores trialEndsAt", () => {
|
|
242
|
+
userMock.company = {
|
|
243
|
+
isActiveSubscription: true,
|
|
244
|
+
createdAt: daysAgo(60, NOW),
|
|
245
|
+
trialEndsAt: new Date("2026-11-05T10:00:00.000Z"),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const { result } = renderHook(() => useSubscriptionStatus());
|
|
249
|
+
|
|
250
|
+
expect(result.current.status).toBe("active");
|
|
251
|
+
expect(result.current.trialEndsAt).toBeNull();
|
|
252
|
+
expect(result.current.isBlocked).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
196
255
|
});
|
|
@@ -86,8 +86,10 @@ export function useSubscriptionStatus(): TrialSubscriptionStatus {
|
|
|
86
86
|
|
|
87
87
|
// No active subscription — but that alone is NOT a block. The trial window
|
|
88
88
|
// has to have elapsed too, and this mirrors the server rule in
|
|
89
|
-
// apps/api
|
|
90
|
-
// step:
|
|
89
|
+
// apps/api/src/features/campaign/entities/campaign-lock.cypher.ts. Keep the
|
|
90
|
+
// two in step: the trial ends at the stored company.trialEndsAt when the
|
|
91
|
+
// server wrote one, otherwise at createdAt + TRIAL_DAYS. If either the
|
|
92
|
+
// fallback or TRIAL_DAYS changes here it must change there.
|
|
91
93
|
//
|
|
92
94
|
// Blocking on the flag alone is wrong in practice. isActiveSubscription is
|
|
93
95
|
// only written true once a Stripe subscription actually reaches
|
|
@@ -100,7 +102,7 @@ export function useSubscriptionStatus(): TrialSubscriptionStatus {
|
|
|
100
102
|
// viewer's local clock: across a DST change the window grows or shrinks by
|
|
101
103
|
// an hour, and dividing that by a fixed 24h day then shows one day too many.
|
|
102
104
|
const createdAt = new Date(company.createdAt);
|
|
103
|
-
const trialEndsAt = new Date(createdAt.getTime() + TRIAL_DAYS * DAY_MS);
|
|
105
|
+
const trialEndsAt = company.trialEndsAt ?? new Date(createdAt.getTime() + TRIAL_DAYS * DAY_MS);
|
|
104
106
|
|
|
105
107
|
const now = new Date();
|
|
106
108
|
const msRemaining = trialEndsAt.getTime() - now.getTime();
|
|
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|
|
5
5
|
import { useEffect, useState } from "react";
|
|
6
6
|
import { Button } from "../../../../shadcnui";
|
|
7
7
|
import { SectionHeader } from "../../../../components/typography";
|
|
8
|
+
import { EmailBlastDeliveryService } from "../../../email-blast/data/EmailBlastDeliveryService";
|
|
8
9
|
import { EmailAutomationEnrollmentService } from "../../data/EmailAutomationEnrollmentService";
|
|
9
10
|
|
|
10
11
|
type UnsubscribeScope = "automation" | "all";
|
|
@@ -12,11 +13,16 @@ type UnsubscribeScope = "automation" | "all";
|
|
|
12
13
|
interface Props {
|
|
13
14
|
token: string;
|
|
14
15
|
scope: UnsubscribeScope;
|
|
16
|
+
/**
|
|
17
|
+
* Which email the link came from. A blast link always stops every marketing
|
|
18
|
+
* email (the shared opt-out), so it lands straight on the "stopped all" state.
|
|
19
|
+
*/
|
|
20
|
+
source?: "automation" | "blast";
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
type UnsubscribeState = "loading" | "success" | "error";
|
|
18
24
|
|
|
19
|
-
export function EmailUnsubscribe({ token, scope }: Props) {
|
|
25
|
+
export function EmailUnsubscribe({ token, scope, source = "automation" }: Props) {
|
|
20
26
|
const t = useTranslations();
|
|
21
27
|
const [state, setState] = useState<UnsubscribeState>("loading");
|
|
22
28
|
const [stoppedScope, setStoppedScope] = useState<UnsubscribeScope>(scope);
|
|
@@ -26,6 +32,12 @@ export function EmailUnsubscribe({ token, scope }: Props) {
|
|
|
26
32
|
useEffect(() => {
|
|
27
33
|
async function unsubscribe() {
|
|
28
34
|
try {
|
|
35
|
+
if (source === "blast") {
|
|
36
|
+
await EmailBlastDeliveryService.unsubscribe({ token });
|
|
37
|
+
setStoppedScope("all");
|
|
38
|
+
setState("success");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
29
41
|
const enrollment = await EmailAutomationEnrollmentService.unsubscribe({ token, scope });
|
|
30
42
|
setAutomationName(enrollment?.automation?.name ?? "");
|
|
31
43
|
setStoppedScope(scope);
|
|
@@ -36,7 +48,7 @@ export function EmailUnsubscribe({ token, scope }: Props) {
|
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
unsubscribe();
|
|
39
|
-
}, [token, scope]);
|
|
51
|
+
}, [token, scope, source]);
|
|
40
52
|
|
|
41
53
|
const stopAll = async () => {
|
|
42
54
|
setIsStoppingAll(true);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { UsersIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailBlastAudience } from "./data/EmailBlastAudience";
|
|
4
|
+
|
|
5
|
+
export const EmailBlastAudienceModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
moduleId: "e2b6d8f0-4c19-4a7e-8d35-6f9a0c2b7e14",
|
|
8
|
+
pageUrl: undefined,
|
|
9
|
+
name: "email-blast-audiences",
|
|
10
|
+
model: EmailBlastAudience,
|
|
11
|
+
icon: UsersIcon,
|
|
12
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { MailIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailBlastDelivery } from "./data/EmailBlastDelivery";
|
|
4
|
+
|
|
5
|
+
export const EmailBlastDeliveryModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
moduleId: "c47e9b1d-2a5f-4c83-9e60-1b7d4a3f8e25",
|
|
8
|
+
pageUrl: undefined,
|
|
9
|
+
name: "email-blast-deliveries",
|
|
10
|
+
model: EmailBlastDelivery,
|
|
11
|
+
icon: MailIcon,
|
|
12
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { SendIcon } from "lucide-react";
|
|
2
|
+
import { createJsonApiInclusion } from "../../core";
|
|
3
|
+
import { ModuleFactory } from "../../permissions";
|
|
4
|
+
import { EmailBlast } from "./data/EmailBlast";
|
|
5
|
+
|
|
6
|
+
export const EmailBlastModule = (factory: ModuleFactory) =>
|
|
7
|
+
factory({
|
|
8
|
+
moduleId: "8a1f4c2e-6b3d-4e9a-b7c5-2d0e9f1a6b48",
|
|
9
|
+
pageUrl: "/administration/email-blasts",
|
|
10
|
+
name: "email-blasts",
|
|
11
|
+
model: EmailBlast,
|
|
12
|
+
icon: SendIcon,
|
|
13
|
+
inclusions: {
|
|
14
|
+
lists: {
|
|
15
|
+
fields: [
|
|
16
|
+
createJsonApiInclusion("email-blasts", [
|
|
17
|
+
"subject",
|
|
18
|
+
"status",
|
|
19
|
+
"sentAt",
|
|
20
|
+
"recipientCount",
|
|
21
|
+
"sentCount",
|
|
22
|
+
"failedCount",
|
|
23
|
+
"unsubscribedCount",
|
|
24
|
+
]),
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { LoaderCircleIcon, MailCheckIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode, useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { CommonDeleter, RoundPageContainer } from "../../../../components";
|
|
7
|
+
import { errorToast } from "../../../../components/errors/errorToast";
|
|
8
|
+
import { DetailField } from "../../../../components/typography";
|
|
9
|
+
import { SharedProvider } from "../../../../contexts/SharedContext";
|
|
10
|
+
import { Modules } from "../../../../core";
|
|
11
|
+
import { usePageUrlGenerator } from "../../../../hooks";
|
|
12
|
+
import { useI18nLocale } from "../../../../i18n";
|
|
13
|
+
import { BreadcrumbItemData } from "../../../../interfaces";
|
|
14
|
+
import { Badge, Button } from "../../../../shadcnui";
|
|
15
|
+
import { showToast } from "../../../../utils/toast";
|
|
16
|
+
import { EmailBlastInterface, EmailBlastStatus } from "../../data/EmailBlastInterface";
|
|
17
|
+
import { EmailBlastService } from "../../data/EmailBlastService";
|
|
18
|
+
import { EmailBlastEditor } from "../forms/EmailBlastEditor";
|
|
19
|
+
import { EmailBlastSendDialog } from "../forms/EmailBlastSendDialog";
|
|
20
|
+
|
|
21
|
+
const STATUS_VARIANTS: Record<EmailBlastStatus, "softGray" | "softAmber" | "softGreen"> = {
|
|
22
|
+
draft: "softGray",
|
|
23
|
+
sending: "softAmber",
|
|
24
|
+
sent: "softGreen",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type EmailBlastContainerProps = {
|
|
28
|
+
blastId: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function EmailBlastSendTestButton({ blast }: { blast: EmailBlastInterface }) {
|
|
32
|
+
const t = useTranslations();
|
|
33
|
+
const [isSending, setIsSending] = useState<boolean>(false);
|
|
34
|
+
|
|
35
|
+
const handleClick = async () => {
|
|
36
|
+
setIsSending(true);
|
|
37
|
+
try {
|
|
38
|
+
await EmailBlastService.sendTest({ id: blast.id });
|
|
39
|
+
showToast(t("emailBlasts.test.sent"));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
42
|
+
} finally {
|
|
43
|
+
setIsSending(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Button size="sm" variant="outline" type="button" onClick={handleClick} disabled={isSending}>
|
|
49
|
+
{isSending ? <LoaderCircleIcon className="animate-spin-slow" /> : <MailCheckIcon />}
|
|
50
|
+
{t("emailBlasts.test.button")}
|
|
51
|
+
</Button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function EmailBlastContainer({ blastId }: EmailBlastContainerProps) {
|
|
56
|
+
const t = useTranslations();
|
|
57
|
+
const locale = useI18nLocale();
|
|
58
|
+
const generateUrl = usePageUrlGenerator();
|
|
59
|
+
const [blast, setBlast] = useState<EmailBlastInterface | undefined>(undefined);
|
|
60
|
+
|
|
61
|
+
const reload = useCallback(async () => {
|
|
62
|
+
setBlast(await EmailBlastService.findOne({ id: blastId }));
|
|
63
|
+
}, [blastId]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
void reload();
|
|
67
|
+
}, [reload]);
|
|
68
|
+
|
|
69
|
+
if (!blast) return null;
|
|
70
|
+
|
|
71
|
+
const listUrl = generateUrl({ page: Modules.EmailBlast });
|
|
72
|
+
|
|
73
|
+
const breadcrumbs: BreadcrumbItemData[] = [
|
|
74
|
+
{ name: t("emailBlasts.admin.title"), href: listUrl },
|
|
75
|
+
{ name: blast.subject },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// Only a draft can be edited, test-sent, sent or deleted (the API refuses the rest).
|
|
79
|
+
const functions: ReactNode[] =
|
|
80
|
+
blast.status === "draft"
|
|
81
|
+
? [
|
|
82
|
+
<EmailBlastEditor key="emailBlastEditor" blast={blast} onSuccess={reload} />,
|
|
83
|
+
<EmailBlastSendTestButton key="emailBlastSendTest" blast={blast} />,
|
|
84
|
+
<EmailBlastSendDialog key="emailBlastSendDialog" blast={blast} onSuccess={reload} />,
|
|
85
|
+
<CommonDeleter
|
|
86
|
+
key="emailBlastDeleter"
|
|
87
|
+
type="email-blasts"
|
|
88
|
+
deleteFunction={() => EmailBlastService.delete({ id: blast.id })}
|
|
89
|
+
redirectTo={listUrl}
|
|
90
|
+
/>,
|
|
91
|
+
]
|
|
92
|
+
: [];
|
|
93
|
+
|
|
94
|
+
// The page chrome (title + header functions) is published here rather than by
|
|
95
|
+
// AdministrationProvider: SharedProvider replaces its value for the subtree,
|
|
96
|
+
// so this one carries the administration title type plus the blast subject.
|
|
97
|
+
return (
|
|
98
|
+
<SharedProvider
|
|
99
|
+
value={{
|
|
100
|
+
breadcrumbs,
|
|
101
|
+
title: {
|
|
102
|
+
type: t("emailBlasts.admin.title"),
|
|
103
|
+
element: blast.subject,
|
|
104
|
+
functions,
|
|
105
|
+
},
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<RoundPageContainer module={Modules.EmailBlast} id={blast.id} fullWidth forceHeader>
|
|
109
|
+
<div className="flex w-full flex-col gap-y-6 p-4">
|
|
110
|
+
<div>
|
|
111
|
+
<Badge variant={STATUS_VARIANTS[blast.status]}>{t(`emailBlasts.list.status.${blast.status}`)}</Badge>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
114
|
+
<DetailField
|
|
115
|
+
label={t("emailBlasts.detail.recipients")}
|
|
116
|
+
value={<span className="tabular-nums">{blast.recipientCount ?? "—"}</span>}
|
|
117
|
+
/>
|
|
118
|
+
<DetailField
|
|
119
|
+
label={t("emailBlasts.detail.sentTo")}
|
|
120
|
+
value={<span className="tabular-nums">{blast.sentCount}</span>}
|
|
121
|
+
/>
|
|
122
|
+
<DetailField
|
|
123
|
+
label={t("emailBlasts.detail.failed")}
|
|
124
|
+
value={<span className="tabular-nums">{blast.failedCount}</span>}
|
|
125
|
+
/>
|
|
126
|
+
<DetailField
|
|
127
|
+
label={t("emailBlasts.detail.unsubscribed")}
|
|
128
|
+
value={<span className="tabular-nums">{blast.unsubscribedCount}</span>}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<DetailField
|
|
132
|
+
label={t("emailBlasts.detail.sentAt")}
|
|
133
|
+
value={blast.sentAt ? blast.sentAt.toLocaleString(locale) : "—"}
|
|
134
|
+
/>
|
|
135
|
+
<DetailField label={t("emailBlasts.list.columns.subject")} value={blast.subject} />
|
|
136
|
+
<DetailField label={t("emailBlasts.detail.replyTo")} value={blast.replyTo ?? "—"} />
|
|
137
|
+
<DetailField
|
|
138
|
+
label={t("emailBlasts.detail.body")}
|
|
139
|
+
value={<div className="whitespace-pre-wrap leading-relaxed">{blast.body}</div>}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
</RoundPageContainer>
|
|
143
|
+
</SharedProvider>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { RoundPageContainer } from "../../../../components";
|
|
5
|
+
import { SharedProvider } from "../../../../contexts/SharedContext";
|
|
6
|
+
import { Modules } from "../../../../core";
|
|
7
|
+
import { BreadcrumbItemData } from "../../../../interfaces";
|
|
8
|
+
import { EmailBlastsList } from "../lists/EmailBlastsList";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Page container for the administrative email-blast list.
|
|
12
|
+
*
|
|
13
|
+
* Client component by necessity: `module={Modules.EmailBlast}` is a
|
|
14
|
+
* registry entry carrying an icon component and methods, which a Server
|
|
15
|
+
* Component cannot pass across the boundary. Routes mount this container
|
|
16
|
+
* instead — the same shape as every other list page.
|
|
17
|
+
*
|
|
18
|
+
* No `forceHeader`: ContentListTable draws its own header row with the title
|
|
19
|
+
* and the create action, exactly as the company and user list pages do.
|
|
20
|
+
*
|
|
21
|
+
* Publishes its own breadcrumb, as CompanyProvider does for the companies list:
|
|
22
|
+
* the list root comes first and has no href because it is the current page.
|
|
23
|
+
*/
|
|
24
|
+
export function EmailBlastsListContainer() {
|
|
25
|
+
const t = useTranslations();
|
|
26
|
+
|
|
27
|
+
const breadcrumbs: BreadcrumbItemData[] = [{ name: t("emailBlasts.admin.title") }];
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<SharedProvider value={{ breadcrumbs, title: { type: t("emailBlasts.admin.title") } }}>
|
|
31
|
+
<RoundPageContainer module={Modules.EmailBlast} fullWidth>
|
|
32
|
+
<EmailBlastsList fullWidth />
|
|
33
|
+
</RoundPageContainer>
|
|
34
|
+
</SharedProvider>
|
|
35
|
+
);
|
|
36
|
+
}
|