@carlonicora/nextjs-jsonapi 5.2.0 → 5.3.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-EGY4ZAAM.js → BlockNoteEditor-UJ6363LC.js} +19 -19
- package/dist/{BlockNoteEditor-EGY4ZAAM.js.map → BlockNoteEditor-UJ6363LC.js.map} +1 -1
- package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-WTSQANKI.mjs} +4 -4
- package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-EL4JFB6P.js} +3 -3
- package/dist/{BlockNoteViewer-I2MMFH3S.js.map → BlockNoteViewer-EL4JFB6P.js.map} +1 -1
- package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-JVKWJZ6J.mjs} +2 -2
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-3VEQ5JZJ.js → chunk-3BAM23VA.js} +847 -847
- package/dist/chunk-3BAM23VA.js.map +1 -0
- package/dist/{chunk-JEZQTZQ4.mjs → chunk-6WU74VFU.mjs} +2 -2
- package/dist/{chunk-S5MQDWWC.js → chunk-CRI4T47Y.js} +736 -176
- package/dist/chunk-CRI4T47Y.js.map +1 -0
- package/dist/{chunk-75MLT74B.js → chunk-EO75CV76.js} +8 -8
- package/dist/{chunk-75MLT74B.js.map → chunk-EO75CV76.js.map} +1 -1
- package/dist/{chunk-NPKFMRH5.mjs → chunk-FNTQQFLH.mjs} +2 -2
- package/dist/{chunk-HNOWSFM5.mjs → chunk-JQWI62NV.mjs} +563 -3
- package/dist/chunk-JQWI62NV.mjs.map +1 -0
- package/dist/{chunk-2Z3ZCYXX.mjs → chunk-MIZTWBPX.mjs} +5 -5
- package/dist/chunk-MIZTWBPX.mjs.map +1 -0
- package/dist/{chunk-YMUPTXOT.mjs → chunk-QKZUE5OF.mjs} +2 -2
- package/dist/{chunk-EXICQC4A.js → chunk-QQQE3N2X.js} +3 -3
- package/dist/{chunk-EXICQC4A.js.map → chunk-QQQE3N2X.js.map} +1 -1
- package/dist/{chunk-THKYH65N.js → chunk-XGTQWBDJ.js} +4 -4
- package/dist/{chunk-THKYH65N.js.map → chunk-XGTQWBDJ.js.map} +1 -1
- package/dist/client/index.d.mts +6 -39
- package/dist/client/index.d.ts +6 -39
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +2 -2
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +187 -1
- package/dist/core/index.d.ts +187 -1
- package/dist/core/index.js +30 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +29 -1
- package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
- package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
- package/dist/email-automations/index.css +30 -0
- package/dist/email-automations/index.css.map +1 -0
- package/dist/email-automations/index.d.mts +303 -0
- package/dist/email-automations/index.d.ts +303 -0
- package/dist/email-automations/index.js +1705 -0
- package/dist/email-automations/index.js.map +1 -0
- package/dist/email-automations/index.mjs +1705 -0
- package/dist/email-automations/index.mjs.map +1 -0
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +31 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +30 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/types-70acd-8C.d.mts +39 -0
- package/dist/types-l8SFleXi.d.ts +39 -0
- package/messages/en.json +117 -0
- package/messages/it.json +117 -0
- package/package.json +11 -1
- package/src/components/forms/FormFieldWrapper.tsx +1 -1
- package/src/components/forms/FormTextarea.tsx +2 -1
- package/src/core/index.ts +8 -0
- package/src/core/registry/ModuleRegistry.ts +5 -0
- package/src/email-automations/index.ts +5 -0
- package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationModule.ts +30 -0
- package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
- package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
- package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
- package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
- package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
- package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
- package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
- package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
- package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
- package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
- package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
- package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
- package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
- package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
- package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
- package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
- package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
- package/src/features/email-automation/components/index.ts +15 -0
- package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
- package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
- package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
- package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
- package/src/features/email-automation/data/EmailAutomation.ts +87 -0
- package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
- package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
- package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
- package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
- package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
- package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
- package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
- package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
- package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
- package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
- package/src/features/email-automation/data/email-automation.fields.ts +9 -0
- package/src/features/email-automation/data/index.ts +13 -0
- package/src/features/email-automation/hooks/index.ts +2 -0
- package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
- package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
- package/src/features/email-automation/index.ts +5 -0
- package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
- package/dist/chunk-3VEQ5JZJ.js.map +0 -1
- package/dist/chunk-HNOWSFM5.mjs.map +0 -1
- package/dist/chunk-S5MQDWWC.js.map +0 -1
- /package/dist/{BlockNoteEditor-2QJVK2HH.mjs.map → BlockNoteEditor-WTSQANKI.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-JVKWJZ6J.mjs.map} +0 -0
- /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-6WU74VFU.mjs.map} +0 -0
- /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-FNTQQFLH.mjs.map} +0 -0
- /package/dist/{chunk-YMUPTXOT.mjs.map → chunk-QKZUE5OF.mjs.map} +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { useForm } from "react-hook-form";
|
|
7
|
+
import { v4 } from "uuid";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { EditorSheet, FormInput, FormSelect, FormSwitch } from "../../../../components";
|
|
10
|
+
import { Modules } from "../../../../core";
|
|
11
|
+
import { useI18nRouter } from "../../../../i18n";
|
|
12
|
+
import { EmailAutomationInput, EmailAutomationInterface } from "../../data/EmailAutomationInterface";
|
|
13
|
+
import { EmailAutomationService } from "../../data/EmailAutomationService";
|
|
14
|
+
import { EmailAutomationTriggerInterface } from "../../data/EmailAutomationTriggerInterface";
|
|
15
|
+
|
|
16
|
+
export type EmailAutomationEditorProps = {
|
|
17
|
+
automation?: EmailAutomationInterface;
|
|
18
|
+
propagateChanges?: (automation: EmailAutomationInterface) => void;
|
|
19
|
+
onSuccess?: () => void | Promise<void>;
|
|
20
|
+
trigger?: ReactNode;
|
|
21
|
+
forceShow?: boolean;
|
|
22
|
+
onClose?: () => void;
|
|
23
|
+
dialogOpen?: boolean;
|
|
24
|
+
onDialogOpenChange?: (open: boolean) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function EmailAutomationEditorInternal({
|
|
28
|
+
automation,
|
|
29
|
+
propagateChanges,
|
|
30
|
+
onSuccess,
|
|
31
|
+
trigger,
|
|
32
|
+
forceShow,
|
|
33
|
+
onClose,
|
|
34
|
+
dialogOpen,
|
|
35
|
+
onDialogOpenChange,
|
|
36
|
+
}: EmailAutomationEditorProps) {
|
|
37
|
+
const t = useTranslations();
|
|
38
|
+
const router = useI18nRouter();
|
|
39
|
+
const isEdit = !!automation;
|
|
40
|
+
const [triggers, setTriggers] = useState<EmailAutomationTriggerInterface[]>([]);
|
|
41
|
+
|
|
42
|
+
// Trigger labels are plain strings from the app config, not i18n keys.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let active = true;
|
|
45
|
+
EmailAutomationService.findTriggers()
|
|
46
|
+
.then((list) => {
|
|
47
|
+
if (active) setTriggers(list);
|
|
48
|
+
})
|
|
49
|
+
.catch(() => {});
|
|
50
|
+
return () => {
|
|
51
|
+
active = false;
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const triggerValues = useMemo(() => triggers.map((entry) => ({ id: entry.key, text: entry.label })), [triggers]);
|
|
56
|
+
|
|
57
|
+
const formSchema = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
z.object({
|
|
60
|
+
id: z.uuidv4(),
|
|
61
|
+
name: z.string().min(1, { message: t("emailAutomations.fields.name.error") }),
|
|
62
|
+
trigger: z.string().optional(),
|
|
63
|
+
exitTrigger: z.string().optional(),
|
|
64
|
+
replyTo: z.string().refine((value) => value === "" || z.email().safeParse(value).success, {
|
|
65
|
+
message: t("emailAutomations.fields.replyTo.error"),
|
|
66
|
+
}),
|
|
67
|
+
isActive: z.boolean(),
|
|
68
|
+
}),
|
|
69
|
+
[t],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const getDefaultValues = useCallback(
|
|
73
|
+
() => ({
|
|
74
|
+
id: automation?.id ?? v4(),
|
|
75
|
+
name: automation?.name ?? "",
|
|
76
|
+
trigger: automation?.trigger ?? "",
|
|
77
|
+
exitTrigger: automation?.exitTrigger ?? "",
|
|
78
|
+
replyTo: automation?.replyTo ?? "",
|
|
79
|
+
isActive: automation?.isActive ?? false,
|
|
80
|
+
}),
|
|
81
|
+
[automation],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const form = useForm<z.infer<typeof formSchema>>({
|
|
85
|
+
resolver: zodResolver(formSchema),
|
|
86
|
+
defaultValues: getDefaultValues(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<EditorSheet
|
|
91
|
+
form={form}
|
|
92
|
+
entityType={t("emailAutomations.admin.title")}
|
|
93
|
+
entityName={automation?.name}
|
|
94
|
+
title={isEdit ? t("emailAutomations.admin.editAutomation") : t("emailAutomations.admin.newAutomation")}
|
|
95
|
+
isEdit={isEdit}
|
|
96
|
+
module={Modules.EmailAutomation}
|
|
97
|
+
size="md"
|
|
98
|
+
propagateChanges={propagateChanges}
|
|
99
|
+
onSuccess={onSuccess}
|
|
100
|
+
onNavigate={(url) => router.push(url)}
|
|
101
|
+
onSubmit={async (values) => {
|
|
102
|
+
const payload: EmailAutomationInput = {
|
|
103
|
+
id: values.id,
|
|
104
|
+
name: values.name,
|
|
105
|
+
trigger: values.trigger || undefined,
|
|
106
|
+
exitTrigger: values.exitTrigger || undefined,
|
|
107
|
+
replyTo: values.replyTo || undefined,
|
|
108
|
+
isActive: values.isActive,
|
|
109
|
+
};
|
|
110
|
+
return isEdit ? await EmailAutomationService.update(payload) : await EmailAutomationService.create(payload);
|
|
111
|
+
}}
|
|
112
|
+
onReset={getDefaultValues}
|
|
113
|
+
trigger={trigger}
|
|
114
|
+
forceShow={forceShow}
|
|
115
|
+
onClose={onClose}
|
|
116
|
+
dialogOpen={dialogOpen}
|
|
117
|
+
onDialogOpenChange={onDialogOpenChange}
|
|
118
|
+
>
|
|
119
|
+
<div className="flex w-full flex-col gap-y-4">
|
|
120
|
+
<FormInput form={form} id="name" name={t("emailAutomations.fields.name.label")} isRequired />
|
|
121
|
+
<FormSelect
|
|
122
|
+
form={form}
|
|
123
|
+
id="trigger"
|
|
124
|
+
name={t("emailAutomations.fields.trigger.label")}
|
|
125
|
+
placeholder={t("emailAutomations.fields.trigger.none")}
|
|
126
|
+
values={triggerValues}
|
|
127
|
+
allowEmpty
|
|
128
|
+
/>
|
|
129
|
+
<FormSelect
|
|
130
|
+
form={form}
|
|
131
|
+
id="exitTrigger"
|
|
132
|
+
name={t("emailAutomations.fields.exitTrigger.label")}
|
|
133
|
+
placeholder={t("emailAutomations.fields.trigger.none")}
|
|
134
|
+
values={triggerValues}
|
|
135
|
+
allowEmpty
|
|
136
|
+
/>
|
|
137
|
+
<FormInput form={form} id="replyTo" name={t("emailAutomations.fields.replyTo.label")} />
|
|
138
|
+
<FormSwitch
|
|
139
|
+
form={form}
|
|
140
|
+
id="isActive"
|
|
141
|
+
name={t("emailAutomations.fields.isActive.label")}
|
|
142
|
+
description={t("emailAutomations.fields.isActive.help")}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</EditorSheet>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function EmailAutomationEditor(props: EmailAutomationEditorProps) {
|
|
150
|
+
return <EmailAutomationEditorInternal {...props} />;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default EmailAutomationEditor;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode, useCallback, useMemo } from "react";
|
|
6
|
+
import { useForm } from "react-hook-form";
|
|
7
|
+
import { v4 } from "uuid";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { EditorSheet, FormInput, FormTextarea } from "../../../../components";
|
|
10
|
+
import { Modules } from "../../../../core";
|
|
11
|
+
import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
|
|
12
|
+
import { EmailAutomationStepInput, EmailAutomationStepInterface } from "../../data/EmailAutomationStepInterface";
|
|
13
|
+
import { EmailAutomationStepService } from "../../data/EmailAutomationStepService";
|
|
14
|
+
|
|
15
|
+
/** The fixed variable set the renderer fills (spec § "Rendering"). */
|
|
16
|
+
const TEMPLATE_VARIABLES = ["{{name}}", "{{email}}", "{{url}}", "{{unsubscribeUrl}}", "{{unsubscribeAllUrl}}"];
|
|
17
|
+
|
|
18
|
+
export type EmailStepEditorProps = {
|
|
19
|
+
step?: EmailAutomationStepInterface;
|
|
20
|
+
automationId: string;
|
|
21
|
+
/** Canvas position for a step created from the editor. */
|
|
22
|
+
initialPosition?: { x: number; y: number };
|
|
23
|
+
/** The head step's delay counts from entering the automation, not from a previous email. */
|
|
24
|
+
isHead?: boolean;
|
|
25
|
+
trigger?: ReactNode;
|
|
26
|
+
forceShow?: boolean;
|
|
27
|
+
onClose?: () => void;
|
|
28
|
+
dialogOpen?: boolean;
|
|
29
|
+
onDialogOpenChange?: (open: boolean) => void;
|
|
30
|
+
onSuccess?: () => void | Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function EmailStepEditorInternal({
|
|
34
|
+
step,
|
|
35
|
+
automationId,
|
|
36
|
+
initialPosition,
|
|
37
|
+
isHead,
|
|
38
|
+
trigger,
|
|
39
|
+
forceShow,
|
|
40
|
+
onClose,
|
|
41
|
+
dialogOpen,
|
|
42
|
+
onDialogOpenChange,
|
|
43
|
+
onSuccess,
|
|
44
|
+
}: EmailStepEditorProps) {
|
|
45
|
+
const t = useTranslations();
|
|
46
|
+
const { reload } = useEmailAutomation();
|
|
47
|
+
const isEdit = !!step;
|
|
48
|
+
|
|
49
|
+
const formSchema = useMemo(
|
|
50
|
+
() =>
|
|
51
|
+
z.object({
|
|
52
|
+
id: z.uuidv4(),
|
|
53
|
+
subject: z.string().min(1, { message: t("emailAutomations.step.fields.subject.error") }),
|
|
54
|
+
delayHours: z.number().int().min(0),
|
|
55
|
+
body: z.string().min(1, { message: t("emailAutomations.step.fields.body.error") }),
|
|
56
|
+
}),
|
|
57
|
+
[t],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const getDefaultValues = useCallback(
|
|
61
|
+
() => ({
|
|
62
|
+
id: step?.id ?? v4(),
|
|
63
|
+
subject: step?.subject ?? "",
|
|
64
|
+
delayHours: step?.delayHours ?? 24,
|
|
65
|
+
body: step?.body ?? "",
|
|
66
|
+
}),
|
|
67
|
+
[step],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const form = useForm<z.infer<typeof formSchema>>({
|
|
71
|
+
resolver: zodResolver(formSchema),
|
|
72
|
+
defaultValues: getDefaultValues(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<EditorSheet
|
|
77
|
+
form={form}
|
|
78
|
+
entityType={t("emailAutomations.step.title")}
|
|
79
|
+
entityName={step?.subject}
|
|
80
|
+
title={isEdit ? t("emailAutomations.step.editTitle") : t("emailAutomations.step.title")}
|
|
81
|
+
isEdit={isEdit}
|
|
82
|
+
module={Modules.EmailAutomationStep}
|
|
83
|
+
size="lg"
|
|
84
|
+
onSubmit={async (values) => {
|
|
85
|
+
const payload: EmailAutomationStepInput = {
|
|
86
|
+
id: values.id,
|
|
87
|
+
automationId,
|
|
88
|
+
subject: values.subject,
|
|
89
|
+
body: values.body,
|
|
90
|
+
delayHours: values.delayHours,
|
|
91
|
+
positionX: step ? step.positionX : initialPosition?.x,
|
|
92
|
+
positionY: step ? step.positionY : initialPosition?.y,
|
|
93
|
+
};
|
|
94
|
+
return isEdit
|
|
95
|
+
? await EmailAutomationStepService.update(payload)
|
|
96
|
+
: await EmailAutomationStepService.create(payload);
|
|
97
|
+
}}
|
|
98
|
+
// Save → reload the canvas, then whatever the caller adds.
|
|
99
|
+
onSuccess={async () => {
|
|
100
|
+
await reload();
|
|
101
|
+
await onSuccess?.();
|
|
102
|
+
}}
|
|
103
|
+
onReset={getDefaultValues}
|
|
104
|
+
trigger={trigger}
|
|
105
|
+
forceShow={forceShow}
|
|
106
|
+
onClose={onClose}
|
|
107
|
+
dialogOpen={dialogOpen}
|
|
108
|
+
onDialogOpenChange={onDialogOpenChange}
|
|
109
|
+
>
|
|
110
|
+
<div className="flex w-full flex-col gap-y-4">
|
|
111
|
+
<FormInput form={form} id="subject" name={t("emailAutomations.step.fields.subject.label")} isRequired />
|
|
112
|
+
<FormInput
|
|
113
|
+
form={form}
|
|
114
|
+
id="delayHours"
|
|
115
|
+
type="number"
|
|
116
|
+
name={t("emailAutomations.step.fields.delayHours.label")}
|
|
117
|
+
description={
|
|
118
|
+
isHead ? t("emailAutomations.canvas.hoursAfterEntering") : t("emailAutomations.canvas.hoursAfterPrevious")
|
|
119
|
+
}
|
|
120
|
+
/>
|
|
121
|
+
{/* FormTextarea has no `rows` prop; min-h-72 is the height of ~14 rows. */}
|
|
122
|
+
<FormTextarea
|
|
123
|
+
form={form}
|
|
124
|
+
id="body"
|
|
125
|
+
name={t("emailAutomations.step.fields.body.label")}
|
|
126
|
+
className="min-h-72"
|
|
127
|
+
isRequired
|
|
128
|
+
description={
|
|
129
|
+
<>
|
|
130
|
+
<span className="block">
|
|
131
|
+
{t("emailAutomations.step.variablesHint")}{" "}
|
|
132
|
+
{TEMPLATE_VARIABLES.map((variable) => (
|
|
133
|
+
<span key={variable} className="me-2 text-foreground">
|
|
134
|
+
{variable}
|
|
135
|
+
</span>
|
|
136
|
+
))}
|
|
137
|
+
</span>
|
|
138
|
+
<span className="mt-1 block">{t("emailAutomations.step.unsubscribeHint")}</span>
|
|
139
|
+
</>
|
|
140
|
+
}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
</EditorSheet>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function EmailStepEditor(props: EmailStepEditorProps) {
|
|
148
|
+
return <EmailStepEditorInternal {...props} />;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default EmailStepEditor;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from "./containers/EmailAutomationsListContainer";
|
|
2
|
+
export * from "./containers/EmailAutomationContainer";
|
|
3
|
+
export * from "./lists/EmailAutomationsList";
|
|
4
|
+
export * from "./lists/EmailAutomationRecipientsList";
|
|
5
|
+
export * from "./forms/EmailAutomationEditor";
|
|
6
|
+
export * from "./forms/EmailStepEditor";
|
|
7
|
+
export * from "./canvas/EmailAutomationActiveSwitch";
|
|
8
|
+
export * from "./canvas/EmailAutomationCanvasTab";
|
|
9
|
+
export * from "./canvas/EmailAutomationCanvas";
|
|
10
|
+
export * from "./canvas/EmailAutomationCanvasInspector";
|
|
11
|
+
export * from "./canvas/nodes/TriggerNode";
|
|
12
|
+
export * from "./canvas/nodes/EmailStepNode";
|
|
13
|
+
export * from "./canvas/edges/DelayEdge";
|
|
14
|
+
export * from "./canvas/buildCanvasGraph";
|
|
15
|
+
export * from "./unsubscribe/EmailUnsubscribe";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { RefreshCw } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
6
|
+
import { ContentListTable } from "../../../../components";
|
|
7
|
+
import { Modules } from "../../../../core";
|
|
8
|
+
import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
|
|
9
|
+
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../shadcnui";
|
|
10
|
+
import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
|
|
11
|
+
import { EmailAutomationEnrollmentFields } from "../../data/email-automation-enrollment.fields";
|
|
12
|
+
import { EmailAutomationEnrollmentInterface } from "../../data/EmailAutomationEnrollmentInterface";
|
|
13
|
+
import { EmailAutomationService } from "../../data/EmailAutomationService";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read-only list of the people in this automation (owner decision 6: no row
|
|
17
|
+
* actions). Rendered inside a tab, so it keeps its own bordered card (no
|
|
18
|
+
* `fullWidth`), as PricesList does inside ProductContainer.
|
|
19
|
+
*/
|
|
20
|
+
export function EmailAutomationRecipientsList() {
|
|
21
|
+
const t = useTranslations();
|
|
22
|
+
const { automation } = useEmailAutomation();
|
|
23
|
+
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
24
|
+
|
|
25
|
+
const data: DataListRetriever<EmailAutomationEnrollmentInterface> = useDataListRetriever({
|
|
26
|
+
retriever: (params) => EmailAutomationService.findEnrollments(params),
|
|
27
|
+
retrieverParams: { automationId: automation?.id },
|
|
28
|
+
module: Modules.EmailAutomationEnrollment,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const handleStatusChange = (value: string | null) => {
|
|
32
|
+
const status = value ?? "all";
|
|
33
|
+
setStatusFilter(status);
|
|
34
|
+
data.addAdditionalParameter("status", status === "all" ? null : status);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const statusItems = useMemo(
|
|
38
|
+
() => ({
|
|
39
|
+
all: t("emailAutomations.recipients.status.all"),
|
|
40
|
+
active: t("emailAutomations.recipients.status.active"),
|
|
41
|
+
completed: t("emailAutomations.recipients.status.completed"),
|
|
42
|
+
unsubscribed: t("emailAutomations.recipients.status.unsubscribed"),
|
|
43
|
+
exited: t("emailAutomations.recipients.status.exited"),
|
|
44
|
+
failed: t("emailAutomations.recipients.status.failed"),
|
|
45
|
+
}),
|
|
46
|
+
[t],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const filters = (
|
|
50
|
+
<div className="flex items-center gap-4">
|
|
51
|
+
<Select items={statusItems} value={statusFilter} onValueChange={handleStatusChange}>
|
|
52
|
+
<SelectTrigger className="w-40">
|
|
53
|
+
<SelectValue />
|
|
54
|
+
</SelectTrigger>
|
|
55
|
+
<SelectContent>
|
|
56
|
+
<SelectItem value="all">{t("emailAutomations.recipients.status.all")}</SelectItem>
|
|
57
|
+
<SelectItem value="active">{t("emailAutomations.recipients.status.active")}</SelectItem>
|
|
58
|
+
<SelectItem value="completed">{t("emailAutomations.recipients.status.completed")}</SelectItem>
|
|
59
|
+
<SelectItem value="unsubscribed">{t("emailAutomations.recipients.status.unsubscribed")}</SelectItem>
|
|
60
|
+
<SelectItem value="exited">{t("emailAutomations.recipients.status.exited")}</SelectItem>
|
|
61
|
+
<SelectItem value="failed">{t("emailAutomations.recipients.status.failed")}</SelectItem>
|
|
62
|
+
</SelectContent>
|
|
63
|
+
</Select>
|
|
64
|
+
|
|
65
|
+
<Button variant="outline" size="icon" onClick={() => void data.refresh()} disabled={!data.isLoaded}>
|
|
66
|
+
<RefreshCw className={`h-4 w-4 ${!data.isLoaded ? "animate-spin" : ""}`} />
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<ContentListTable
|
|
73
|
+
data={data}
|
|
74
|
+
fields={[
|
|
75
|
+
EmailAutomationEnrollmentFields.email,
|
|
76
|
+
EmailAutomationEnrollmentFields.name,
|
|
77
|
+
EmailAutomationEnrollmentFields.status,
|
|
78
|
+
EmailAutomationEnrollmentFields.currentStep,
|
|
79
|
+
EmailAutomationEnrollmentFields.nextSendAt,
|
|
80
|
+
EmailAutomationEnrollmentFields.sentCount,
|
|
81
|
+
]}
|
|
82
|
+
tableGeneratorType={Modules.EmailAutomationEnrollment}
|
|
83
|
+
filters={filters}
|
|
84
|
+
/* The enrolments endpoint takes no search term, so rendering the box would
|
|
85
|
+
give the page a control that silently does nothing. */
|
|
86
|
+
allowSearch={false}
|
|
87
|
+
emptyState={t("emailAutomations.recipients.empty")}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MailIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode, useEffect, useMemo, useState } 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 { EmailAutomationFields } from "../../data/email-automation.fields";
|
|
11
|
+
import { EmailAutomationInterface } from "../../data/EmailAutomationInterface";
|
|
12
|
+
import { EmailAutomationService } from "../../data/EmailAutomationService";
|
|
13
|
+
import { EmailAutomationTriggerInterface } from "../../data/EmailAutomationTriggerInterface";
|
|
14
|
+
import EmailAutomationEditor from "../forms/EmailAutomationEditor";
|
|
15
|
+
|
|
16
|
+
type EmailAutomationsListProps = {
|
|
17
|
+
/**
|
|
18
|
+
* Pass this whenever the list is the page's own content inside a
|
|
19
|
+
* `RoundPageContainer fullWidth`. Without it ContentListTable wraps itself in
|
|
20
|
+
* `rounded-md border`, drawing a second bordered card inside the page's
|
|
21
|
+
* rounded shell.
|
|
22
|
+
*/
|
|
23
|
+
fullWidth?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function EmailAutomationsList({ fullWidth }: EmailAutomationsListProps = {}) {
|
|
27
|
+
const t = useTranslations();
|
|
28
|
+
const [triggers, setTriggers] = useState<EmailAutomationTriggerInterface[]>([]);
|
|
29
|
+
|
|
30
|
+
const data: DataListRetriever<EmailAutomationInterface> = useDataListRetriever({
|
|
31
|
+
retriever: (params) => EmailAutomationService.findMany(params),
|
|
32
|
+
retrieverParams: {},
|
|
33
|
+
module: Modules.EmailAutomation,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// The trigger column shows the label the app declares, not the stored key.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let active = true;
|
|
39
|
+
EmailAutomationService.findTriggers()
|
|
40
|
+
.then((list) => {
|
|
41
|
+
if (active) setTriggers(list);
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {});
|
|
44
|
+
return () => {
|
|
45
|
+
active = false;
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const triggerLabels = useMemo(
|
|
50
|
+
() => Object.fromEntries(triggers.map((trigger) => [trigger.key, trigger.label])),
|
|
51
|
+
[triggers],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const functions: ReactNode[] = [<EmailAutomationEditor key="create-email-automation" />];
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<ContentListTable
|
|
58
|
+
data={data}
|
|
59
|
+
fields={[
|
|
60
|
+
EmailAutomationFields.name,
|
|
61
|
+
EmailAutomationFields.trigger,
|
|
62
|
+
EmailAutomationFields.status,
|
|
63
|
+
EmailAutomationFields.steps,
|
|
64
|
+
EmailAutomationFields.active,
|
|
65
|
+
EmailAutomationFields.completed,
|
|
66
|
+
EmailAutomationFields.unsubscribed,
|
|
67
|
+
]}
|
|
68
|
+
tableGeneratorType={Modules.EmailAutomation}
|
|
69
|
+
functions={functions}
|
|
70
|
+
fullWidth={fullWidth}
|
|
71
|
+
title={t("emailAutomations.admin.title")}
|
|
72
|
+
context={{ triggerLabels }}
|
|
73
|
+
emptyState={<EmptyState icon={MailIcon} title={t("emailAutomations.admin.empty")} />}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckCircle, Loader2, XCircle } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { Button } from "../../../../shadcnui";
|
|
7
|
+
import { SectionHeader } from "../../../../components/typography";
|
|
8
|
+
import { EmailAutomationEnrollmentService } from "../../data/EmailAutomationEnrollmentService";
|
|
9
|
+
|
|
10
|
+
type UnsubscribeScope = "automation" | "all";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
token: string;
|
|
14
|
+
scope: UnsubscribeScope;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type UnsubscribeState = "loading" | "success" | "error";
|
|
18
|
+
|
|
19
|
+
export function EmailUnsubscribe({ token, scope }: Props) {
|
|
20
|
+
const t = useTranslations();
|
|
21
|
+
const [state, setState] = useState<UnsubscribeState>("loading");
|
|
22
|
+
const [stoppedScope, setStoppedScope] = useState<UnsubscribeScope>(scope);
|
|
23
|
+
const [automationName, setAutomationName] = useState<string>("");
|
|
24
|
+
const [isStoppingAll, setIsStoppingAll] = useState<boolean>(false);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
async function unsubscribe() {
|
|
28
|
+
try {
|
|
29
|
+
const enrollment = await EmailAutomationEnrollmentService.unsubscribe({ token, scope });
|
|
30
|
+
setAutomationName(enrollment?.automation?.name ?? "");
|
|
31
|
+
setStoppedScope(scope);
|
|
32
|
+
setState("success");
|
|
33
|
+
} catch {
|
|
34
|
+
setState("error");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
unsubscribe();
|
|
39
|
+
}, [token, scope]);
|
|
40
|
+
|
|
41
|
+
const stopAll = async () => {
|
|
42
|
+
setIsStoppingAll(true);
|
|
43
|
+
try {
|
|
44
|
+
await EmailAutomationEnrollmentService.unsubscribe({ token, scope: "all" });
|
|
45
|
+
setStoppedScope("all");
|
|
46
|
+
} catch {
|
|
47
|
+
setState("error");
|
|
48
|
+
} finally {
|
|
49
|
+
setIsStoppingAll(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (state === "loading") {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex flex-col items-center justify-center space-y-4 py-16 text-center">
|
|
56
|
+
<Loader2 className="text-primary h-12 w-12 animate-spin" />
|
|
57
|
+
<p className="text-muted-foreground text-sm">{t("emailAutomations.unsubscribe.loading")}</p>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (state === "error") {
|
|
63
|
+
return (
|
|
64
|
+
<div className="flex flex-col items-center justify-center space-y-4 py-16 text-center">
|
|
65
|
+
<div className="bg-destructive/10 rounded-full p-4">
|
|
66
|
+
<XCircle className="text-destructive h-12 w-12" />
|
|
67
|
+
</div>
|
|
68
|
+
<SectionHeader level={2}>{t("emailAutomations.unsubscribe.invalidTitle")}</SectionHeader>
|
|
69
|
+
<p className="text-muted-foreground max-w-md text-sm leading-relaxed">
|
|
70
|
+
{t("emailAutomations.unsubscribe.invalidBody")}
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-col items-center justify-center space-y-4 py-16 text-center">
|
|
78
|
+
<div className="bg-primary/10 rounded-full p-4">
|
|
79
|
+
<CheckCircle className="text-primary h-12 w-12" />
|
|
80
|
+
</div>
|
|
81
|
+
<SectionHeader level={2}>{t("emailAutomations.unsubscribe.title")}</SectionHeader>
|
|
82
|
+
{stoppedScope === "all" ? (
|
|
83
|
+
<p className="text-muted-foreground max-w-md text-sm leading-relaxed">
|
|
84
|
+
{t("emailAutomations.unsubscribe.stoppedAll")}
|
|
85
|
+
</p>
|
|
86
|
+
) : (
|
|
87
|
+
<>
|
|
88
|
+
<p className="text-muted-foreground max-w-md text-sm leading-relaxed">
|
|
89
|
+
{t("emailAutomations.unsubscribe.stoppedAutomation", { name: automationName })}
|
|
90
|
+
</p>
|
|
91
|
+
<Button variant="secondary" onClick={stopAll} disabled={isStoppingAll}>
|
|
92
|
+
{isStoppingAll && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
93
|
+
{t("emailAutomations.unsubscribe.alsoStopAll")}
|
|
94
|
+
</Button>
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { EmailUnsubscribe } from "../EmailUnsubscribe";
|
|
4
|
+
|
|
5
|
+
const { unsubscribe } = vi.hoisted(() => ({
|
|
6
|
+
unsubscribe: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("../../../data/EmailAutomationEnrollmentService", () => ({
|
|
10
|
+
EmailAutomationEnrollmentService: {
|
|
11
|
+
unsubscribe: (params: unknown) => unsubscribe(params),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("EmailUnsubscribe", () => {
|
|
20
|
+
it("stops the automation and offers to stop every marketing email", async () => {
|
|
21
|
+
unsubscribe.mockResolvedValue({ automation: { name: "Welcome" } });
|
|
22
|
+
|
|
23
|
+
render(<EmailUnsubscribe token="tok" scope="automation" />);
|
|
24
|
+
|
|
25
|
+
expect(await screen.findByText("emailAutomations.unsubscribe.stoppedAutomation")).toBeInTheDocument();
|
|
26
|
+
expect(unsubscribe).toHaveBeenCalledWith({ token: "tok", scope: "automation" });
|
|
27
|
+
|
|
28
|
+
fireEvent.click(screen.getByRole("button", { name: "emailAutomations.unsubscribe.alsoStopAll" }));
|
|
29
|
+
|
|
30
|
+
expect(await screen.findByText("emailAutomations.unsubscribe.stoppedAll")).toBeInTheDocument();
|
|
31
|
+
expect(unsubscribe).toHaveBeenLastCalledWith({ token: "tok", scope: "all" });
|
|
32
|
+
expect(screen.queryByRole("button", { name: "emailAutomations.unsubscribe.alsoStopAll" })).not.toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("shows only the stopped-all message when the link already asks for every email", async () => {
|
|
36
|
+
unsubscribe.mockResolvedValue({ automation: { name: "Welcome" } });
|
|
37
|
+
|
|
38
|
+
render(<EmailUnsubscribe token="tok" scope="all" />);
|
|
39
|
+
|
|
40
|
+
expect(await screen.findByText("emailAutomations.unsubscribe.stoppedAll")).toBeInTheDocument();
|
|
41
|
+
expect(unsubscribe).toHaveBeenCalledWith({ token: "tok", scope: "all" });
|
|
42
|
+
expect(screen.queryByRole("button", { name: "emailAutomations.unsubscribe.alsoStopAll" })).not.toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("shows the invalid-link state when the call is rejected", async () => {
|
|
46
|
+
unsubscribe.mockRejectedValue(new Error("Not found"));
|
|
47
|
+
|
|
48
|
+
render(<EmailUnsubscribe token="unknown" scope="automation" />);
|
|
49
|
+
|
|
50
|
+
await waitFor(() => {
|
|
51
|
+
expect(screen.getByText("emailAutomations.unsubscribe.invalidTitle")).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
expect(screen.getByText("emailAutomations.unsubscribe.invalidBody")).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
});
|