@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
package/messages/en.json
CHANGED
|
@@ -1212,7 +1212,8 @@
|
|
|
1212
1212
|
"status": "Status",
|
|
1213
1213
|
"steps": "Emails",
|
|
1214
1214
|
"trigger": "Trigger",
|
|
1215
|
-
"unsubscribed": "Left"
|
|
1215
|
+
"unsubscribed": "Left",
|
|
1216
|
+
"purpose": "Purpose"
|
|
1216
1217
|
},
|
|
1217
1218
|
"status": {
|
|
1218
1219
|
"off": "Off",
|
|
@@ -1236,6 +1237,27 @@
|
|
|
1236
1237
|
"exited": "Exited",
|
|
1237
1238
|
"failed": "Failed",
|
|
1238
1239
|
"unsubscribed": "Unsubscribed"
|
|
1240
|
+
},
|
|
1241
|
+
"addPerson": "Add person",
|
|
1242
|
+
"addPersonNoSteps": "Add an email first.",
|
|
1243
|
+
"addDialog": {
|
|
1244
|
+
"title": "Add a person",
|
|
1245
|
+
"search": "Search by name or email",
|
|
1246
|
+
"empty": "Nobody found.",
|
|
1247
|
+
"kind": {
|
|
1248
|
+
"user": "User",
|
|
1249
|
+
"waitlist": "Waitlist"
|
|
1250
|
+
},
|
|
1251
|
+
"reason": {
|
|
1252
|
+
"already_enrolled": "Already in this automation",
|
|
1253
|
+
"opted_out": "Unsubscribed from marketing",
|
|
1254
|
+
"no_consent": "No marketing consent"
|
|
1255
|
+
},
|
|
1256
|
+
"added": "{email} added",
|
|
1257
|
+
"groups": {
|
|
1258
|
+
"addable": "Can be added",
|
|
1259
|
+
"blocked": "Can't be added"
|
|
1260
|
+
}
|
|
1239
1261
|
}
|
|
1240
1262
|
},
|
|
1241
1263
|
"step": {
|
|
@@ -1283,7 +1305,8 @@
|
|
|
1283
1305
|
"sentAt": "Sent",
|
|
1284
1306
|
"sent": "Sent to",
|
|
1285
1307
|
"failed": "Failed",
|
|
1286
|
-
"unsubscribed": "Unsubscribed"
|
|
1308
|
+
"unsubscribed": "Unsubscribed",
|
|
1309
|
+
"purpose": "Purpose"
|
|
1287
1310
|
},
|
|
1288
1311
|
"status": {
|
|
1289
1312
|
"draft": "Draft",
|
|
@@ -1309,7 +1332,8 @@
|
|
|
1309
1332
|
}
|
|
1310
1333
|
},
|
|
1311
1334
|
"variablesHint": "You can use:",
|
|
1312
|
-
"unsubscribeHint": "Every email ends with a link to stop all marketing emails."
|
|
1335
|
+
"unsubscribeHint": "Every email ends with a link to stop all marketing emails.",
|
|
1336
|
+
"serviceHint": "Service emails go to every user and carry no unsubscribe link."
|
|
1313
1337
|
},
|
|
1314
1338
|
"detail": {
|
|
1315
1339
|
"recipients": "Recipients",
|
|
@@ -2190,5 +2214,13 @@
|
|
|
2190
2214
|
"hint": "Did not receive the email? Check your spam folder or try again later.",
|
|
2191
2215
|
"title": "You are on the list!"
|
|
2192
2216
|
}
|
|
2217
|
+
},
|
|
2218
|
+
"emailPurpose": {
|
|
2219
|
+
"label": "Purpose",
|
|
2220
|
+
"placeholder": "Choose a purpose",
|
|
2221
|
+
"error": "Choose a purpose",
|
|
2222
|
+
"service": "Service",
|
|
2223
|
+
"marketing": "Marketing",
|
|
2224
|
+
"serviceWarning": "Only information about their waitlist spot or account. Anything that promotes the product is Marketing."
|
|
2193
2225
|
}
|
|
2194
2226
|
}
|
package/messages/it.json
CHANGED
|
@@ -1212,7 +1212,8 @@
|
|
|
1212
1212
|
"status": "Stato",
|
|
1213
1213
|
"steps": "Email",
|
|
1214
1214
|
"trigger": "Evento",
|
|
1215
|
-
"unsubscribed": "Disiscritti"
|
|
1215
|
+
"unsubscribed": "Disiscritti",
|
|
1216
|
+
"purpose": "Scopo"
|
|
1216
1217
|
},
|
|
1217
1218
|
"status": {
|
|
1218
1219
|
"off": "Spenta",
|
|
@@ -1236,6 +1237,27 @@
|
|
|
1236
1237
|
"exited": "Usciti",
|
|
1237
1238
|
"failed": "Non riusciti",
|
|
1238
1239
|
"unsubscribed": "Disiscritti"
|
|
1240
|
+
},
|
|
1241
|
+
"addPerson": "Aggiungi persona",
|
|
1242
|
+
"addPersonNoSteps": "Aggiungi prima un'email.",
|
|
1243
|
+
"addDialog": {
|
|
1244
|
+
"title": "Aggiungi una persona",
|
|
1245
|
+
"search": "Cerca per nome o email",
|
|
1246
|
+
"empty": "Nessuno trovato.",
|
|
1247
|
+
"kind": {
|
|
1248
|
+
"user": "Utente",
|
|
1249
|
+
"waitlist": "Lista d'attesa"
|
|
1250
|
+
},
|
|
1251
|
+
"reason": {
|
|
1252
|
+
"already_enrolled": "Già in questa automazione",
|
|
1253
|
+
"opted_out": "Disiscritto dal marketing",
|
|
1254
|
+
"no_consent": "Nessun consenso marketing"
|
|
1255
|
+
},
|
|
1256
|
+
"added": "{email} aggiunto",
|
|
1257
|
+
"groups": {
|
|
1258
|
+
"addable": "Possono essere aggiunti",
|
|
1259
|
+
"blocked": "Non possono essere aggiunti"
|
|
1260
|
+
}
|
|
1239
1261
|
}
|
|
1240
1262
|
},
|
|
1241
1263
|
"step": {
|
|
@@ -1283,7 +1305,8 @@
|
|
|
1283
1305
|
"sentAt": "Inviata",
|
|
1284
1306
|
"sent": "Inviata a",
|
|
1285
1307
|
"failed": "Non riuscite",
|
|
1286
|
-
"unsubscribed": "Disiscritti"
|
|
1308
|
+
"unsubscribed": "Disiscritti",
|
|
1309
|
+
"purpose": "Scopo"
|
|
1287
1310
|
},
|
|
1288
1311
|
"status": {
|
|
1289
1312
|
"draft": "Bozza",
|
|
@@ -1309,7 +1332,8 @@
|
|
|
1309
1332
|
}
|
|
1310
1333
|
},
|
|
1311
1334
|
"variablesHint": "Puoi usare:",
|
|
1312
|
-
"unsubscribeHint": "Ogni email finisce con un link per non ricevere più email di marketing."
|
|
1335
|
+
"unsubscribeHint": "Ogni email finisce con un link per non ricevere più email di marketing.",
|
|
1336
|
+
"serviceHint": "Le email di servizio arrivano a tutti gli utenti e non hanno il link di disiscrizione."
|
|
1313
1337
|
},
|
|
1314
1338
|
"detail": {
|
|
1315
1339
|
"recipients": "Destinatari",
|
|
@@ -2190,5 +2214,13 @@
|
|
|
2190
2214
|
"hint": "Non hai ricevuto l'email? Controlla lo spam o riprova più tardi.",
|
|
2191
2215
|
"title": "Sei in lista!"
|
|
2192
2216
|
}
|
|
2217
|
+
},
|
|
2218
|
+
"emailPurpose": {
|
|
2219
|
+
"label": "Scopo",
|
|
2220
|
+
"placeholder": "Scegli lo scopo",
|
|
2221
|
+
"error": "Scegli lo scopo",
|
|
2222
|
+
"service": "Servizio",
|
|
2223
|
+
"marketing": "Marketing",
|
|
2224
|
+
"serviceWarning": "Solo informazioni sul loro posto in lista d'attesa o sul loro account. Tutto ciò che promuove il prodotto è Marketing."
|
|
2193
2225
|
}
|
|
2194
2226
|
}
|
package/package.json
CHANGED
package/src/core/index.ts
CHANGED
|
@@ -81,6 +81,7 @@ export * from "../features/email-automation/EmailAutomationModule";
|
|
|
81
81
|
export * from "../features/email-automation/EmailAutomationStepModule";
|
|
82
82
|
export * from "../features/email-automation/EmailAutomationEnrollmentModule";
|
|
83
83
|
export * from "../features/email-automation/EmailAutomationTriggerModule";
|
|
84
|
+
export * from "../features/email-automation/EmailAutomationCandidateModule";
|
|
84
85
|
// Email automation data (models, services, interfaces, fields): server-safe, so a
|
|
85
86
|
// server page can wrap EmailAutomationService.findOne in cache(). The client-marked
|
|
86
87
|
// ./email-automations entry carries only components, contexts and hooks.
|
|
@@ -49,6 +49,7 @@ export interface FoundationModuleDefinitions {
|
|
|
49
49
|
EmailAutomationStep: ModuleWithPermissions;
|
|
50
50
|
EmailAutomationEnrollment: ModuleWithPermissions;
|
|
51
51
|
EmailAutomationTrigger: ModuleWithPermissions;
|
|
52
|
+
EmailAutomationCandidate: ModuleWithPermissions;
|
|
52
53
|
// Email blast modules
|
|
53
54
|
EmailBlast: ModuleWithPermissions;
|
|
54
55
|
EmailBlastDelivery: ModuleWithPermissions;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { UserPlusIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailAutomationCandidate } from "./data/EmailAutomationCandidate";
|
|
4
|
+
|
|
5
|
+
export const EmailAutomationCandidateModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
moduleId: "5c3e9a71-2b84-4f06-9d1e-7a4b6c8e0f25",
|
|
8
|
+
pageUrl: undefined,
|
|
9
|
+
name: "email-automation-candidates",
|
|
10
|
+
model: EmailAutomationCandidate,
|
|
11
|
+
icon: UserPlusIcon,
|
|
12
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
1
|
+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
2
2
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
4
4
|
import { configureI18n } from "../../../../i18n";
|
|
@@ -22,31 +22,51 @@ vi.mock("../../data/EmailAutomationService", () => ({
|
|
|
22
22
|
|
|
23
23
|
// Base UI renders a closed Select's options nowhere in the DOM, so FormSelect
|
|
24
24
|
// is replaced by a stub that prints its label, its empty option and its
|
|
25
|
-
// values.
|
|
25
|
+
// values. The stub keeps the real FormFieldWrapper (so the description and the
|
|
26
|
+
// validation error render as in production) and turns each value into a
|
|
27
|
+
// button that sets the field. Everything else in the barrel stays real.
|
|
26
28
|
vi.mock("../../../../components", async () => {
|
|
27
29
|
const actual = await vi.importActual<typeof import("../../../../components")>("../../../../components");
|
|
28
30
|
return {
|
|
29
31
|
...actual,
|
|
30
32
|
FormSelect: (props: {
|
|
33
|
+
form: any;
|
|
31
34
|
id: string;
|
|
32
35
|
name?: string;
|
|
36
|
+
description?: string;
|
|
33
37
|
placeholder?: string;
|
|
34
38
|
allowEmpty?: boolean;
|
|
39
|
+
isRequired?: boolean;
|
|
35
40
|
values: { id: string; text: string }[];
|
|
36
41
|
}) => (
|
|
37
42
|
<div data-testid={`form-select-${props.id}`}>
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
<actual.FormFieldWrapper
|
|
44
|
+
form={props.form}
|
|
45
|
+
name={props.id}
|
|
46
|
+
label={props.name}
|
|
47
|
+
description={props.description}
|
|
48
|
+
isRequired={props.isRequired}
|
|
49
|
+
>
|
|
50
|
+
{(field: any) => (
|
|
51
|
+
<>
|
|
52
|
+
{props.allowEmpty && <span data-value="">{props.placeholder}</span>}
|
|
53
|
+
{props.values.map((value) => (
|
|
54
|
+
<button key={value.id} type="button" data-value={value.id} onClick={() => field.onChange(value.id)}>
|
|
55
|
+
{value.text}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</>
|
|
59
|
+
)}
|
|
60
|
+
</actual.FormFieldWrapper>
|
|
45
61
|
</div>
|
|
46
62
|
),
|
|
47
63
|
};
|
|
48
64
|
});
|
|
49
65
|
|
|
66
|
+
// The sheet is portaled into document.body and its label is not tied to the
|
|
67
|
+
// input, so the name field is found by the field name react-hook-form sets.
|
|
68
|
+
const nameInput = () => document.querySelector<HTMLInputElement>('input[name="name"]')!;
|
|
69
|
+
|
|
50
70
|
const TRIGGERS = [
|
|
51
71
|
{ id: "waitlist-confirmed", key: "waitlist-confirmed", label: "Waitlist confirmed" },
|
|
52
72
|
{ id: "registration-completed", key: "registration-completed", label: "Registration completed" },
|
|
@@ -101,4 +121,49 @@ describe("EmailAutomationEditor", () => {
|
|
|
101
121
|
// The option values are the trigger keys, the visible text the plain label.
|
|
102
122
|
expect(within(triggerSelect).getByText("Waitlist confirmed")).toHaveAttribute("data-value", "waitlist-confirmed");
|
|
103
123
|
});
|
|
124
|
+
it("purpose select is required on create", async () => {
|
|
125
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
126
|
+
|
|
127
|
+
const purposeSelect = await screen.findByTestId("form-select-purpose");
|
|
128
|
+
expect(within(purposeSelect).getByText("emailPurpose.service")).toHaveAttribute("data-value", "service");
|
|
129
|
+
expect(within(purposeSelect).getByText("emailPurpose.marketing")).toHaveAttribute("data-value", "marketing");
|
|
130
|
+
|
|
131
|
+
fireEvent.change(nameInput(), {
|
|
132
|
+
target: { value: "Welcome" },
|
|
133
|
+
});
|
|
134
|
+
fireEvent.click(screen.getByTestId("modal-button-create"));
|
|
135
|
+
|
|
136
|
+
expect(await within(purposeSelect).findByText("emailPurpose.error")).toBeInTheDocument();
|
|
137
|
+
expect(mocks.create).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("service purpose shows the warning", async () => {
|
|
141
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
142
|
+
|
|
143
|
+
const purposeSelect = await screen.findByTestId("form-select-purpose");
|
|
144
|
+
expect(within(purposeSelect).queryByText("emailPurpose.serviceWarning")).not.toBeInTheDocument();
|
|
145
|
+
|
|
146
|
+
fireEvent.click(within(purposeSelect).getByText("emailPurpose.service"));
|
|
147
|
+
expect(await within(purposeSelect).findByText("emailPurpose.serviceWarning")).toBeInTheDocument();
|
|
148
|
+
|
|
149
|
+
fireEvent.click(within(purposeSelect).getByText("emailPurpose.marketing"));
|
|
150
|
+
await waitFor(() =>
|
|
151
|
+
expect(within(purposeSelect).queryByText("emailPurpose.serviceWarning")).not.toBeInTheDocument(),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("payload carries purpose", async () => {
|
|
156
|
+
mocks.create.mockResolvedValue({ id: "a1" });
|
|
157
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
158
|
+
|
|
159
|
+
const purposeSelect = await screen.findByTestId("form-select-purpose");
|
|
160
|
+
fireEvent.change(nameInput(), {
|
|
161
|
+
target: { value: "Welcome" },
|
|
162
|
+
});
|
|
163
|
+
fireEvent.click(within(purposeSelect).getByText("emailPurpose.service"));
|
|
164
|
+
fireEvent.click(screen.getByTestId("modal-button-create"));
|
|
165
|
+
|
|
166
|
+
await waitFor(() => expect(mocks.create).toHaveBeenCalled());
|
|
167
|
+
expect(mocks.create.mock.calls[0][0]).toMatchObject({ name: "Welcome", purpose: "service" });
|
|
168
|
+
});
|
|
104
169
|
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { UserPlusIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { v4 } from "uuid";
|
|
7
|
+
import { errorToast } from "../../../../components";
|
|
8
|
+
import { Modules } from "../../../../core";
|
|
9
|
+
import { DataListRetriever, useDataListRetriever, useDebounce } from "../../../../hooks";
|
|
10
|
+
import {
|
|
11
|
+
Avatar,
|
|
12
|
+
AvatarFallback,
|
|
13
|
+
Badge,
|
|
14
|
+
Button,
|
|
15
|
+
Command,
|
|
16
|
+
CommandEmpty,
|
|
17
|
+
CommandGroup,
|
|
18
|
+
CommandInput,
|
|
19
|
+
CommandItem,
|
|
20
|
+
CommandList,
|
|
21
|
+
Dialog,
|
|
22
|
+
DialogContent,
|
|
23
|
+
DialogHeader,
|
|
24
|
+
DialogTitle,
|
|
25
|
+
DialogTrigger,
|
|
26
|
+
} from "../../../../shadcnui";
|
|
27
|
+
import { getInitials } from "../../../../utils/getInitials";
|
|
28
|
+
import { showToast } from "../../../../utils/toast";
|
|
29
|
+
import { EmailAutomationCandidateInterface } from "../../data/EmailAutomationCandidateInterface";
|
|
30
|
+
import { EmailAutomationService } from "../../data/EmailAutomationService";
|
|
31
|
+
|
|
32
|
+
export type EmailAutomationAddPersonDialogProps = {
|
|
33
|
+
automationId: string;
|
|
34
|
+
disabled: boolean;
|
|
35
|
+
onAdded: () => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* "Add person" on the Recipients tab: searches users and waitlist entries and
|
|
40
|
+
* enrols the picked one. The API decides who is addable and why not; this
|
|
41
|
+
* dialog only shows it (addable rows first, then dimmed rows with the reason).
|
|
42
|
+
*/
|
|
43
|
+
export function EmailAutomationAddPersonDialog({
|
|
44
|
+
automationId,
|
|
45
|
+
disabled,
|
|
46
|
+
onAdded,
|
|
47
|
+
}: EmailAutomationAddPersonDialogProps) {
|
|
48
|
+
const t = useTranslations();
|
|
49
|
+
const [open, setOpen] = useState<boolean>(false);
|
|
50
|
+
const searchTermRef = useRef<string>("");
|
|
51
|
+
const [searchTerm, setSearchTerm] = useState<string>("");
|
|
52
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
53
|
+
|
|
54
|
+
const data: DataListRetriever<EmailAutomationCandidateInterface> = useDataListRetriever({
|
|
55
|
+
ready: open,
|
|
56
|
+
retriever: (params) => EmailAutomationService.findCandidates(params),
|
|
57
|
+
retrieverParams: { automationId },
|
|
58
|
+
module: Modules.EmailAutomationCandidate,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (open) data.setReady(true);
|
|
63
|
+
}, [data, open]);
|
|
64
|
+
|
|
65
|
+
const refreshList = useCallback(
|
|
66
|
+
async (searchedTerm: string) => {
|
|
67
|
+
if (searchedTerm === searchTermRef.current) return;
|
|
68
|
+
searchTermRef.current = searchedTerm;
|
|
69
|
+
await data.search(searchedTerm);
|
|
70
|
+
},
|
|
71
|
+
[data],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const updateSearchTerm = useDebounce(refreshList, 500);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (open) updateSearchTerm(searchTerm);
|
|
78
|
+
}, [open, searchTerm]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => () => updateSearchTerm.cancel(), [updateSearchTerm]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!data.next || !sentinelRef.current) return;
|
|
84
|
+
const observer = new IntersectionObserver(
|
|
85
|
+
(entries) => {
|
|
86
|
+
if (entries[0]?.isIntersecting) data.next?.();
|
|
87
|
+
},
|
|
88
|
+
{ threshold: 0.1, rootMargin: "200px" },
|
|
89
|
+
);
|
|
90
|
+
observer.observe(sentinelRef.current);
|
|
91
|
+
return () => observer.disconnect();
|
|
92
|
+
}, [data.next]);
|
|
93
|
+
|
|
94
|
+
const handleOpenChange = (next: boolean) => {
|
|
95
|
+
setOpen(next);
|
|
96
|
+
if (!next) {
|
|
97
|
+
updateSearchTerm.cancel();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reopening starts from an empty search and re-reads the list: someone added
|
|
102
|
+
// last time is now "already in this automation".
|
|
103
|
+
setSearchTerm("");
|
|
104
|
+
if (data.isLoaded) {
|
|
105
|
+
if (searchTermRef.current !== "") {
|
|
106
|
+
searchTermRef.current = "";
|
|
107
|
+
void data.search("");
|
|
108
|
+
} else {
|
|
109
|
+
void data.refresh();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const add = async (candidate: EmailAutomationCandidateInterface) => {
|
|
115
|
+
if (!candidate.addable) return;
|
|
116
|
+
try {
|
|
117
|
+
await EmailAutomationService.addEnrollment({
|
|
118
|
+
automationId,
|
|
119
|
+
input: { id: v4(), recipientType: candidate.recipientType, recipientId: candidate.recipientId },
|
|
120
|
+
});
|
|
121
|
+
setOpen(false);
|
|
122
|
+
showToast(t("emailAutomations.recipients.addDialog.added", { email: candidate.email }));
|
|
123
|
+
onAdded();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const candidates = data.data ?? [];
|
|
130
|
+
const addable = candidates.filter((candidate) => candidate.addable);
|
|
131
|
+
const blocked = candidates.filter((candidate) => !candidate.addable);
|
|
132
|
+
|
|
133
|
+
const renderCandidate = (candidate: EmailAutomationCandidateInterface) => (
|
|
134
|
+
<CommandItem
|
|
135
|
+
key={candidate.id}
|
|
136
|
+
className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
|
|
137
|
+
value={candidate.id}
|
|
138
|
+
disabled={!candidate.addable}
|
|
139
|
+
data-testid={`email-automation-candidate-${candidate.recipientId}`}
|
|
140
|
+
data-addable={candidate.addable ? "true" : "false"}
|
|
141
|
+
onSelect={() => void add(candidate)}
|
|
142
|
+
>
|
|
143
|
+
<div className="flex min-w-0 flex-1 flex-row items-center px-4 py-1">
|
|
144
|
+
<div className="*:ring-border *:ring-1">
|
|
145
|
+
<Avatar className="h-6 w-6">
|
|
146
|
+
<AvatarFallback>{getInitials(candidate.name || candidate.email)}</AvatarFallback>
|
|
147
|
+
</Avatar>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="ms-5 me-3 flex min-w-0 flex-1 flex-col">
|
|
150
|
+
{candidate.name ? (
|
|
151
|
+
<>
|
|
152
|
+
<div className="truncate text-sm font-medium">{candidate.name}</div>
|
|
153
|
+
<div className="truncate text-xs font-normal">{candidate.email}</div>
|
|
154
|
+
</>
|
|
155
|
+
) : (
|
|
156
|
+
<div className="truncate text-sm font-medium">{candidate.email}</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
{candidate.addable ? (
|
|
160
|
+
<Badge
|
|
161
|
+
variant={candidate.recipientType === "waitlist" ? "softBlue" : "softGray"}
|
|
162
|
+
className="ms-auto shrink-0"
|
|
163
|
+
>
|
|
164
|
+
{t(`emailAutomations.recipients.addDialog.kind.${candidate.recipientType}`)}
|
|
165
|
+
</Badge>
|
|
166
|
+
) : (
|
|
167
|
+
candidate.reason && (
|
|
168
|
+
<Badge
|
|
169
|
+
variant="softAmber"
|
|
170
|
+
className="ms-auto shrink-0"
|
|
171
|
+
data-testid={`email-automation-candidate-reason-${candidate.recipientId}`}
|
|
172
|
+
>
|
|
173
|
+
{t(`emailAutomations.recipients.addDialog.reason.${candidate.reason}`)}
|
|
174
|
+
</Badge>
|
|
175
|
+
)
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</CommandItem>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="flex items-center gap-2">
|
|
183
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
184
|
+
<DialogTrigger
|
|
185
|
+
render={<Button variant="outline" size="sm" data-testid="email-automation-add-person" />}
|
|
186
|
+
disabled={disabled}
|
|
187
|
+
>
|
|
188
|
+
<UserPlusIcon className="h-3.5 w-3.5" />
|
|
189
|
+
{t("emailAutomations.recipients.addPerson")}
|
|
190
|
+
</DialogTrigger>
|
|
191
|
+
<DialogContent className="sm:max-w-2xl" data-testid="email-automation-add-person-dialog">
|
|
192
|
+
<DialogHeader>
|
|
193
|
+
<DialogTitle>{t("emailAutomations.recipients.addDialog.title")}</DialogTitle>
|
|
194
|
+
</DialogHeader>
|
|
195
|
+
<Command shouldFilter={false}>
|
|
196
|
+
<CommandInput
|
|
197
|
+
data-testid="email-automation-add-person-search"
|
|
198
|
+
placeholder={t("emailAutomations.recipients.addDialog.search")}
|
|
199
|
+
value={searchTerm}
|
|
200
|
+
onValueChange={setSearchTerm}
|
|
201
|
+
/>
|
|
202
|
+
<CommandList className="mt-2">
|
|
203
|
+
<CommandEmpty>{t("emailAutomations.recipients.addDialog.empty")}</CommandEmpty>
|
|
204
|
+
{addable.length > 0 && (
|
|
205
|
+
<CommandGroup heading={t("emailAutomations.recipients.addDialog.groups.addable")}>
|
|
206
|
+
{addable.map(renderCandidate)}
|
|
207
|
+
</CommandGroup>
|
|
208
|
+
)}
|
|
209
|
+
{blocked.length > 0 && (
|
|
210
|
+
<CommandGroup heading={t("emailAutomations.recipients.addDialog.groups.blocked")}>
|
|
211
|
+
{blocked.map(renderCandidate)}
|
|
212
|
+
</CommandGroup>
|
|
213
|
+
)}
|
|
214
|
+
{data.next && <div ref={sentinelRef} style={{ height: 1 }} />}
|
|
215
|
+
</CommandList>
|
|
216
|
+
</Command>
|
|
217
|
+
</DialogContent>
|
|
218
|
+
</Dialog>
|
|
219
|
+
{disabled && (
|
|
220
|
+
<span className="text-muted-foreground text-xs font-normal">
|
|
221
|
+
{t("emailAutomations.recipients.addPersonNoSteps")}
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -12,6 +12,7 @@ import { useI18nRouter } from "../../../../i18n";
|
|
|
12
12
|
import { EmailAutomationInput, EmailAutomationInterface } from "../../data/EmailAutomationInterface";
|
|
13
13
|
import { EmailAutomationService } from "../../data/EmailAutomationService";
|
|
14
14
|
import { EmailAutomationTriggerInterface } from "../../data/EmailAutomationTriggerInterface";
|
|
15
|
+
import { EmailPurpose } from "../../data/EmailPurpose";
|
|
15
16
|
|
|
16
17
|
export type EmailAutomationEditorProps = {
|
|
17
18
|
automation?: EmailAutomationInterface;
|
|
@@ -65,6 +66,7 @@ function EmailAutomationEditorInternal({
|
|
|
65
66
|
message: t("emailAutomations.fields.replyTo.error"),
|
|
66
67
|
}),
|
|
67
68
|
isActive: z.boolean(),
|
|
69
|
+
purpose: z.enum(["service", "marketing"], { message: t("emailPurpose.error") }),
|
|
68
70
|
}),
|
|
69
71
|
[t],
|
|
70
72
|
);
|
|
@@ -77,6 +79,8 @@ function EmailAutomationEditorInternal({
|
|
|
77
79
|
exitTrigger: automation?.exitTrigger ?? "",
|
|
78
80
|
replyTo: automation?.replyTo ?? "",
|
|
79
81
|
isActive: automation?.isActive ?? false,
|
|
82
|
+
// No preselected value on create: the form refuses to save without a choice.
|
|
83
|
+
purpose: (automation?.purpose ?? "") as EmailPurpose,
|
|
80
84
|
}),
|
|
81
85
|
[automation],
|
|
82
86
|
);
|
|
@@ -86,6 +90,15 @@ function EmailAutomationEditorInternal({
|
|
|
86
90
|
defaultValues: getDefaultValues(),
|
|
87
91
|
});
|
|
88
92
|
|
|
93
|
+
const purpose = form.watch("purpose");
|
|
94
|
+
const purposeValues = useMemo(
|
|
95
|
+
() => [
|
|
96
|
+
{ id: "service", text: t("emailPurpose.service") },
|
|
97
|
+
{ id: "marketing", text: t("emailPurpose.marketing") },
|
|
98
|
+
],
|
|
99
|
+
[t],
|
|
100
|
+
);
|
|
101
|
+
|
|
89
102
|
return (
|
|
90
103
|
<EditorSheet
|
|
91
104
|
form={form}
|
|
@@ -106,6 +119,7 @@ function EmailAutomationEditorInternal({
|
|
|
106
119
|
exitTrigger: values.exitTrigger || undefined,
|
|
107
120
|
replyTo: values.replyTo || undefined,
|
|
108
121
|
isActive: values.isActive,
|
|
122
|
+
purpose: values.purpose,
|
|
109
123
|
};
|
|
110
124
|
return isEdit ? await EmailAutomationService.update(payload) : await EmailAutomationService.create(payload);
|
|
111
125
|
}}
|
|
@@ -118,6 +132,15 @@ function EmailAutomationEditorInternal({
|
|
|
118
132
|
>
|
|
119
133
|
<div className="flex w-full flex-col gap-y-4">
|
|
120
134
|
<FormInput form={form} id="name" name={t("emailAutomations.fields.name.label")} isRequired />
|
|
135
|
+
<FormSelect
|
|
136
|
+
form={form}
|
|
137
|
+
id="purpose"
|
|
138
|
+
name={t("emailPurpose.label")}
|
|
139
|
+
placeholder={t("emailPurpose.placeholder")}
|
|
140
|
+
values={purposeValues}
|
|
141
|
+
description={purpose === "service" ? t("emailPurpose.serviceWarning") : undefined}
|
|
142
|
+
isRequired
|
|
143
|
+
/>
|
|
121
144
|
<FormSelect
|
|
122
145
|
form={form}
|
|
123
146
|
id="trigger"
|
|
@@ -13,7 +13,9 @@ import { EmailAutomationStepInput, EmailAutomationStepInterface } from "../../da
|
|
|
13
13
|
import { EmailAutomationStepService } from "../../data/EmailAutomationStepService";
|
|
14
14
|
|
|
15
15
|
/** The fixed variable set the renderer fills (spec § "Rendering"). */
|
|
16
|
-
const TEMPLATE_VARIABLES = ["{{name}}", "{{email}}", "{{url}}", "{{unsubscribeUrl}}"
|
|
16
|
+
const TEMPLATE_VARIABLES = ["{{name}}", "{{email}}", "{{url}}", "{{unsubscribeUrl}}"];
|
|
17
|
+
/** Only a Marketing automation fills it: a Service email has no "stop all marketing emails" link. */
|
|
18
|
+
const MARKETING_ONLY_VARIABLES = ["{{unsubscribeAllUrl}}"];
|
|
17
19
|
|
|
18
20
|
export type EmailStepEditorProps = {
|
|
19
21
|
step?: EmailAutomationStepInterface;
|
|
@@ -43,8 +45,10 @@ function EmailStepEditorInternal({
|
|
|
43
45
|
onSuccess,
|
|
44
46
|
}: EmailStepEditorProps) {
|
|
45
47
|
const t = useTranslations();
|
|
46
|
-
const { reload } = useEmailAutomation();
|
|
48
|
+
const { automation, reload } = useEmailAutomation();
|
|
47
49
|
const isEdit = !!step;
|
|
50
|
+
const templateVariables =
|
|
51
|
+
automation?.purpose === "service" ? TEMPLATE_VARIABLES : [...TEMPLATE_VARIABLES, ...MARKETING_ONLY_VARIABLES];
|
|
48
52
|
|
|
49
53
|
const formSchema = useMemo(
|
|
50
54
|
() =>
|
|
@@ -129,7 +133,7 @@ function EmailStepEditorInternal({
|
|
|
129
133
|
<>
|
|
130
134
|
<span className="block">
|
|
131
135
|
{t("emailAutomations.step.variablesHint")}{" "}
|
|
132
|
-
{
|
|
136
|
+
{templateVariables.map((variable) => (
|
|
133
137
|
<span key={variable} className="me-2 text-foreground">
|
|
134
138
|
{variable}
|
|
135
139
|
</span>
|
|
@@ -4,6 +4,7 @@ export * from "./lists/EmailAutomationsList";
|
|
|
4
4
|
export * from "./lists/EmailAutomationRecipientsList";
|
|
5
5
|
export * from "./forms/EmailAutomationEditor";
|
|
6
6
|
export * from "./forms/EmailStepEditor";
|
|
7
|
+
export * from "./forms/EmailAutomationAddPersonDialog";
|
|
7
8
|
export * from "./canvas/EmailAutomationActiveSwitch";
|
|
8
9
|
export * from "./canvas/EmailAutomationCanvasTab";
|
|
9
10
|
export * from "./canvas/EmailAutomationCanvas";
|