@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.
Files changed (134) hide show
  1. package/dist/{BlockNoteEditor-EGY4ZAAM.js → BlockNoteEditor-UJ6363LC.js} +19 -19
  2. package/dist/{BlockNoteEditor-EGY4ZAAM.js.map → BlockNoteEditor-UJ6363LC.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-WTSQANKI.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-EL4JFB6P.js} +3 -3
  5. package/dist/{BlockNoteViewer-I2MMFH3S.js.map → BlockNoteViewer-EL4JFB6P.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-JVKWJZ6J.mjs} +2 -2
  7. package/dist/billing/index.js +441 -441
  8. package/dist/billing/index.mjs +3 -3
  9. package/dist/{chunk-3VEQ5JZJ.js → chunk-3BAM23VA.js} +847 -847
  10. package/dist/chunk-3BAM23VA.js.map +1 -0
  11. package/dist/{chunk-JEZQTZQ4.mjs → chunk-6WU74VFU.mjs} +2 -2
  12. package/dist/{chunk-S5MQDWWC.js → chunk-CRI4T47Y.js} +736 -176
  13. package/dist/chunk-CRI4T47Y.js.map +1 -0
  14. package/dist/{chunk-75MLT74B.js → chunk-EO75CV76.js} +8 -8
  15. package/dist/{chunk-75MLT74B.js.map → chunk-EO75CV76.js.map} +1 -1
  16. package/dist/{chunk-NPKFMRH5.mjs → chunk-FNTQQFLH.mjs} +2 -2
  17. package/dist/{chunk-HNOWSFM5.mjs → chunk-JQWI62NV.mjs} +563 -3
  18. package/dist/chunk-JQWI62NV.mjs.map +1 -0
  19. package/dist/{chunk-2Z3ZCYXX.mjs → chunk-MIZTWBPX.mjs} +5 -5
  20. package/dist/chunk-MIZTWBPX.mjs.map +1 -0
  21. package/dist/{chunk-YMUPTXOT.mjs → chunk-QKZUE5OF.mjs} +2 -2
  22. package/dist/{chunk-EXICQC4A.js → chunk-QQQE3N2X.js} +3 -3
  23. package/dist/{chunk-EXICQC4A.js.map → chunk-QQQE3N2X.js.map} +1 -1
  24. package/dist/{chunk-THKYH65N.js → chunk-XGTQWBDJ.js} +4 -4
  25. package/dist/{chunk-THKYH65N.js.map → chunk-XGTQWBDJ.js.map} +1 -1
  26. package/dist/client/index.d.mts +6 -39
  27. package/dist/client/index.d.ts +6 -39
  28. package/dist/client/index.js +4 -4
  29. package/dist/client/index.mjs +3 -3
  30. package/dist/components/index.d.mts +2 -2
  31. package/dist/components/index.d.ts +2 -2
  32. package/dist/components/index.js +4 -4
  33. package/dist/components/index.mjs +3 -3
  34. package/dist/contexts/index.js +4 -4
  35. package/dist/contexts/index.mjs +3 -3
  36. package/dist/core/index.d.mts +187 -1
  37. package/dist/core/index.d.ts +187 -1
  38. package/dist/core/index.js +30 -2
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/index.mjs +29 -1
  41. package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
  42. package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
  43. package/dist/email-automations/index.css +30 -0
  44. package/dist/email-automations/index.css.map +1 -0
  45. package/dist/email-automations/index.d.mts +303 -0
  46. package/dist/email-automations/index.d.ts +303 -0
  47. package/dist/email-automations/index.js +1705 -0
  48. package/dist/email-automations/index.js.map +1 -0
  49. package/dist/email-automations/index.mjs +1705 -0
  50. package/dist/email-automations/index.mjs.map +1 -0
  51. package/dist/features/analytics/index.js +122 -122
  52. package/dist/features/analytics/index.mjs +4 -4
  53. package/dist/features/analytics/server.js +4 -4
  54. package/dist/features/analytics/server.mjs +2 -2
  55. package/dist/features/help/index.js +37 -37
  56. package/dist/features/help/index.mjs +3 -3
  57. package/dist/features/tokenusage/index.js +125 -125
  58. package/dist/features/tokenusage/index.mjs +4 -4
  59. package/dist/index.d.mts +2 -1
  60. package/dist/index.d.ts +2 -1
  61. package/dist/index.js +31 -3
  62. package/dist/index.js.map +1 -1
  63. package/dist/index.mjs +30 -2
  64. package/dist/server/index.js +3 -3
  65. package/dist/server/index.mjs +2 -2
  66. package/dist/types-70acd-8C.d.mts +39 -0
  67. package/dist/types-l8SFleXi.d.ts +39 -0
  68. package/messages/en.json +117 -0
  69. package/messages/it.json +117 -0
  70. package/package.json +11 -1
  71. package/src/components/forms/FormFieldWrapper.tsx +1 -1
  72. package/src/components/forms/FormTextarea.tsx +2 -1
  73. package/src/core/index.ts +8 -0
  74. package/src/core/registry/ModuleRegistry.ts +5 -0
  75. package/src/email-automations/index.ts +5 -0
  76. package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
  77. package/src/features/email-automation/EmailAutomationModule.ts +30 -0
  78. package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
  79. package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
  80. package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
  81. package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
  82. package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
  83. package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
  84. package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
  85. package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
  86. package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
  87. package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
  88. package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
  89. package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
  90. package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
  91. package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
  92. package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
  93. package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
  94. package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
  95. package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
  96. package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
  97. package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
  98. package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
  99. package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
  100. package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
  101. package/src/features/email-automation/components/index.ts +15 -0
  102. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
  103. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
  104. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
  105. package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
  106. package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
  107. package/src/features/email-automation/data/EmailAutomation.ts +87 -0
  108. package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
  109. package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
  110. package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
  111. package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
  112. package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
  113. package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
  114. package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
  115. package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
  116. package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
  117. package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
  118. package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
  119. package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
  120. package/src/features/email-automation/data/email-automation.fields.ts +9 -0
  121. package/src/features/email-automation/data/index.ts +13 -0
  122. package/src/features/email-automation/hooks/index.ts +2 -0
  123. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
  124. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
  125. package/src/features/email-automation/index.ts +5 -0
  126. package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
  127. package/dist/chunk-3VEQ5JZJ.js.map +0 -1
  128. package/dist/chunk-HNOWSFM5.mjs.map +0 -1
  129. package/dist/chunk-S5MQDWWC.js.map +0 -1
  130. /package/dist/{BlockNoteEditor-2QJVK2HH.mjs.map → BlockNoteEditor-WTSQANKI.mjs.map} +0 -0
  131. /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-JVKWJZ6J.mjs.map} +0 -0
  132. /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-6WU74VFU.mjs.map} +0 -0
  133. /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-FNTQQFLH.mjs.map} +0 -0
  134. /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
+ }
@@ -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
+ });