@carlonicora/nextjs-jsonapi 6.15.0 → 6.17.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-UIAX72XW.js → BlockNoteEditor-FAUTV5FW.js} +20 -20
- package/dist/{BlockNoteEditor-UIAX72XW.js.map → BlockNoteEditor-FAUTV5FW.js.map} +1 -1
- package/dist/{BlockNoteEditor-I3J6YYXK.mjs → BlockNoteEditor-IGUHTERQ.mjs} +5 -5
- package/dist/{BlockNoteViewer-HIUAJQRI.mjs → BlockNoteViewer-X4UEVI2A.mjs} +3 -3
- package/dist/{BlockNoteViewer-APBOPT4B.js → BlockNoteViewer-XYKFY77F.js} +4 -4
- package/dist/{BlockNoteViewer-APBOPT4B.js.map → BlockNoteViewer-XYKFY77F.js.map} +1 -1
- package/dist/EmailPurpose-DOsSqM5K.d.mts +3 -0
- package/dist/EmailPurpose-DOsSqM5K.d.ts +3 -0
- package/dist/billing/index.js +492 -492
- package/dist/billing/index.mjs +4 -4
- package/dist/{chunk-RT7XFGIN.js → chunk-4OCBNCKH.js} +1041 -999
- package/dist/chunk-4OCBNCKH.js.map +1 -0
- package/dist/{chunk-4XLDBOSE.js → chunk-A7Y5YW7Q.js} +1 -1
- package/dist/{chunk-4XLDBOSE.js.map → chunk-A7Y5YW7Q.js.map} +1 -1
- package/dist/{chunk-ZYSNJKOP.js → chunk-DFA566KY.js} +4 -4
- package/dist/{chunk-ZYSNJKOP.js.map → chunk-DFA566KY.js.map} +1 -1
- package/dist/{chunk-DJXSCARG.js → chunk-I5BQLY7V.js} +9 -9
- package/dist/{chunk-DJXSCARG.js.map → chunk-I5BQLY7V.js.map} +1 -1
- package/dist/{chunk-RFKSJIQB.mjs → chunk-KMPBSOFB.mjs} +138 -5
- package/dist/chunk-KMPBSOFB.mjs.map +1 -0
- package/dist/{chunk-UOTHRJD2.mjs → chunk-OKBJEIOP.mjs} +1 -1
- package/dist/chunk-OKBJEIOP.mjs.map +1 -0
- package/dist/{chunk-PMESKNKL.js → chunk-OO2DBP6L.js} +1106 -973
- package/dist/chunk-OO2DBP6L.js.map +1 -0
- package/dist/{chunk-RYY7USSW.mjs → chunk-RINMQQZH.mjs} +56 -14
- package/dist/{chunk-RYY7USSW.mjs.map → chunk-RINMQQZH.mjs.map} +1 -1
- package/dist/{chunk-HAW5PNJG.mjs → chunk-TY5YWBLL.mjs} +3 -3
- package/dist/{chunk-OUEYZOLB.mjs → chunk-YVKWACDP.mjs} +2 -2
- package/dist/client/index.js +5 -5
- package/dist/client/index.mjs +4 -4
- package/dist/components/index.d.mts +10 -3
- package/dist/components/index.d.ts +10 -3
- package/dist/components/index.js +5 -5
- package/dist/components/index.mjs +4 -4
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +92 -7
- package/dist/core/index.d.ts +92 -7
- package/dist/core/index.js +7 -3
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +6 -2
- package/dist/{email-automation-enrollment.fields-BZvF14qx.d.mts → email-automation-enrollment.fields-CPsgxLif.d.mts} +11 -1
- package/dist/{email-automation-enrollment.fields-Cp3VMzFq.d.ts → email-automation-enrollment.fields-CVicSamF.d.ts} +11 -1
- package/dist/email-automations/index.d.mts +19 -5
- package/dist/email-automations/index.d.ts +19 -5
- package/dist/email-automations/index.js +356 -129
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +306 -79
- 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 +5 -5
- package/dist/features/analytics/server.mjs +3 -3
- package/dist/features/blog/index.js +151 -151
- package/dist/features/blog/index.mjs +4 -4
- package/dist/features/blog/server.js +17 -17
- package/dist/features/blog/server.mjs +2 -2
- package/dist/features/help/index.js +38 -38
- package/dist/features/help/index.mjs +4 -4
- package/dist/features/tokenusage/index.js +127 -127
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7 -3
- package/dist/offline/index.js +14 -14
- package/dist/offline/index.mjs +1 -1
- package/dist/{referral-admin.interface-RVO-ZTJz.d.mts → referral-admin.interface-DTj4LzXx.d.mts} +3 -0
- package/dist/{referral-admin.interface-CnytdDaQ.d.ts → referral-admin.interface-DeSCw3Pp.d.ts} +3 -0
- package/dist/server/index.js +4 -4
- package/dist/server/index.mjs +3 -3
- package/messages/en.json +35 -3
- package/messages/it.json +35 -3
- package/package.json +1 -1
- package/src/core/index.ts +1 -0
- package/src/core/registry/ModuleRegistry.ts +1 -0
- package/src/features/email-automation/EmailAutomationCandidateModule.ts +12 -0
- package/src/features/email-automation/EmailAutomationModule.ts +1 -0
- package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +74 -9
- package/src/features/email-automation/components/forms/EmailAutomationAddPersonDialog.tsx +226 -0
- package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +23 -0
- package/src/features/email-automation/components/forms/EmailStepEditor.tsx +7 -3
- package/src/features/email-automation/components/index.ts +1 -0
- package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +38 -20
- package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +1 -0
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +9 -4
- package/src/features/email-automation/data/EmailAutomation.ts +9 -0
- package/src/features/email-automation/data/EmailAutomationCandidate.ts +66 -0
- package/src/features/email-automation/data/EmailAutomationCandidateInterface.ts +19 -0
- package/src/features/email-automation/data/EmailAutomationEnrollment.ts +16 -2
- package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +7 -0
- package/src/features/email-automation/data/EmailAutomationInterface.ts +3 -0
- package/src/features/email-automation/data/EmailAutomationService.ts +53 -1
- package/src/features/email-automation/data/EmailPurpose.ts +1 -0
- package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +70 -0
- package/src/features/email-automation/data/email-automation.fields.ts +1 -0
- package/src/features/email-automation/data/index.ts +2 -0
- package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +19 -0
- package/src/features/email-automation/index.ts +2 -0
- package/src/features/email-blast/EmailBlastModule.ts +1 -0
- package/src/features/email-blast/components/forms/EmailBlastEditor.tsx +33 -4
- package/src/features/email-blast/components/lists/EmailBlastsList.tsx +1 -0
- package/src/features/email-blast/data/EmailBlast.ts +10 -1
- package/src/features/email-blast/data/EmailBlastInterface.ts +3 -0
- package/src/features/email-blast/data/__tests__/EmailBlast.spec.ts +18 -0
- package/src/features/email-blast/data/email-blast.fields.ts +1 -0
- package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +15 -0
- package/src/features/notification/components/notifications/PushNotificationProvider.tsx +14 -2
- package/src/features/notification/components/notifications/__tests__/PushNotificationProvider.spec.tsx +11 -0
- package/dist/chunk-PMESKNKL.js.map +0 -1
- package/dist/chunk-RFKSJIQB.mjs.map +0 -1
- package/dist/chunk-RT7XFGIN.js.map +0 -1
- package/dist/chunk-UOTHRJD2.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-I3J6YYXK.mjs.map → BlockNoteEditor-IGUHTERQ.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-HIUAJQRI.mjs.map → BlockNoteViewer-X4UEVI2A.mjs.map} +0 -0
- /package/dist/{chunk-HAW5PNJG.mjs.map → chunk-TY5YWBLL.mjs.map} +0 -0
- /package/dist/{chunk-OUEYZOLB.mjs.map → chunk-YVKWACDP.mjs.map} +0 -0
|
@@ -8,18 +8,20 @@ import { Modules } from "../../../../core";
|
|
|
8
8
|
import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
|
|
9
9
|
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../shadcnui";
|
|
10
10
|
import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
|
|
11
|
+
import { EmailAutomationAddPersonDialog } from "../forms/EmailAutomationAddPersonDialog";
|
|
11
12
|
import { EmailAutomationEnrollmentFields } from "../../data/email-automation-enrollment.fields";
|
|
12
13
|
import { EmailAutomationEnrollmentInterface } from "../../data/EmailAutomationEnrollmentInterface";
|
|
13
14
|
import { EmailAutomationService } from "../../data/EmailAutomationService";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `fullWidth`), as PricesList does
|
|
17
|
+
* List of the people in this automation (owner decision 6: no row actions; a
|
|
18
|
+
* person is added by hand through "Add person" in the filters). Rendered inside
|
|
19
|
+
* a tab, so it keeps its own bordered card (no `fullWidth`), as PricesList does
|
|
20
|
+
* inside ProductContainer.
|
|
19
21
|
*/
|
|
20
22
|
export function EmailAutomationRecipientsList() {
|
|
21
23
|
const t = useTranslations();
|
|
22
|
-
const { automation } = useEmailAutomation();
|
|
24
|
+
const { automation, reload } = useEmailAutomation();
|
|
23
25
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
24
26
|
|
|
25
27
|
const data: DataListRetriever<EmailAutomationEnrollmentInterface> = useDataListRetriever({
|
|
@@ -48,6 +50,17 @@ export function EmailAutomationRecipientsList() {
|
|
|
48
50
|
|
|
49
51
|
const filters = (
|
|
50
52
|
<div className="flex items-center gap-4">
|
|
53
|
+
{automation && (
|
|
54
|
+
<EmailAutomationAddPersonDialog
|
|
55
|
+
automationId={automation.id}
|
|
56
|
+
disabled={automation.stepCount === 0}
|
|
57
|
+
onAdded={() => {
|
|
58
|
+
void data.refresh();
|
|
59
|
+
void reload();
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
)}
|
|
63
|
+
|
|
51
64
|
<Select items={statusItems} value={statusFilter} onValueChange={handleStatusChange}>
|
|
52
65
|
<SelectTrigger className="w-40">
|
|
53
66
|
<SelectValue />
|
|
@@ -69,22 +82,27 @@ export function EmailAutomationRecipientsList() {
|
|
|
69
82
|
);
|
|
70
83
|
|
|
71
84
|
return (
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
<div data-testid="email-automation-recipients">
|
|
86
|
+
<ContentListTable
|
|
87
|
+
data={data}
|
|
88
|
+
fields={[
|
|
89
|
+
EmailAutomationEnrollmentFields.email,
|
|
90
|
+
EmailAutomationEnrollmentFields.name,
|
|
91
|
+
EmailAutomationEnrollmentFields.status,
|
|
92
|
+
EmailAutomationEnrollmentFields.currentStep,
|
|
93
|
+
EmailAutomationEnrollmentFields.nextSendAt,
|
|
94
|
+
EmailAutomationEnrollmentFields.sentCount,
|
|
95
|
+
]}
|
|
96
|
+
tableGeneratorType={Modules.EmailAutomationEnrollment}
|
|
97
|
+
/* ContentListTable renders its filters only inside the title bar, so the
|
|
98
|
+
list needs a title for "Add person" and the status filter to show. */
|
|
99
|
+
title={t("emailAutomations.tabs.recipients")}
|
|
100
|
+
filters={filters}
|
|
101
|
+
/* The enrolments endpoint takes no search term, so rendering the box would
|
|
85
102
|
give the page a control that silently does nothing. */
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
allowSearch={false}
|
|
104
|
+
emptyState={t("emailAutomations.recipients.empty")}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
89
107
|
);
|
|
90
108
|
}
|
|
@@ -58,6 +58,7 @@ export function EmailAutomationsList({ fullWidth }: EmailAutomationsListProps =
|
|
|
58
58
|
data={data}
|
|
59
59
|
fields={[
|
|
60
60
|
EmailAutomationFields.name,
|
|
61
|
+
EmailAutomationFields.purpose,
|
|
61
62
|
EmailAutomationFields.trigger,
|
|
62
63
|
EmailAutomationFields.status,
|
|
63
64
|
EmailAutomationFields.steps,
|
|
@@ -27,6 +27,8 @@ export function EmailUnsubscribe({ token, scope, source = "automation" }: Props)
|
|
|
27
27
|
const [state, setState] = useState<UnsubscribeState>("loading");
|
|
28
28
|
const [stoppedScope, setStoppedScope] = useState<UnsubscribeScope>(scope);
|
|
29
29
|
const [automationName, setAutomationName] = useState<string>("");
|
|
30
|
+
// A Service automation is not a marketing email: its page offers only "Stop these emails".
|
|
31
|
+
const [isServiceAutomation, setIsServiceAutomation] = useState<boolean>(false);
|
|
30
32
|
const [isStoppingAll, setIsStoppingAll] = useState<boolean>(false);
|
|
31
33
|
|
|
32
34
|
useEffect(() => {
|
|
@@ -40,6 +42,7 @@ export function EmailUnsubscribe({ token, scope, source = "automation" }: Props)
|
|
|
40
42
|
}
|
|
41
43
|
const enrollment = await EmailAutomationEnrollmentService.unsubscribe({ token, scope });
|
|
42
44
|
setAutomationName(enrollment?.automation?.name ?? "");
|
|
45
|
+
setIsServiceAutomation(enrollment?.automation?.purpose === "service");
|
|
43
46
|
setStoppedScope(scope);
|
|
44
47
|
setState("success");
|
|
45
48
|
} catch {
|
|
@@ -100,10 +103,12 @@ export function EmailUnsubscribe({ token, scope, source = "automation" }: Props)
|
|
|
100
103
|
<p className="text-muted-foreground max-w-md text-sm leading-relaxed">
|
|
101
104
|
{t("emailAutomations.unsubscribe.stoppedAutomation", { name: automationName })}
|
|
102
105
|
</p>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
{!isServiceAutomation && (
|
|
107
|
+
<Button variant="secondary" onClick={stopAll} disabled={isStoppingAll}>
|
|
108
|
+
{isStoppingAll && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
109
|
+
{t("emailAutomations.unsubscribe.alsoStopAll")}
|
|
110
|
+
</Button>
|
|
111
|
+
)}
|
|
107
112
|
</>
|
|
108
113
|
)}
|
|
109
114
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
2
2
|
import { EmailAutomationInput, EmailAutomationInterface } from "./EmailAutomationInterface";
|
|
3
|
+
import { EmailPurpose } from "./EmailPurpose";
|
|
3
4
|
|
|
4
5
|
export class EmailAutomation extends AbstractApiData implements EmailAutomationInterface {
|
|
5
6
|
private _name?: string;
|
|
@@ -7,6 +8,7 @@ export class EmailAutomation extends AbstractApiData implements EmailAutomationI
|
|
|
7
8
|
private _exitTrigger?: string;
|
|
8
9
|
private _replyTo?: string;
|
|
9
10
|
private _isActive: boolean = false;
|
|
11
|
+
private _purpose: EmailPurpose = "marketing";
|
|
10
12
|
private _stepCount?: number;
|
|
11
13
|
private _activeCount?: number;
|
|
12
14
|
private _completedCount?: number;
|
|
@@ -32,6 +34,10 @@ export class EmailAutomation extends AbstractApiData implements EmailAutomationI
|
|
|
32
34
|
return this._isActive;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
get purpose(): EmailPurpose {
|
|
38
|
+
return this._purpose;
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
get stepCount(): number {
|
|
36
42
|
return this._stepCount ?? 0;
|
|
37
43
|
}
|
|
@@ -56,6 +62,8 @@ export class EmailAutomation extends AbstractApiData implements EmailAutomationI
|
|
|
56
62
|
this._exitTrigger = data.jsonApi.attributes.exitTrigger ?? undefined;
|
|
57
63
|
this._replyTo = data.jsonApi.attributes.replyTo ?? undefined;
|
|
58
64
|
this._isActive = data.jsonApi.attributes.isActive === true;
|
|
65
|
+
// A record saved before purpose existed counts as Marketing.
|
|
66
|
+
this._purpose = data.jsonApi.attributes.purpose === "service" ? "service" : "marketing";
|
|
59
67
|
this._stepCount = data.jsonApi.attributes.stepCount;
|
|
60
68
|
this._activeCount = data.jsonApi.attributes.activeCount;
|
|
61
69
|
this._completedCount = data.jsonApi.attributes.completedCount;
|
|
@@ -81,6 +89,7 @@ export class EmailAutomation extends AbstractApiData implements EmailAutomationI
|
|
|
81
89
|
if (data.exitTrigger !== undefined) response.data.attributes.exitTrigger = data.exitTrigger;
|
|
82
90
|
if (data.replyTo !== undefined) response.data.attributes.replyTo = data.replyTo;
|
|
83
91
|
if (data.isActive !== undefined) response.data.attributes.isActive = data.isActive;
|
|
92
|
+
if (data.purpose !== undefined) response.data.attributes.purpose = data.purpose;
|
|
84
93
|
|
|
85
94
|
return response;
|
|
86
95
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
2
|
+
import {
|
|
3
|
+
EmailAutomationCandidateInterface,
|
|
4
|
+
EmailAutomationCandidateReason,
|
|
5
|
+
EmailAutomationRecipientType,
|
|
6
|
+
} from "./EmailAutomationCandidateInterface";
|
|
7
|
+
|
|
8
|
+
export class EmailAutomationCandidate extends AbstractApiData implements EmailAutomationCandidateInterface {
|
|
9
|
+
private _recipientType: EmailAutomationRecipientType = "user";
|
|
10
|
+
private _recipientId?: string;
|
|
11
|
+
private _email?: string;
|
|
12
|
+
private _name?: string;
|
|
13
|
+
private _addable?: boolean;
|
|
14
|
+
private _reason?: EmailAutomationCandidateReason;
|
|
15
|
+
|
|
16
|
+
get recipientType(): EmailAutomationRecipientType {
|
|
17
|
+
return this._recipientType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get recipientId(): string {
|
|
21
|
+
return this._recipientId ?? "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get email(): string {
|
|
25
|
+
return this._email ?? "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get name(): string | undefined {
|
|
29
|
+
return this._name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get addable(): boolean {
|
|
33
|
+
return this._addable ?? false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get reason(): EmailAutomationCandidateReason | undefined {
|
|
37
|
+
return this._reason;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
41
|
+
super.rehydrate(data);
|
|
42
|
+
|
|
43
|
+
const attributes = data.jsonApi.attributes;
|
|
44
|
+
this._recipientType = attributes.recipientType === "waitlist" ? "waitlist" : "user";
|
|
45
|
+
this._recipientId = attributes.recipientId;
|
|
46
|
+
this._email = attributes.email;
|
|
47
|
+
this._name = attributes.name ?? undefined;
|
|
48
|
+
this._addable = attributes.addable === true;
|
|
49
|
+
this._reason = attributes.reason ?? undefined;
|
|
50
|
+
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The API computes candidates and never accepts one as a request body; this
|
|
56
|
+
* only identifies the resource.
|
|
57
|
+
*/
|
|
58
|
+
createJsonApi(data: { id: string }) {
|
|
59
|
+
return {
|
|
60
|
+
data: {
|
|
61
|
+
type: Modules.EmailAutomationCandidate.name,
|
|
62
|
+
id: data.id,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ApiDataInterface } from "../../../core";
|
|
2
|
+
|
|
3
|
+
export type EmailAutomationRecipientType = "user" | "waitlist";
|
|
4
|
+
|
|
5
|
+
export type EmailAutomationCandidateReason = "already_enrolled" | "opted_out" | "no_consent";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A person the administrator may add to an automation by hand. The API decides
|
|
9
|
+
* whether they can be added (`addable`) and why not (`reason`); the browser only
|
|
10
|
+
* displays it.
|
|
11
|
+
*/
|
|
12
|
+
export interface EmailAutomationCandidateInterface extends ApiDataInterface {
|
|
13
|
+
get recipientType(): EmailAutomationRecipientType;
|
|
14
|
+
get recipientId(): string;
|
|
15
|
+
get email(): string;
|
|
16
|
+
get name(): string | undefined;
|
|
17
|
+
get addable(): boolean;
|
|
18
|
+
get reason(): EmailAutomationCandidateReason | undefined;
|
|
19
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
2
2
|
import {
|
|
3
|
+
EmailAutomationEnrollmentInput,
|
|
3
4
|
EmailAutomationEnrollmentInterface,
|
|
4
5
|
EmailAutomationEnrollmentStatus,
|
|
5
6
|
} from "./EmailAutomationEnrollmentInterface";
|
|
@@ -85,8 +86,21 @@ export class EmailAutomationEnrollment extends AbstractApiData implements EmailA
|
|
|
85
86
|
return this;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Body of POST email-automations/:id/email-automation-enrollments: an
|
|
91
|
+
* administrator adds a person by hand. The API enrols them at the first step.
|
|
92
|
+
*/
|
|
93
|
+
createJsonApi(data: EmailAutomationEnrollmentInput) {
|
|
94
|
+
return {
|
|
95
|
+
data: {
|
|
96
|
+
type: Modules.EmailAutomationEnrollment.name,
|
|
97
|
+
id: data.id,
|
|
98
|
+
attributes: {
|
|
99
|
+
recipientType: data.recipientType,
|
|
100
|
+
recipientId: data.recipientId,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
/**
|
|
@@ -3,6 +3,13 @@ import { EmailAutomationInterface } from "./EmailAutomationInterface";
|
|
|
3
3
|
|
|
4
4
|
export type EmailAutomationEnrollmentStatus = "active" | "completed" | "unsubscribed" | "exited" | "failed";
|
|
5
5
|
|
|
6
|
+
/** Body of POST email-automations/:id/email-automation-enrollments (manual add). */
|
|
7
|
+
export type EmailAutomationEnrollmentInput = {
|
|
8
|
+
id: string;
|
|
9
|
+
recipientType: "user" | "waitlist";
|
|
10
|
+
recipientId: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
6
13
|
export interface EmailAutomationEnrollmentInterface extends ApiDataInterface {
|
|
7
14
|
get email(): string;
|
|
8
15
|
get name(): string | undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApiDataInterface } from "../../../core";
|
|
2
|
+
import { EmailPurpose } from "./EmailPurpose";
|
|
2
3
|
|
|
3
4
|
export type EmailAutomationInput = {
|
|
4
5
|
id: string;
|
|
@@ -7,6 +8,7 @@ export type EmailAutomationInput = {
|
|
|
7
8
|
exitTrigger?: string;
|
|
8
9
|
replyTo?: string;
|
|
9
10
|
isActive?: boolean;
|
|
11
|
+
purpose?: EmailPurpose;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export interface EmailAutomationInterface extends ApiDataInterface {
|
|
@@ -15,6 +17,7 @@ export interface EmailAutomationInterface extends ApiDataInterface {
|
|
|
15
17
|
get exitTrigger(): string | undefined;
|
|
16
18
|
get replyTo(): string | undefined;
|
|
17
19
|
get isActive(): boolean;
|
|
20
|
+
get purpose(): EmailPurpose;
|
|
18
21
|
get stepCount(): number;
|
|
19
22
|
get activeCount(): number;
|
|
20
23
|
get completedCount(): number;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef, PreviousRef } from "../../../core";
|
|
2
|
-
import {
|
|
2
|
+
import { EmailAutomationCandidateInterface } from "./EmailAutomationCandidateInterface";
|
|
3
|
+
import {
|
|
4
|
+
EmailAutomationEnrollmentInput,
|
|
5
|
+
EmailAutomationEnrollmentInterface,
|
|
6
|
+
} from "./EmailAutomationEnrollmentInterface";
|
|
3
7
|
import { EmailAutomationInput, EmailAutomationInterface } from "./EmailAutomationInterface";
|
|
4
8
|
import { EmailAutomationTriggerInterface } from "./EmailAutomationTriggerInterface";
|
|
5
9
|
|
|
@@ -103,4 +107,52 @@ export class EmailAutomationService extends AbstractService {
|
|
|
103
107
|
previous: params.previous,
|
|
104
108
|
});
|
|
105
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* GET email-automations/:id/email-automation-candidates: users and waitlist
|
|
113
|
+
* entries matching the search, each flagged addable or not by the API. Paged:
|
|
114
|
+
* read through useDataListRetriever, which passes the next/previous refs.
|
|
115
|
+
*/
|
|
116
|
+
static async findCandidates(params: {
|
|
117
|
+
automationId: string;
|
|
118
|
+
search?: string;
|
|
119
|
+
next?: NextRef;
|
|
120
|
+
previous?: PreviousRef;
|
|
121
|
+
}): Promise<EmailAutomationCandidateInterface[]> {
|
|
122
|
+
const endpoint = new EndpointCreator({
|
|
123
|
+
endpoint: Modules.EmailAutomation,
|
|
124
|
+
id: params.automationId,
|
|
125
|
+
childEndpoint: Modules.EmailAutomationCandidate,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (params.search) endpoint.addAdditionalParam("search", params.search);
|
|
129
|
+
|
|
130
|
+
return this.callApi<EmailAutomationCandidateInterface[]>({
|
|
131
|
+
type: Modules.EmailAutomationCandidate,
|
|
132
|
+
method: HttpMethod.GET,
|
|
133
|
+
endpoint: endpoint.generate(),
|
|
134
|
+
next: params.next,
|
|
135
|
+
previous: params.previous,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* POST email-automations/:id/email-automation-enrollments: adds one person by
|
|
141
|
+
* hand. The API re-checks every rule and answers 400 with the reason.
|
|
142
|
+
*/
|
|
143
|
+
static async addEnrollment(params: {
|
|
144
|
+
automationId: string;
|
|
145
|
+
input: EmailAutomationEnrollmentInput;
|
|
146
|
+
}): Promise<EmailAutomationEnrollmentInterface> {
|
|
147
|
+
return this.callApi<EmailAutomationEnrollmentInterface>({
|
|
148
|
+
type: Modules.EmailAutomationEnrollment,
|
|
149
|
+
method: HttpMethod.POST,
|
|
150
|
+
endpoint: new EndpointCreator({
|
|
151
|
+
endpoint: Modules.EmailAutomation,
|
|
152
|
+
id: params.automationId,
|
|
153
|
+
childEndpoint: Modules.EmailAutomationEnrollment,
|
|
154
|
+
}).generate(),
|
|
155
|
+
input: params.input,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
106
158
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type EmailPurpose = "service" | "marketing";
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
2
|
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
3
3
|
import { ModuleFactory, ModuleWithPermissions } from "../../../../permissions/types";
|
|
4
|
+
import { EmailAutomationCandidateModule } from "../../EmailAutomationCandidateModule";
|
|
4
5
|
import { EmailAutomationEnrollmentModule } from "../../EmailAutomationEnrollmentModule";
|
|
5
6
|
import { EmailAutomationModule } from "../../EmailAutomationModule";
|
|
6
7
|
import { EmailAutomationStepModule } from "../../EmailAutomationStepModule";
|
|
7
8
|
import { EmailAutomationTriggerModule } from "../../EmailAutomationTriggerModule";
|
|
8
9
|
import { EmailAutomation } from "../EmailAutomation";
|
|
10
|
+
import { EmailAutomationCandidate } from "../EmailAutomationCandidate";
|
|
9
11
|
import { EMAIL_AUTOMATION_UNSUBSCRIBE_TYPE, EmailAutomationEnrollment } from "../EmailAutomationEnrollment";
|
|
10
12
|
import { EmailAutomationStep } from "../EmailAutomationStep";
|
|
11
13
|
|
|
@@ -24,6 +26,7 @@ beforeAll(() => {
|
|
|
24
26
|
ModuleRegistry.register("EmailAutomationStep", EmailAutomationStepModule(moduleFactory));
|
|
25
27
|
ModuleRegistry.register("EmailAutomationEnrollment", EmailAutomationEnrollmentModule(moduleFactory));
|
|
26
28
|
ModuleRegistry.register("EmailAutomationTrigger", EmailAutomationTriggerModule(moduleFactory));
|
|
29
|
+
ModuleRegistry.register("EmailAutomationCandidate", EmailAutomationCandidateModule(moduleFactory));
|
|
27
30
|
});
|
|
28
31
|
|
|
29
32
|
const hydrate = (attributes: Record<string, unknown>) =>
|
|
@@ -100,4 +103,71 @@ describe("email automation models", () => {
|
|
|
100
103
|
data: { type: EMAIL_AUTOMATION_UNSUBSCRIBE_TYPE, attributes: { scope: "all" } },
|
|
101
104
|
});
|
|
102
105
|
});
|
|
106
|
+
|
|
107
|
+
it("EmailAutomationCandidate rehydrates kind, id, email, addable and reason", () => {
|
|
108
|
+
const blocked = new EmailAutomationCandidate().rehydrate(
|
|
109
|
+
hydrate({
|
|
110
|
+
recipientType: "waitlist",
|
|
111
|
+
recipientId: "w1",
|
|
112
|
+
email: "ada@example.com",
|
|
113
|
+
addable: false,
|
|
114
|
+
reason: "no_consent",
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
expect(blocked.recipientType).toBe("waitlist");
|
|
118
|
+
expect(blocked.recipientId).toBe("w1");
|
|
119
|
+
expect(blocked.email).toBe("ada@example.com");
|
|
120
|
+
expect(blocked.name).toBeUndefined();
|
|
121
|
+
expect(blocked.addable).toBe(false);
|
|
122
|
+
expect(blocked.reason).toBe("no_consent");
|
|
123
|
+
|
|
124
|
+
const open = new EmailAutomationCandidate().rehydrate(
|
|
125
|
+
hydrate({
|
|
126
|
+
recipientType: "user",
|
|
127
|
+
recipientId: "u1",
|
|
128
|
+
email: "grace@example.com",
|
|
129
|
+
name: "Grace",
|
|
130
|
+
addable: true,
|
|
131
|
+
reason: null,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(open.recipientType).toBe("user");
|
|
135
|
+
expect(open.name).toBe("Grace");
|
|
136
|
+
expect(open.addable).toBe(true);
|
|
137
|
+
expect(open.reason).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("EmailAutomationCandidate createJsonApi returns type and id only", () => {
|
|
141
|
+
expect(new EmailAutomationCandidate().createJsonApi({ id: "u1" })).toEqual({
|
|
142
|
+
data: { type: "email-automation-candidates", id: "u1" },
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("EmailAutomationEnrollment.createJsonApi builds type, id, recipientType and recipientId", () => {
|
|
147
|
+
expect(
|
|
148
|
+
new EmailAutomationEnrollment().createJsonApi({ id: "e1", recipientType: "user", recipientId: "u1" }),
|
|
149
|
+
).toEqual({
|
|
150
|
+
data: {
|
|
151
|
+
type: "email-automation-enrollments",
|
|
152
|
+
id: "e1",
|
|
153
|
+
attributes: { recipientType: "user", recipientId: "u1" },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
it('EmailAutomation rehydrates purpose "service"', () => {
|
|
158
|
+
const m = new EmailAutomation().rehydrate(hydrate({ name: "Welcome", purpose: "service" }));
|
|
159
|
+
expect(m.purpose).toBe("service");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("missing purpose rehydrates as marketing", () => {
|
|
163
|
+
expect(new EmailAutomation().rehydrate(hydrate({ name: "Welcome" })).purpose).toBe("marketing");
|
|
164
|
+
expect(new EmailAutomation().rehydrate(hydrate({ name: "Welcome", purpose: "other" })).purpose).toBe("marketing");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("createJsonApi sends purpose when set", () => {
|
|
168
|
+
const body = new EmailAutomation().createJsonApi({ id: "a1", name: "Welcome", purpose: "service" });
|
|
169
|
+
expect(body.data.attributes.purpose).toBe("service");
|
|
170
|
+
const without = new EmailAutomation().createJsonApi({ id: "a1", name: "Welcome" });
|
|
171
|
+
expect("purpose" in without.data.attributes).toBe(false);
|
|
172
|
+
});
|
|
103
173
|
});
|
|
@@ -9,5 +9,7 @@ export * from "./EmailAutomationEnrollment";
|
|
|
9
9
|
export * from "./EmailAutomationEnrollmentService";
|
|
10
10
|
export * from "./EmailAutomationTriggerInterface";
|
|
11
11
|
export * from "./EmailAutomationTrigger";
|
|
12
|
+
export * from "./EmailAutomationCandidateInterface";
|
|
13
|
+
export * from "./EmailAutomationCandidate";
|
|
12
14
|
export * from "./email-automation.fields";
|
|
13
15
|
export * from "./email-automation-enrollment.fields";
|
|
@@ -62,6 +62,25 @@ export const useEmailAutomationTableStructure: UseTableStructureHook<
|
|
|
62
62
|
enableSorting: false,
|
|
63
63
|
enableHiding: false,
|
|
64
64
|
}),
|
|
65
|
+
[EmailAutomationFields.purpose]: () => ({
|
|
66
|
+
id: "purpose",
|
|
67
|
+
accessorKey: "purpose",
|
|
68
|
+
header: t("emailAutomations.list.columns.purpose"),
|
|
69
|
+
cell: ({ row }: { row: TableContent<EmailAutomationInterface> }) => {
|
|
70
|
+
const automation: EmailAutomationInterface = row.original.jsonApiData;
|
|
71
|
+
return automation.purpose === "service" ? (
|
|
72
|
+
<Badge variant="softBlue" data-testid="email-automation-purpose-badge">
|
|
73
|
+
{t("emailPurpose.service")}
|
|
74
|
+
</Badge>
|
|
75
|
+
) : (
|
|
76
|
+
<Badge variant="softGray" data-testid="email-automation-purpose-badge">
|
|
77
|
+
{t("emailPurpose.marketing")}
|
|
78
|
+
</Badge>
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
enableSorting: false,
|
|
82
|
+
enableHiding: false,
|
|
83
|
+
}),
|
|
65
84
|
[EmailAutomationFields.trigger]: () => ({
|
|
66
85
|
id: "trigger",
|
|
67
86
|
accessorKey: "trigger",
|
|
@@ -2,4 +2,6 @@ export * from "./EmailAutomationModule";
|
|
|
2
2
|
export * from "./EmailAutomationStepModule";
|
|
3
3
|
export * from "./EmailAutomationEnrollmentModule";
|
|
4
4
|
export * from "./EmailAutomationTriggerModule";
|
|
5
|
+
export * from "./EmailAutomationCandidateModule";
|
|
5
6
|
export * from "./data";
|
|
7
|
+
export * from "./data/EmailPurpose";
|
|
@@ -6,14 +6,17 @@ import { ReactNode, useCallback, useMemo } from "react";
|
|
|
6
6
|
import { useForm } from "react-hook-form";
|
|
7
7
|
import { v4 } from "uuid";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { EditorSheet, FormInput, FormTextarea } from "../../../../components";
|
|
9
|
+
import { EditorSheet, FormInput, FormSelect, FormTextarea } from "../../../../components";
|
|
10
10
|
import { Modules } from "../../../../core";
|
|
11
11
|
import { useI18nRouter } from "../../../../i18n";
|
|
12
|
+
import { EmailPurpose } from "../../../email-automation/data/EmailPurpose";
|
|
12
13
|
import { EmailBlastInput, EmailBlastInterface } from "../../data/EmailBlastInterface";
|
|
13
14
|
import { EmailBlastService } from "../../data/EmailBlastService";
|
|
14
15
|
|
|
15
16
|
/** The fixed variable set the blast renderer fills. */
|
|
16
|
-
const TEMPLATE_VARIABLES = ["{{name}}", "{{email}}", "{{url}}"
|
|
17
|
+
const TEMPLATE_VARIABLES = ["{{name}}", "{{email}}", "{{url}}"];
|
|
18
|
+
/** Only a Marketing blast fills it: a Service blast carries no unsubscribe link. */
|
|
19
|
+
const MARKETING_ONLY_VARIABLES = ["{{unsubscribeAllUrl}}"];
|
|
17
20
|
|
|
18
21
|
export type EmailBlastEditorProps = {
|
|
19
22
|
blast?: EmailBlastInterface;
|
|
@@ -49,6 +52,7 @@ function EmailBlastEditorInternal({
|
|
|
49
52
|
replyTo: z.string().refine((value) => value === "" || z.email().safeParse(value).success, {
|
|
50
53
|
message: t("emailBlasts.editor.fields.replyTo.error"),
|
|
51
54
|
}),
|
|
55
|
+
purpose: z.enum(["service", "marketing"], { message: t("emailPurpose.error") }),
|
|
52
56
|
}),
|
|
53
57
|
[t],
|
|
54
58
|
);
|
|
@@ -59,6 +63,8 @@ function EmailBlastEditorInternal({
|
|
|
59
63
|
subject: blast?.subject ?? "",
|
|
60
64
|
body: blast?.body ?? "",
|
|
61
65
|
replyTo: blast?.replyTo ?? "",
|
|
66
|
+
// No preselected value on create: the form refuses to save without a choice.
|
|
67
|
+
purpose: (blast?.purpose ?? "") as EmailPurpose,
|
|
62
68
|
}),
|
|
63
69
|
[blast],
|
|
64
70
|
);
|
|
@@ -68,6 +74,17 @@ function EmailBlastEditorInternal({
|
|
|
68
74
|
defaultValues: getDefaultValues(),
|
|
69
75
|
});
|
|
70
76
|
|
|
77
|
+
const purpose = form.watch("purpose");
|
|
78
|
+
const templateVariables =
|
|
79
|
+
purpose === "service" ? TEMPLATE_VARIABLES : [...TEMPLATE_VARIABLES, ...MARKETING_ONLY_VARIABLES];
|
|
80
|
+
const purposeValues = useMemo(
|
|
81
|
+
() => [
|
|
82
|
+
{ id: "service", text: t("emailPurpose.service") },
|
|
83
|
+
{ id: "marketing", text: t("emailPurpose.marketing") },
|
|
84
|
+
],
|
|
85
|
+
[t],
|
|
86
|
+
);
|
|
87
|
+
|
|
71
88
|
return (
|
|
72
89
|
<EditorSheet
|
|
73
90
|
form={form}
|
|
@@ -86,6 +103,7 @@ function EmailBlastEditorInternal({
|
|
|
86
103
|
subject: values.subject,
|
|
87
104
|
body: values.body,
|
|
88
105
|
replyTo: values.replyTo || undefined,
|
|
106
|
+
purpose: values.purpose,
|
|
89
107
|
};
|
|
90
108
|
return isEdit ? await EmailBlastService.update(payload) : await EmailBlastService.create(payload);
|
|
91
109
|
}}
|
|
@@ -98,6 +116,15 @@ function EmailBlastEditorInternal({
|
|
|
98
116
|
>
|
|
99
117
|
<div className="flex w-full flex-col gap-y-4">
|
|
100
118
|
<FormInput form={form} id="subject" name={t("emailBlasts.editor.fields.subject.label")} isRequired />
|
|
119
|
+
<FormSelect
|
|
120
|
+
form={form}
|
|
121
|
+
id="purpose"
|
|
122
|
+
name={t("emailPurpose.label")}
|
|
123
|
+
placeholder={t("emailPurpose.placeholder")}
|
|
124
|
+
values={purposeValues}
|
|
125
|
+
description={purpose === "service" ? t("emailPurpose.serviceWarning") : undefined}
|
|
126
|
+
isRequired
|
|
127
|
+
/>
|
|
101
128
|
<FormInput form={form} id="replyTo" name={t("emailBlasts.editor.fields.replyTo.label")} />
|
|
102
129
|
{/* FormTextarea has no `rows` prop; min-h-72 is the height of ~14 rows. */}
|
|
103
130
|
<FormTextarea
|
|
@@ -110,13 +137,15 @@ function EmailBlastEditorInternal({
|
|
|
110
137
|
<>
|
|
111
138
|
<span className="block">
|
|
112
139
|
{t("emailBlasts.editor.variablesHint")}{" "}
|
|
113
|
-
{
|
|
140
|
+
{templateVariables.map((variable) => (
|
|
114
141
|
<span key={variable} className="me-2 text-foreground">
|
|
115
142
|
{variable}
|
|
116
143
|
</span>
|
|
117
144
|
))}
|
|
118
145
|
</span>
|
|
119
|
-
<span className="mt-1 block">
|
|
146
|
+
<span className="mt-1 block">
|
|
147
|
+
{purpose === "service" ? t("emailBlasts.editor.serviceHint") : t("emailBlasts.editor.unsubscribeHint")}
|
|
148
|
+
</span>
|
|
120
149
|
</>
|
|
121
150
|
}
|
|
122
151
|
/>
|