@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.
Files changed (118) hide show
  1. package/dist/{BlockNoteEditor-UIAX72XW.js → BlockNoteEditor-FAUTV5FW.js} +20 -20
  2. package/dist/{BlockNoteEditor-UIAX72XW.js.map → BlockNoteEditor-FAUTV5FW.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-I3J6YYXK.mjs → BlockNoteEditor-IGUHTERQ.mjs} +5 -5
  4. package/dist/{BlockNoteViewer-HIUAJQRI.mjs → BlockNoteViewer-X4UEVI2A.mjs} +3 -3
  5. package/dist/{BlockNoteViewer-APBOPT4B.js → BlockNoteViewer-XYKFY77F.js} +4 -4
  6. package/dist/{BlockNoteViewer-APBOPT4B.js.map → BlockNoteViewer-XYKFY77F.js.map} +1 -1
  7. package/dist/EmailPurpose-DOsSqM5K.d.mts +3 -0
  8. package/dist/EmailPurpose-DOsSqM5K.d.ts +3 -0
  9. package/dist/billing/index.js +492 -492
  10. package/dist/billing/index.mjs +4 -4
  11. package/dist/{chunk-RT7XFGIN.js → chunk-4OCBNCKH.js} +1041 -999
  12. package/dist/chunk-4OCBNCKH.js.map +1 -0
  13. package/dist/{chunk-4XLDBOSE.js → chunk-A7Y5YW7Q.js} +1 -1
  14. package/dist/{chunk-4XLDBOSE.js.map → chunk-A7Y5YW7Q.js.map} +1 -1
  15. package/dist/{chunk-ZYSNJKOP.js → chunk-DFA566KY.js} +4 -4
  16. package/dist/{chunk-ZYSNJKOP.js.map → chunk-DFA566KY.js.map} +1 -1
  17. package/dist/{chunk-DJXSCARG.js → chunk-I5BQLY7V.js} +9 -9
  18. package/dist/{chunk-DJXSCARG.js.map → chunk-I5BQLY7V.js.map} +1 -1
  19. package/dist/{chunk-RFKSJIQB.mjs → chunk-KMPBSOFB.mjs} +138 -5
  20. package/dist/chunk-KMPBSOFB.mjs.map +1 -0
  21. package/dist/{chunk-UOTHRJD2.mjs → chunk-OKBJEIOP.mjs} +1 -1
  22. package/dist/chunk-OKBJEIOP.mjs.map +1 -0
  23. package/dist/{chunk-PMESKNKL.js → chunk-OO2DBP6L.js} +1106 -973
  24. package/dist/chunk-OO2DBP6L.js.map +1 -0
  25. package/dist/{chunk-RYY7USSW.mjs → chunk-RINMQQZH.mjs} +56 -14
  26. package/dist/{chunk-RYY7USSW.mjs.map → chunk-RINMQQZH.mjs.map} +1 -1
  27. package/dist/{chunk-HAW5PNJG.mjs → chunk-TY5YWBLL.mjs} +3 -3
  28. package/dist/{chunk-OUEYZOLB.mjs → chunk-YVKWACDP.mjs} +2 -2
  29. package/dist/client/index.js +5 -5
  30. package/dist/client/index.mjs +4 -4
  31. package/dist/components/index.d.mts +10 -3
  32. package/dist/components/index.d.ts +10 -3
  33. package/dist/components/index.js +5 -5
  34. package/dist/components/index.mjs +4 -4
  35. package/dist/contexts/index.js +5 -5
  36. package/dist/contexts/index.mjs +4 -4
  37. package/dist/core/index.d.mts +92 -7
  38. package/dist/core/index.d.ts +92 -7
  39. package/dist/core/index.js +7 -3
  40. package/dist/core/index.js.map +1 -1
  41. package/dist/core/index.mjs +6 -2
  42. package/dist/{email-automation-enrollment.fields-BZvF14qx.d.mts → email-automation-enrollment.fields-CPsgxLif.d.mts} +11 -1
  43. package/dist/{email-automation-enrollment.fields-Cp3VMzFq.d.ts → email-automation-enrollment.fields-CVicSamF.d.ts} +11 -1
  44. package/dist/email-automations/index.d.mts +19 -5
  45. package/dist/email-automations/index.d.ts +19 -5
  46. package/dist/email-automations/index.js +356 -129
  47. package/dist/email-automations/index.js.map +1 -1
  48. package/dist/email-automations/index.mjs +306 -79
  49. package/dist/email-automations/index.mjs.map +1 -1
  50. package/dist/features/analytics/index.js +121 -121
  51. package/dist/features/analytics/index.mjs +4 -4
  52. package/dist/features/analytics/server.js +5 -5
  53. package/dist/features/analytics/server.mjs +3 -3
  54. package/dist/features/blog/index.js +151 -151
  55. package/dist/features/blog/index.mjs +4 -4
  56. package/dist/features/blog/server.js +17 -17
  57. package/dist/features/blog/server.mjs +2 -2
  58. package/dist/features/help/index.js +38 -38
  59. package/dist/features/help/index.mjs +4 -4
  60. package/dist/features/tokenusage/index.js +127 -127
  61. package/dist/features/tokenusage/index.mjs +4 -4
  62. package/dist/index.d.mts +4 -3
  63. package/dist/index.d.ts +4 -3
  64. package/dist/index.js +8 -4
  65. package/dist/index.js.map +1 -1
  66. package/dist/index.mjs +7 -3
  67. package/dist/offline/index.js +14 -14
  68. package/dist/offline/index.mjs +1 -1
  69. package/dist/{referral-admin.interface-RVO-ZTJz.d.mts → referral-admin.interface-DTj4LzXx.d.mts} +3 -0
  70. package/dist/{referral-admin.interface-CnytdDaQ.d.ts → referral-admin.interface-DeSCw3Pp.d.ts} +3 -0
  71. package/dist/server/index.js +4 -4
  72. package/dist/server/index.mjs +3 -3
  73. package/messages/en.json +35 -3
  74. package/messages/it.json +35 -3
  75. package/package.json +1 -1
  76. package/src/core/index.ts +1 -0
  77. package/src/core/registry/ModuleRegistry.ts +1 -0
  78. package/src/features/email-automation/EmailAutomationCandidateModule.ts +12 -0
  79. package/src/features/email-automation/EmailAutomationModule.ts +1 -0
  80. package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +74 -9
  81. package/src/features/email-automation/components/forms/EmailAutomationAddPersonDialog.tsx +226 -0
  82. package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +23 -0
  83. package/src/features/email-automation/components/forms/EmailStepEditor.tsx +7 -3
  84. package/src/features/email-automation/components/index.ts +1 -0
  85. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +38 -20
  86. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +1 -0
  87. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +9 -4
  88. package/src/features/email-automation/data/EmailAutomation.ts +9 -0
  89. package/src/features/email-automation/data/EmailAutomationCandidate.ts +66 -0
  90. package/src/features/email-automation/data/EmailAutomationCandidateInterface.ts +19 -0
  91. package/src/features/email-automation/data/EmailAutomationEnrollment.ts +16 -2
  92. package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +7 -0
  93. package/src/features/email-automation/data/EmailAutomationInterface.ts +3 -0
  94. package/src/features/email-automation/data/EmailAutomationService.ts +53 -1
  95. package/src/features/email-automation/data/EmailPurpose.ts +1 -0
  96. package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +70 -0
  97. package/src/features/email-automation/data/email-automation.fields.ts +1 -0
  98. package/src/features/email-automation/data/index.ts +2 -0
  99. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +19 -0
  100. package/src/features/email-automation/index.ts +2 -0
  101. package/src/features/email-blast/EmailBlastModule.ts +1 -0
  102. package/src/features/email-blast/components/forms/EmailBlastEditor.tsx +33 -4
  103. package/src/features/email-blast/components/lists/EmailBlastsList.tsx +1 -0
  104. package/src/features/email-blast/data/EmailBlast.ts +10 -1
  105. package/src/features/email-blast/data/EmailBlastInterface.ts +3 -0
  106. package/src/features/email-blast/data/__tests__/EmailBlast.spec.ts +18 -0
  107. package/src/features/email-blast/data/email-blast.fields.ts +1 -0
  108. package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +15 -0
  109. package/src/features/notification/components/notifications/PushNotificationProvider.tsx +14 -2
  110. package/src/features/notification/components/notifications/__tests__/PushNotificationProvider.spec.tsx +11 -0
  111. package/dist/chunk-PMESKNKL.js.map +0 -1
  112. package/dist/chunk-RFKSJIQB.mjs.map +0 -1
  113. package/dist/chunk-RT7XFGIN.js.map +0 -1
  114. package/dist/chunk-UOTHRJD2.mjs.map +0 -1
  115. /package/dist/{BlockNoteEditor-I3J6YYXK.mjs.map → BlockNoteEditor-IGUHTERQ.mjs.map} +0 -0
  116. /package/dist/{BlockNoteViewer-HIUAJQRI.mjs.map → BlockNoteViewer-X4UEVI2A.mjs.map} +0 -0
  117. /package/dist/{chunk-HAW5PNJG.mjs.map → chunk-TY5YWBLL.mjs.map} +0 -0
  118. /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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "6.15.0",
3
+ "version": "6.17.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
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
+ });
@@ -15,6 +15,7 @@ export const EmailAutomationModule = (factory: ModuleFactory) =>
15
15
  fields: [
16
16
  createJsonApiInclusion("email-automations", [
17
17
  "name",
18
+ "purpose",
18
19
  "trigger",
19
20
  "exitTrigger",
20
21
  "replyTo",
@@ -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. Everything else in the barrel stays real.
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
- <span>{props.name}</span>
39
- {props.allowEmpty && <span data-value="">{props.placeholder}</span>}
40
- {props.values.map((value) => (
41
- <span key={value.id} data-value={value.id}>
42
- {value.text}
43
- </span>
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}}", "{{unsubscribeAllUrl}}"];
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
- {TEMPLATE_VARIABLES.map((variable) => (
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";