@carlonicora/nextjs-jsonapi 6.8.0 → 6.10.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-D3LDTJCP.mjs → BlockNoteEditor-GK3DRTOP.mjs} +51 -17
- package/dist/BlockNoteEditor-GK3DRTOP.mjs.map +1 -0
- package/dist/{BlockNoteEditor-FB6FQFRF.js → BlockNoteEditor-YOKJOVV3.js} +74 -40
- package/dist/BlockNoteEditor-YOKJOVV3.js.map +1 -0
- package/dist/{BlockNoteViewer-LGFMGTYX.js → BlockNoteViewer-EKAWVLIR.js} +3 -3
- package/dist/{BlockNoteViewer-LGFMGTYX.js.map → BlockNoteViewer-EKAWVLIR.js.map} +1 -1
- package/dist/{BlockNoteViewer-YSPYUDZE.mjs → BlockNoteViewer-RWRI3QO2.mjs} +2 -2
- 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/billing/index.js +492 -492
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-D44UDP4G.mjs → chunk-3TNGKMJU.mjs} +2 -2
- package/dist/{chunk-UT4GVYJE.js → chunk-4UFMLTL2.js} +1387 -955
- package/dist/chunk-4UFMLTL2.js.map +1 -0
- package/dist/{chunk-VT5HRZH7.mjs → chunk-6HFRXEF5.mjs} +326 -3
- package/dist/chunk-6HFRXEF5.mjs.map +1 -0
- package/dist/{chunk-5HBFQEYU.mjs → chunk-AWLTSKMM.mjs} +2 -2
- package/dist/{chunk-TL5O7EGW.mjs → chunk-DPAM7BCE.mjs} +2 -2
- package/dist/{chunk-PSLT5JLE.js → chunk-G3YKDIAL.js} +3 -3
- package/dist/{chunk-PSLT5JLE.js.map → chunk-G3YKDIAL.js.map} +1 -1
- package/dist/{chunk-ZXK2OMCC.js → chunk-KRQRJA4J.js} +8 -8
- package/dist/{chunk-ZXK2OMCC.js.map → chunk-KRQRJA4J.js.map} +1 -1
- package/dist/{chunk-JHT32ODN.js → chunk-RNZ3IK5J.js} +4 -4
- package/dist/{chunk-JHT32ODN.js.map → chunk-RNZ3IK5J.js.map} +1 -1
- package/dist/{chunk-UZP7KRAV.js → chunk-SQTXJPRE.js} +500 -177
- package/dist/chunk-SQTXJPRE.js.map +1 -0
- package/dist/{chunk-5HTX4RDA.mjs → chunk-THZAK7S3.mjs} +1743 -1311
- package/dist/chunk-THZAK7S3.mjs.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +69 -8
- package/dist/components/index.d.ts +69 -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.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +134 -3
- package/dist/core/index.d.ts +134 -3
- 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.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.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.js +127 -127
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +25 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +24 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/messages/en.json +62 -0
- package/messages/it.json +62 -0
- package/package.json +1 -1
- package/src/components/editors/BlockNoteEditor.tsx +69 -14
- package/src/components/editors/__tests__/BlockNoteEditor.ai.spec.tsx +295 -0
- 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/assistant/components/containers/AssistantPageContainer.tsx +20 -9
- package/src/features/assistant/components/containers/__tests__/AssistantPageContainer.spec.tsx +116 -0
- package/src/features/assistant/components/parts/AssistantThread.tsx +2 -1
- package/src/features/assistant-message/components/MessageItem.tsx +1 -1
- package/src/features/assistant-message/components/MessageList.tsx +1 -1
- package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +1 -1
- package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +1 -1
- package/src/features/assistant-message/components/parts/__tests__/MessageSourcesPanel.spec.tsx +17 -1
- package/src/features/assistant-message/components/parts/__tests__/SuggestedQuestionsTab.spec.tsx +22 -0
- package/src/features/assistant-message/components/parts/tabs/SuggestedQuestionsTab.tsx +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/BlockNoteEditor-D3LDTJCP.mjs.map +0 -1
- package/dist/BlockNoteEditor-FB6FQFRF.js.map +0 -1
- package/dist/chunk-5HTX4RDA.mjs.map +0 -1
- package/dist/chunk-UT4GVYJE.js.map +0 -1
- package/dist/chunk-UZP7KRAV.js.map +0 -1
- package/dist/chunk-VT5HRZH7.mjs.map +0 -1
- /package/dist/{BlockNoteViewer-YSPYUDZE.mjs.map → BlockNoteViewer-RWRI3QO2.mjs.map} +0 -0
- /package/dist/{chunk-D44UDP4G.mjs.map → chunk-3TNGKMJU.mjs.map} +0 -0
- /package/dist/{chunk-5HBFQEYU.mjs.map → chunk-AWLTSKMM.mjs.map} +0 -0
- /package/dist/{chunk-TL5O7EGW.mjs.map → chunk-DPAM7BCE.mjs.map} +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SendIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode } from "react";
|
|
6
|
+
import { ContentListTable } from "../../../../components";
|
|
7
|
+
import { Modules } from "../../../../core";
|
|
8
|
+
import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
|
|
9
|
+
import { EmptyState } from "../../../../shadcnui";
|
|
10
|
+
import { EmailBlastFields } from "../../data/email-blast.fields";
|
|
11
|
+
import { EmailBlastInterface } from "../../data/EmailBlastInterface";
|
|
12
|
+
import { EmailBlastService } from "../../data/EmailBlastService";
|
|
13
|
+
// Registers the "email-blasts" table generator. The blast components ship in the
|
|
14
|
+
// shared ./components entry, which does not re-export feature hooks (HowToList pattern).
|
|
15
|
+
import "../../hooks/useEmailBlastTableStructure";
|
|
16
|
+
import EmailBlastEditor from "../forms/EmailBlastEditor";
|
|
17
|
+
|
|
18
|
+
type EmailBlastsListProps = {
|
|
19
|
+
/**
|
|
20
|
+
* Pass this whenever the list is the page's own content inside a
|
|
21
|
+
* `RoundPageContainer fullWidth`. Without it ContentListTable wraps itself in
|
|
22
|
+
* `rounded-md border`, drawing a second bordered card inside the page's
|
|
23
|
+
* rounded shell.
|
|
24
|
+
*/
|
|
25
|
+
fullWidth?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function EmailBlastsList({ fullWidth }: EmailBlastsListProps = {}) {
|
|
29
|
+
const t = useTranslations();
|
|
30
|
+
|
|
31
|
+
const data: DataListRetriever<EmailBlastInterface> = useDataListRetriever({
|
|
32
|
+
retriever: (params) => EmailBlastService.findMany(params),
|
|
33
|
+
retrieverParams: {},
|
|
34
|
+
module: Modules.EmailBlast,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const functions: ReactNode[] = [<EmailBlastEditor key="create-email-blast" />];
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<ContentListTable
|
|
41
|
+
data={data}
|
|
42
|
+
fields={[
|
|
43
|
+
EmailBlastFields.subject,
|
|
44
|
+
EmailBlastFields.status,
|
|
45
|
+
EmailBlastFields.sentAt,
|
|
46
|
+
EmailBlastFields.sent,
|
|
47
|
+
EmailBlastFields.failed,
|
|
48
|
+
EmailBlastFields.unsubscribed,
|
|
49
|
+
]}
|
|
50
|
+
tableGeneratorType={Modules.EmailBlast}
|
|
51
|
+
functions={functions}
|
|
52
|
+
fullWidth={fullWidth}
|
|
53
|
+
title={t("emailBlasts.admin.title")}
|
|
54
|
+
emptyState={<EmptyState icon={SendIcon} title={t("emailBlasts.admin.empty")} />}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
2
|
+
import { EmailBlastInput, EmailBlastInterface, EmailBlastStatus } from "./EmailBlastInterface";
|
|
3
|
+
|
|
4
|
+
export class EmailBlast extends AbstractApiData implements EmailBlastInterface {
|
|
5
|
+
private _subject?: string;
|
|
6
|
+
private _body?: string;
|
|
7
|
+
private _replyTo?: string;
|
|
8
|
+
private _status: EmailBlastStatus = "draft";
|
|
9
|
+
private _sentAt?: Date;
|
|
10
|
+
private _recipientCount?: number;
|
|
11
|
+
private _sentCount?: number;
|
|
12
|
+
private _failedCount?: number;
|
|
13
|
+
private _unsubscribedCount?: number;
|
|
14
|
+
|
|
15
|
+
get subject(): string {
|
|
16
|
+
return this._subject ?? "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get body(): string {
|
|
20
|
+
return this._body ?? "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get replyTo(): string | undefined {
|
|
24
|
+
return this._replyTo;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get status(): EmailBlastStatus {
|
|
28
|
+
return this._status;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get sentAt(): Date | undefined {
|
|
32
|
+
return this._sentAt;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get recipientCount(): number | undefined {
|
|
36
|
+
return this._recipientCount;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get sentCount(): number {
|
|
40
|
+
return this._sentCount ?? 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get failedCount(): number {
|
|
44
|
+
return this._failedCount ?? 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get unsubscribedCount(): number {
|
|
48
|
+
return this._unsubscribedCount ?? 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
52
|
+
super.rehydrate(data);
|
|
53
|
+
|
|
54
|
+
const attributes = data.jsonApi.attributes;
|
|
55
|
+
this._subject = attributes.subject;
|
|
56
|
+
this._body = attributes.body;
|
|
57
|
+
this._replyTo = attributes.replyTo ?? undefined;
|
|
58
|
+
this._status = attributes.status ?? "draft";
|
|
59
|
+
this._sentAt = attributes.sentAt ? new Date(attributes.sentAt) : undefined;
|
|
60
|
+
this._recipientCount = attributes.recipientCount ?? undefined;
|
|
61
|
+
this._sentCount = attributes.sentCount;
|
|
62
|
+
this._failedCount = attributes.failedCount;
|
|
63
|
+
this._unsubscribedCount = attributes.unsubscribedCount;
|
|
64
|
+
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Only the editable text travels: status, counters and sentAt are set by the API.
|
|
70
|
+
*/
|
|
71
|
+
createJsonApi(data: EmailBlastInput) {
|
|
72
|
+
const response: any = {
|
|
73
|
+
data: {
|
|
74
|
+
type: Modules.EmailBlast.name,
|
|
75
|
+
id: data.id,
|
|
76
|
+
attributes: {},
|
|
77
|
+
meta: {},
|
|
78
|
+
relationships: {},
|
|
79
|
+
},
|
|
80
|
+
included: [],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (data.subject !== undefined) response.data.attributes.subject = data.subject;
|
|
84
|
+
if (data.body !== undefined) response.data.attributes.body = data.body;
|
|
85
|
+
if (data.replyTo !== undefined) response.data.attributes.replyTo = data.replyTo;
|
|
86
|
+
|
|
87
|
+
return response;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface } from "../../../core";
|
|
2
|
+
import { EmailBlastAudienceInterface } from "./EmailBlastAudienceInterface";
|
|
3
|
+
|
|
4
|
+
export class EmailBlastAudience extends AbstractApiData implements EmailBlastAudienceInterface {
|
|
5
|
+
private _recipientCount?: number;
|
|
6
|
+
|
|
7
|
+
get recipientCount(): number {
|
|
8
|
+
return this._recipientCount ?? 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
12
|
+
super.rehydrate(data);
|
|
13
|
+
|
|
14
|
+
this._recipientCount = data.jsonApi.attributes.recipientCount;
|
|
15
|
+
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
createJsonApi(_data?: any): any {
|
|
20
|
+
throw new Error("EmailBlastAudience is computed by the API and is read-only");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface } from "../../../core";
|
|
2
|
+
import { EmailBlastDeliveryInterface, EmailBlastDeliveryStatus } from "./EmailBlastDeliveryInterface";
|
|
3
|
+
|
|
4
|
+
/** JSON:API type of the POST email-blasts/unsubscribe/:token request body (not a module: nothing is stored). */
|
|
5
|
+
export const EMAIL_BLAST_UNSUBSCRIBE_TYPE = "email-blast-unsubscribes";
|
|
6
|
+
|
|
7
|
+
export class EmailBlastDelivery extends AbstractApiData implements EmailBlastDeliveryInterface {
|
|
8
|
+
private _status: EmailBlastDeliveryStatus = "pending";
|
|
9
|
+
|
|
10
|
+
get status(): EmailBlastDeliveryStatus {
|
|
11
|
+
return this._status;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
15
|
+
super.rehydrate(data);
|
|
16
|
+
|
|
17
|
+
this._status = data.jsonApi.attributes.status ?? "pending";
|
|
18
|
+
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
createJsonApi(_data?: any): any {
|
|
23
|
+
throw new Error("EmailBlastDelivery is created by the API and cannot be created from the client");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Body of POST email-blasts/unsubscribe/:token.
|
|
28
|
+
*/
|
|
29
|
+
createUnsubscribeJsonApi() {
|
|
30
|
+
return {
|
|
31
|
+
data: {
|
|
32
|
+
type: EMAIL_BLAST_UNSUBSCRIBE_TYPE,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ApiDataInterface } from "../../../core";
|
|
2
|
+
|
|
3
|
+
export type EmailBlastDeliveryStatus = "pending" | "sending" | "sent" | "skipped" | "failed";
|
|
4
|
+
|
|
5
|
+
export interface EmailBlastDeliveryInterface extends ApiDataInterface {
|
|
6
|
+
get status(): EmailBlastDeliveryStatus;
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
|
|
2
|
+
import { EMAIL_BLAST_SEGMENTS } from "./email-blast.segments";
|
|
3
|
+
import { EmailBlastDelivery } from "./EmailBlastDelivery";
|
|
4
|
+
|
|
5
|
+
export class EmailBlastDeliveryService extends AbstractService {
|
|
6
|
+
/**
|
|
7
|
+
* POST email-blasts/unsubscribe/:token (public). Replies 204: the token holder
|
|
8
|
+
* receives nothing back.
|
|
9
|
+
*/
|
|
10
|
+
static async unsubscribe(params: { token: string }): Promise<void> {
|
|
11
|
+
const delivery = new EmailBlastDelivery();
|
|
12
|
+
|
|
13
|
+
await this.callApi({
|
|
14
|
+
type: Modules.EmailBlastDelivery,
|
|
15
|
+
method: HttpMethod.POST,
|
|
16
|
+
endpoint: new EndpointCreator({
|
|
17
|
+
endpoint: Modules.EmailBlast,
|
|
18
|
+
id: EMAIL_BLAST_SEGMENTS.unsubscribe,
|
|
19
|
+
childEndpoint: params.token,
|
|
20
|
+
}).generate(),
|
|
21
|
+
input: delivery.createUnsubscribeJsonApi(),
|
|
22
|
+
overridesJsonApiCreation: true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ApiDataInterface } from "../../../core";
|
|
2
|
+
|
|
3
|
+
export type EmailBlastStatus = "draft" | "sending" | "sent";
|
|
4
|
+
|
|
5
|
+
export type EmailBlastInput = {
|
|
6
|
+
id: string;
|
|
7
|
+
subject: string;
|
|
8
|
+
body: string;
|
|
9
|
+
replyTo?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface EmailBlastInterface extends ApiDataInterface {
|
|
13
|
+
get subject(): string;
|
|
14
|
+
get body(): string;
|
|
15
|
+
get replyTo(): string | undefined;
|
|
16
|
+
get status(): EmailBlastStatus;
|
|
17
|
+
get sentAt(): Date | undefined;
|
|
18
|
+
get recipientCount(): number | undefined;
|
|
19
|
+
get sentCount(): number;
|
|
20
|
+
get failedCount(): number;
|
|
21
|
+
get unsubscribedCount(): number;
|
|
22
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef, PreviousRef } from "../../../core";
|
|
2
|
+
import { EMAIL_BLAST_SEGMENTS } from "./email-blast.segments";
|
|
3
|
+
import { EmailBlastAudienceInterface } from "./EmailBlastAudienceInterface";
|
|
4
|
+
import { EmailBlastInput, EmailBlastInterface } from "./EmailBlastInterface";
|
|
5
|
+
|
|
6
|
+
export class EmailBlastService extends AbstractService {
|
|
7
|
+
static async findMany(params: { next?: NextRef; previous?: PreviousRef } = {}): Promise<EmailBlastInterface[]> {
|
|
8
|
+
const endpoint = new EndpointCreator({ endpoint: Modules.EmailBlast });
|
|
9
|
+
|
|
10
|
+
if (Modules.EmailBlast.inclusions?.lists?.fields)
|
|
11
|
+
endpoint.limitToFields(Modules.EmailBlast.inclusions.lists.fields);
|
|
12
|
+
if (Modules.EmailBlast.inclusions?.lists?.types) endpoint.limitToType(Modules.EmailBlast.inclusions.lists.types);
|
|
13
|
+
|
|
14
|
+
return this.callApi<EmailBlastInterface[]>({
|
|
15
|
+
type: Modules.EmailBlast,
|
|
16
|
+
method: HttpMethod.GET,
|
|
17
|
+
endpoint: endpoint.generate(),
|
|
18
|
+
next: params.next,
|
|
19
|
+
previous: params.previous,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static async findOne(params: { id: string }): Promise<EmailBlastInterface> {
|
|
24
|
+
return this.callApi<EmailBlastInterface>({
|
|
25
|
+
type: Modules.EmailBlast,
|
|
26
|
+
method: HttpMethod.GET,
|
|
27
|
+
endpoint: new EndpointCreator({ endpoint: Modules.EmailBlast, id: params.id }).generate(),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static async create(params: EmailBlastInput): Promise<EmailBlastInterface> {
|
|
32
|
+
return this.callApi<EmailBlastInterface>({
|
|
33
|
+
type: Modules.EmailBlast,
|
|
34
|
+
method: HttpMethod.POST,
|
|
35
|
+
endpoint: new EndpointCreator({ endpoint: Modules.EmailBlast }).generate(),
|
|
36
|
+
input: params,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static async update(params: EmailBlastInput): Promise<EmailBlastInterface> {
|
|
41
|
+
return this.callApi<EmailBlastInterface>({
|
|
42
|
+
type: Modules.EmailBlast,
|
|
43
|
+
method: HttpMethod.PATCH,
|
|
44
|
+
endpoint: new EndpointCreator({ endpoint: Modules.EmailBlast, id: params.id }).generate(),
|
|
45
|
+
input: params,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static async delete(params: { id: string }): Promise<void> {
|
|
50
|
+
await this.callApi({
|
|
51
|
+
type: Modules.EmailBlast,
|
|
52
|
+
method: HttpMethod.DELETE,
|
|
53
|
+
endpoint: new EndpointCreator({ endpoint: Modules.EmailBlast, id: params.id }).generate(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* GET email-blasts/:id/recipient-count: how many people the blast would reach now.
|
|
59
|
+
*/
|
|
60
|
+
static async findAudience(params: { id: string }): Promise<EmailBlastAudienceInterface> {
|
|
61
|
+
return this.callApi<EmailBlastAudienceInterface>({
|
|
62
|
+
type: Modules.EmailBlastAudience,
|
|
63
|
+
method: HttpMethod.GET,
|
|
64
|
+
endpoint: new EndpointCreator({
|
|
65
|
+
endpoint: Modules.EmailBlast,
|
|
66
|
+
id: params.id,
|
|
67
|
+
childEndpoint: EMAIL_BLAST_SEGMENTS.audience,
|
|
68
|
+
}).generate(),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* POST email-blasts/:id/test: sends the draft to the calling administrator. Replies 204.
|
|
74
|
+
*/
|
|
75
|
+
static async sendTest(params: { id: string }): Promise<void> {
|
|
76
|
+
await this.callApi({
|
|
77
|
+
type: Modules.EmailBlast,
|
|
78
|
+
method: HttpMethod.POST,
|
|
79
|
+
endpoint: new EndpointCreator({
|
|
80
|
+
endpoint: Modules.EmailBlast,
|
|
81
|
+
id: params.id,
|
|
82
|
+
childEndpoint: EMAIL_BLAST_SEGMENTS.test,
|
|
83
|
+
}).generate(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* POST email-blasts/:id/send: starts the send and returns the blast, now `sending`.
|
|
89
|
+
*/
|
|
90
|
+
static async send(params: { id: string }): Promise<EmailBlastInterface> {
|
|
91
|
+
return this.callApi<EmailBlastInterface>({
|
|
92
|
+
type: Modules.EmailBlast,
|
|
93
|
+
method: HttpMethod.POST,
|
|
94
|
+
endpoint: new EndpointCreator({
|
|
95
|
+
endpoint: Modules.EmailBlast,
|
|
96
|
+
id: params.id,
|
|
97
|
+
childEndpoint: EMAIL_BLAST_SEGMENTS.send,
|
|
98
|
+
}).generate(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
3
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../../permissions/types";
|
|
4
|
+
import { EmailBlastAudienceModule } from "../../EmailBlastAudienceModule";
|
|
5
|
+
import { EmailBlastDeliveryModule } from "../../EmailBlastDeliveryModule";
|
|
6
|
+
import { EmailBlastModule } from "../../EmailBlastModule";
|
|
7
|
+
import { EmailBlast } from "../EmailBlast";
|
|
8
|
+
import { EmailBlastAudience } from "../EmailBlastAudience";
|
|
9
|
+
|
|
10
|
+
// createJsonApi reads Modules.X.name (resolved lazily via ModuleRegistry), so
|
|
11
|
+
// the modules must be registered before a model serialises — same setup as
|
|
12
|
+
// email-automation-models.spec.
|
|
13
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
14
|
+
({
|
|
15
|
+
pageUrl: params.pageUrl,
|
|
16
|
+
name: params.name,
|
|
17
|
+
model: params.model,
|
|
18
|
+
}) as ModuleWithPermissions;
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
ModuleRegistry.register("EmailBlast", EmailBlastModule(moduleFactory));
|
|
22
|
+
ModuleRegistry.register("EmailBlastDelivery", EmailBlastDeliveryModule(moduleFactory));
|
|
23
|
+
ModuleRegistry.register("EmailBlastAudience", EmailBlastAudienceModule(moduleFactory));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const hydrate = (attributes: Record<string, unknown>) =>
|
|
27
|
+
({ jsonApi: { id: "x", type: "t", attributes }, included: [] }) as any;
|
|
28
|
+
|
|
29
|
+
describe("email blast models", () => {
|
|
30
|
+
it("rehydrate parses sentAt to a Date and defaults counters to 0", () => {
|
|
31
|
+
const sent = new EmailBlast().rehydrate(
|
|
32
|
+
hydrate({
|
|
33
|
+
subject: "News",
|
|
34
|
+
body: "Hello",
|
|
35
|
+
status: "sent",
|
|
36
|
+
sentAt: "2026-10-27T08:00:00.000Z",
|
|
37
|
+
recipientCount: 12,
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
expect(sent.sentAt).toBeInstanceOf(Date);
|
|
41
|
+
expect(sent.sentAt?.toISOString()).toBe("2026-10-27T08:00:00.000Z");
|
|
42
|
+
expect(sent.recipientCount).toBe(12);
|
|
43
|
+
expect(sent.status).toBe("sent");
|
|
44
|
+
expect(sent.sentCount).toBe(0);
|
|
45
|
+
expect(sent.failedCount).toBe(0);
|
|
46
|
+
expect(sent.unsubscribedCount).toBe(0);
|
|
47
|
+
|
|
48
|
+
const draft = new EmailBlast().rehydrate(hydrate({ subject: "News", body: "Hello" }));
|
|
49
|
+
expect(draft.sentAt).toBeUndefined();
|
|
50
|
+
expect(draft.recipientCount).toBeUndefined();
|
|
51
|
+
expect(draft.replyTo).toBeUndefined();
|
|
52
|
+
expect(draft.status).toBe("draft");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("createJsonApi emits only subject, body and replyTo", () => {
|
|
56
|
+
const body = new EmailBlast().createJsonApi({
|
|
57
|
+
id: "b1",
|
|
58
|
+
subject: "News",
|
|
59
|
+
body: "Hello",
|
|
60
|
+
replyTo: "gm@example.com",
|
|
61
|
+
...({ status: "sent", sentCount: 3, failedCount: 1, unsubscribedCount: 1, sentAt: new Date() } as object),
|
|
62
|
+
});
|
|
63
|
+
expect(body.data.type).toBe("email-blasts");
|
|
64
|
+
expect(body.data.id).toBe("b1");
|
|
65
|
+
expect(body.data.attributes).toEqual({ subject: "News", body: "Hello", replyTo: "gm@example.com" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("EmailBlastAudience rehydrates recipientCount", () => {
|
|
69
|
+
const audience = new EmailBlastAudience().rehydrate(hydrate({ recipientCount: 42 }));
|
|
70
|
+
expect(audience.recipientCount).toBe(42);
|
|
71
|
+
expect(new EmailBlastAudience().rehydrate(hydrate({})).recipientCount).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL segments of the email-blast child routes. The only place these strings
|
|
3
|
+
* are written: services build every child URL from here through EndpointCreator.
|
|
4
|
+
*/
|
|
5
|
+
export const EMAIL_BLAST_SEGMENTS = {
|
|
6
|
+
audience: "recipient-count",
|
|
7
|
+
test: "test",
|
|
8
|
+
send: "send",
|
|
9
|
+
unsubscribe: "unsubscribe",
|
|
10
|
+
} as const;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./email-blast.segments";
|
|
2
|
+
export * from "./EmailBlastInterface";
|
|
3
|
+
export * from "./EmailBlast";
|
|
4
|
+
export * from "./EmailBlastService";
|
|
5
|
+
export * from "./EmailBlastDeliveryInterface";
|
|
6
|
+
export * from "./EmailBlastDelivery";
|
|
7
|
+
export * from "./EmailBlastDeliveryService";
|
|
8
|
+
export * from "./EmailBlastAudienceInterface";
|
|
9
|
+
export * from "./EmailBlastAudience";
|
|
10
|
+
export * from "./email-blast.fields";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ColumnDef } from "@tanstack/react-table";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { Badge, Link } from "../../../shadcnui";
|
|
7
|
+
import { Modules } from "../../../core";
|
|
8
|
+
import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
|
|
9
|
+
import { useI18nLocale } from "../../../i18n";
|
|
10
|
+
import { EmailBlastFields } from "../data/email-blast.fields";
|
|
11
|
+
import { EmailBlastInterface, EmailBlastStatus } from "../data/EmailBlastInterface";
|
|
12
|
+
|
|
13
|
+
const STATUS_VARIANTS: Record<EmailBlastStatus, "softGray" | "softAmber" | "softGreen"> = {
|
|
14
|
+
draft: "softGray",
|
|
15
|
+
sending: "softAmber",
|
|
16
|
+
sent: "softGreen",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const useEmailBlastTableStructure: UseTableStructureHook<EmailBlastInterface, EmailBlastFields> = (params) => {
|
|
20
|
+
const t = useTranslations();
|
|
21
|
+
const locale = useI18nLocale();
|
|
22
|
+
const generateUrl = usePageUrlGenerator();
|
|
23
|
+
|
|
24
|
+
const tableData = useMemo(() => {
|
|
25
|
+
return params.data.map((blast: EmailBlastInterface) => {
|
|
26
|
+
const entry: TableContent<EmailBlastInterface> = { jsonApiData: blast };
|
|
27
|
+
params.fields.forEach((field) => {
|
|
28
|
+
entry[field] = (blast as any)[field as keyof EmailBlastInterface];
|
|
29
|
+
});
|
|
30
|
+
return entry;
|
|
31
|
+
});
|
|
32
|
+
}, [params.data, params.fields]);
|
|
33
|
+
|
|
34
|
+
// Typography role 15 (numeric): tabular-nums, end-aligned. NEVER
|
|
35
|
+
// font-mono — no monospace font is loaded in these apps.
|
|
36
|
+
const numericColumn = (field: EmailBlastFields, header: string, read: (b: EmailBlastInterface) => number) => ({
|
|
37
|
+
id: field,
|
|
38
|
+
accessorKey: field,
|
|
39
|
+
header: header,
|
|
40
|
+
cell: ({ row }: { row: TableContent<EmailBlastInterface> }) => {
|
|
41
|
+
const blast: EmailBlastInterface = row.original.jsonApiData;
|
|
42
|
+
return <span className="block text-xs tabular-nums text-end">{read(blast) ?? 0}</span>;
|
|
43
|
+
},
|
|
44
|
+
enableSorting: false,
|
|
45
|
+
enableHiding: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const fieldColumnMap: Partial<Record<EmailBlastFields, () => any>> = {
|
|
49
|
+
[EmailBlastFields.subject]: () => ({
|
|
50
|
+
id: "subject",
|
|
51
|
+
accessorKey: "subject",
|
|
52
|
+
header: t("emailBlasts.list.columns.subject"),
|
|
53
|
+
cell: ({ row }: { row: TableContent<EmailBlastInterface> }) => {
|
|
54
|
+
const blast: EmailBlastInterface = row.original.jsonApiData;
|
|
55
|
+
return <Link href={generateUrl({ page: Modules.EmailBlast, id: blast.id })}>{blast.subject}</Link>;
|
|
56
|
+
},
|
|
57
|
+
enableSorting: false,
|
|
58
|
+
enableHiding: false,
|
|
59
|
+
}),
|
|
60
|
+
[EmailBlastFields.status]: () => ({
|
|
61
|
+
id: "status",
|
|
62
|
+
accessorKey: "status",
|
|
63
|
+
header: t("emailBlasts.list.columns.status"),
|
|
64
|
+
cell: ({ row }: { row: TableContent<EmailBlastInterface> }) => {
|
|
65
|
+
const blast: EmailBlastInterface = row.original.jsonApiData;
|
|
66
|
+
return <Badge variant={STATUS_VARIANTS[blast.status]}>{t(`emailBlasts.list.status.${blast.status}`)}</Badge>;
|
|
67
|
+
},
|
|
68
|
+
enableSorting: false,
|
|
69
|
+
enableHiding: false,
|
|
70
|
+
}),
|
|
71
|
+
[EmailBlastFields.sentAt]: () => ({
|
|
72
|
+
id: "sentAt",
|
|
73
|
+
accessorKey: "sentAt",
|
|
74
|
+
header: t("emailBlasts.list.columns.sentAt"),
|
|
75
|
+
cell: ({ row }: { row: TableContent<EmailBlastInterface> }) => {
|
|
76
|
+
const blast: EmailBlastInterface = row.original.jsonApiData;
|
|
77
|
+
if (!blast.sentAt) return <span className="text-muted-foreground text-xs">—</span>;
|
|
78
|
+
return <span className="text-sm">{blast.sentAt.toLocaleDateString(locale)}</span>;
|
|
79
|
+
},
|
|
80
|
+
enableSorting: false,
|
|
81
|
+
enableHiding: false,
|
|
82
|
+
}),
|
|
83
|
+
[EmailBlastFields.sent]: () =>
|
|
84
|
+
numericColumn(EmailBlastFields.sent, t("emailBlasts.list.columns.sent"), (b) => b.sentCount),
|
|
85
|
+
[EmailBlastFields.failed]: () =>
|
|
86
|
+
numericColumn(EmailBlastFields.failed, t("emailBlasts.list.columns.failed"), (b) => b.failedCount),
|
|
87
|
+
[EmailBlastFields.unsubscribed]: () =>
|
|
88
|
+
numericColumn(
|
|
89
|
+
EmailBlastFields.unsubscribed,
|
|
90
|
+
t("emailBlasts.list.columns.unsubscribed"),
|
|
91
|
+
(b) => b.unsubscribedCount,
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const columns = useMemo(() => {
|
|
96
|
+
return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
|
|
97
|
+
TableContent<EmailBlastInterface>
|
|
98
|
+
>[];
|
|
99
|
+
}, [params.fields, fieldColumnMap, t, generateUrl, locale]);
|
|
100
|
+
|
|
101
|
+
return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
registerTableGenerator("email-blasts", useEmailBlastTableStructure);
|
package/src/features/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./billing/stripe-usage";
|
|
|
12
12
|
export * from "./billing/stripe-promotion-code";
|
|
13
13
|
export * from "./company";
|
|
14
14
|
export * from "./content";
|
|
15
|
+
export * from "./email-blast";
|
|
15
16
|
export * from "./feature";
|
|
16
17
|
export * from "./module";
|
|
17
18
|
export * from "./notification";
|