@carlonicora/nextjs-jsonapi 5.2.0 → 5.4.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 (237) hide show
  1. package/dist/{BlockNoteEditor-EGY4ZAAM.js → BlockNoteEditor-4YCAIEPT.js} +23 -22
  2. package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
  3. package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
  4. package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
  5. package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
  6. package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
  7. package/dist/{BlockNoteViewer-I2MMFH3S.js.map → BlockNoteViewer-YL3J7J2Y.js.map} +1 -1
  8. package/dist/BlogCategoryFields-DO48vOTE.d.mts +14 -0
  9. package/dist/BlogCategoryFields-DO48vOTE.d.ts +14 -0
  10. package/dist/BlogPostInterface-BB_4wcDR.d.mts +124 -0
  11. package/dist/BlogPostInterface-bWAT1rfR.d.ts +124 -0
  12. package/dist/billing/index.js +441 -441
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/blog-public.types-BbrzsC1c.d.mts +30 -0
  15. package/dist/blog-public.types-BbrzsC1c.d.ts +30 -0
  16. package/dist/{chunk-3VEQ5JZJ.js → chunk-3NLAXVIN.js} +860 -849
  17. package/dist/{chunk-3VEQ5JZJ.js.map → chunk-3NLAXVIN.js.map} +1 -1
  18. package/dist/{chunk-YMUPTXOT.mjs → chunk-3YW35YHQ.mjs} +1 -101
  19. package/dist/chunk-3YW35YHQ.mjs.map +1 -0
  20. package/dist/{chunk-EXICQC4A.js → chunk-5PPG6PL7.js} +3 -3
  21. package/dist/{chunk-EXICQC4A.js.map → chunk-5PPG6PL7.js.map} +1 -1
  22. package/dist/{chunk-NPKFMRH5.mjs → chunk-6DC2WVVB.mjs} +2 -2
  23. package/dist/{chunk-2Z3ZCYXX.mjs → chunk-EUKTBOI2.mjs} +19 -8
  24. package/dist/chunk-EUKTBOI2.mjs.map +1 -0
  25. package/dist/{chunk-THKYH65N.js → chunk-KSYQEKJX.js} +11 -111
  26. package/dist/chunk-KSYQEKJX.js.map +1 -0
  27. package/dist/{chunk-S5MQDWWC.js → chunk-NFBOSPNJ.js} +1451 -176
  28. package/dist/chunk-NFBOSPNJ.js.map +1 -0
  29. package/dist/{chunk-JEZQTZQ4.mjs → chunk-QMYQRPQC.mjs} +2 -2
  30. package/dist/{chunk-HNOWSFM5.mjs → chunk-WC63PJSC.mjs} +1278 -3
  31. package/dist/chunk-WC63PJSC.mjs.map +1 -0
  32. package/dist/chunk-XCHSX3VC.mjs +107 -0
  33. package/dist/chunk-XCHSX3VC.mjs.map +1 -0
  34. package/dist/{chunk-75MLT74B.js → chunk-YNCQSWQW.js} +8 -8
  35. package/dist/{chunk-75MLT74B.js.map → chunk-YNCQSWQW.js.map} +1 -1
  36. package/dist/chunk-YW4ZCEWB.js +107 -0
  37. package/dist/chunk-YW4ZCEWB.js.map +1 -0
  38. package/dist/client/index.d.mts +6 -39
  39. package/dist/client/index.d.ts +6 -39
  40. package/dist/client/index.js +4 -4
  41. package/dist/client/index.mjs +3 -3
  42. package/dist/components/index.d.mts +15 -4
  43. package/dist/components/index.d.ts +15 -4
  44. package/dist/components/index.js +4 -4
  45. package/dist/components/index.mjs +3 -3
  46. package/dist/contexts/index.js +4 -4
  47. package/dist/contexts/index.mjs +3 -3
  48. package/dist/core/index.d.mts +400 -3
  49. package/dist/core/index.d.ts +400 -3
  50. package/dist/core/index.js +64 -2
  51. package/dist/core/index.js.map +1 -1
  52. package/dist/core/index.mjs +63 -1
  53. package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
  54. package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
  55. package/dist/email-automations/index.css +30 -0
  56. package/dist/email-automations/index.css.map +1 -0
  57. package/dist/email-automations/index.d.mts +303 -0
  58. package/dist/email-automations/index.d.ts +303 -0
  59. package/dist/email-automations/index.js +1705 -0
  60. package/dist/email-automations/index.js.map +1 -0
  61. package/dist/email-automations/index.mjs +1705 -0
  62. package/dist/email-automations/index.mjs.map +1 -0
  63. package/dist/features/analytics/index.js +122 -122
  64. package/dist/features/analytics/index.mjs +4 -4
  65. package/dist/features/analytics/server.js +6 -5
  66. package/dist/features/analytics/server.js.map +1 -1
  67. package/dist/features/analytics/server.mjs +4 -3
  68. package/dist/features/analytics/server.mjs.map +1 -1
  69. package/dist/features/blog/index.css +30 -0
  70. package/dist/features/blog/index.css.map +1 -0
  71. package/dist/features/blog/index.d.mts +266 -0
  72. package/dist/features/blog/index.d.ts +266 -0
  73. package/dist/features/blog/index.js +1799 -0
  74. package/dist/features/blog/index.js.map +1 -0
  75. package/dist/features/blog/index.mjs +1799 -0
  76. package/dist/features/blog/index.mjs.map +1 -0
  77. package/dist/features/blog/server.d.mts +33 -0
  78. package/dist/features/blog/server.d.ts +33 -0
  79. package/dist/features/blog/server.js +139 -0
  80. package/dist/features/blog/server.js.map +1 -0
  81. package/dist/features/blog/server.mjs +139 -0
  82. package/dist/features/blog/server.mjs.map +1 -0
  83. package/dist/features/help/index.js +37 -37
  84. package/dist/features/help/index.mjs +3 -3
  85. package/dist/features/tokenusage/index.js +125 -125
  86. package/dist/features/tokenusage/index.mjs +4 -4
  87. package/dist/index.d.mts +5 -2
  88. package/dist/index.d.ts +5 -2
  89. package/dist/index.js +65 -3
  90. package/dist/index.js.map +1 -1
  91. package/dist/index.mjs +64 -2
  92. package/dist/{s3.service-CY-jgOAM.d.ts → s3.service-Bz_K-tjz.d.ts} +11 -0
  93. package/dist/{s3.service-Sri0QP7Z.d.mts → s3.service-D5zNCEW3.d.mts} +11 -0
  94. package/dist/server/index.d.mts +1 -1
  95. package/dist/server/index.d.ts +1 -1
  96. package/dist/server/index.js +8 -6
  97. package/dist/server/index.js.map +1 -1
  98. package/dist/server/index.mjs +12 -10
  99. package/dist/types-70acd-8C.d.mts +39 -0
  100. package/dist/types-l8SFleXi.d.ts +39 -0
  101. package/messages/en.json +210 -0
  102. package/messages/it.json +210 -0
  103. package/package.json +21 -1
  104. package/src/components/editors/BlockNoteEditor.tsx +13 -3
  105. package/src/components/forms/FormBlockNote.tsx +4 -0
  106. package/src/components/forms/FormFieldWrapper.tsx +1 -1
  107. package/src/components/forms/FormTextarea.tsx +2 -1
  108. package/src/components/navigations/ContentTitle.tsx +14 -3
  109. package/src/core/index.ts +12 -0
  110. package/src/core/registry/ModuleRegistry.ts +11 -0
  111. package/src/email-automations/index.ts +5 -0
  112. package/src/features/blog/BlogModules.ts +49 -0
  113. package/src/features/blog/components/admin/BlogAdminContainer.tsx +32 -0
  114. package/src/features/blog/components/admin/BlogCategoriesList.tsx +56 -0
  115. package/src/features/blog/components/admin/BlogCategoryEditor.tsx +230 -0
  116. package/src/features/blog/components/admin/BlogCoverImageField.tsx +123 -0
  117. package/src/features/blog/components/admin/BlogLayoutSetting.tsx +86 -0
  118. package/src/features/blog/components/admin/BlogPostComparison.tsx +54 -0
  119. package/src/features/blog/components/admin/BlogPostContainer.tsx +102 -0
  120. package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
  121. package/src/features/blog/components/admin/BlogPostEditor.tsx +207 -0
  122. package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
  123. package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +110 -0
  124. package/src/features/blog/components/admin/BlogPostTranslationPanel.tsx +136 -0
  125. package/src/features/blog/components/admin/BlogPostsList.tsx +58 -0
  126. package/src/features/blog/components/admin/BlogTranslationStatus.tsx +104 -0
  127. package/src/features/blog/components/admin/__tests__/BlogPostTranslationPanel.spec.tsx +100 -0
  128. package/src/features/blog/components/admin/__tests__/BlogPostsList.spec.tsx +30 -0
  129. package/src/features/blog/components/admin/index.ts +14 -0
  130. package/src/features/blog/components/public/BlogCategoryChips.tsx +59 -0
  131. package/src/features/blog/components/public/BlogList.tsx +21 -0
  132. package/src/features/blog/components/public/BlogListColumn.tsx +53 -0
  133. package/src/features/blog/components/public/BlogListFeatured.tsx +57 -0
  134. package/src/features/blog/components/public/BlogListParts.tsx +74 -0
  135. package/src/features/blog/components/public/BlogListRail.tsx +62 -0
  136. package/src/features/blog/components/public/BlogPostCard.tsx +132 -0
  137. package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
  138. package/src/features/blog/components/public/BlogPostView.tsx +74 -0
  139. package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
  140. package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +173 -0
  141. package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +128 -0
  142. package/src/features/blog/components/public/blogPublicHelpers.ts +30 -0
  143. package/src/features/blog/components/public/index.ts +3 -0
  144. package/src/features/blog/contexts/BlogPostContext.tsx +66 -0
  145. package/src/features/blog/contexts/index.ts +1 -0
  146. package/src/features/blog/data/BlogCategory.ts +93 -0
  147. package/src/features/blog/data/BlogCategoryFields.ts +4 -0
  148. package/src/features/blog/data/BlogCategoryInterface.ts +24 -0
  149. package/src/features/blog/data/BlogCategoryService.ts +54 -0
  150. package/src/features/blog/data/BlogCategoryTranslation.ts +90 -0
  151. package/src/features/blog/data/BlogCategoryTranslationInterface.ts +22 -0
  152. package/src/features/blog/data/BlogCategoryTranslationService.ts +30 -0
  153. package/src/features/blog/data/BlogPost.spec.ts +123 -0
  154. package/src/features/blog/data/BlogPost.ts +159 -0
  155. package/src/features/blog/data/BlogPostFields.ts +7 -0
  156. package/src/features/blog/data/BlogPostInterface.ts +39 -0
  157. package/src/features/blog/data/BlogPostService.ts +82 -0
  158. package/src/features/blog/data/BlogPostTranslation.spec.ts +73 -0
  159. package/src/features/blog/data/BlogPostTranslation.ts +110 -0
  160. package/src/features/blog/data/BlogPostTranslationInterface.ts +27 -0
  161. package/src/features/blog/data/BlogPostTranslationService.ts +30 -0
  162. package/src/features/blog/data/BlogSettings.ts +37 -0
  163. package/src/features/blog/data/BlogSettingsInterface.ts +11 -0
  164. package/src/features/blog/data/BlogSettingsService.ts +24 -0
  165. package/src/features/blog/data/index.ts +17 -0
  166. package/src/features/blog/hooks/index.ts +3 -0
  167. package/src/features/blog/hooks/useBlogCategoryNames.ts +34 -0
  168. package/src/features/blog/hooks/useBlogCategoryTableStructure.tsx +84 -0
  169. package/src/features/blog/hooks/useBlogPostTableStructure.tsx +126 -0
  170. package/src/features/blog/index.ts +15 -0
  171. package/src/features/blog/server/fetchers.ts +120 -0
  172. package/src/features/blog/server/toPublic.spec.ts +91 -0
  173. package/src/features/blog/server/toPublic.ts +36 -0
  174. package/src/features/blog/server-entry.ts +17 -0
  175. package/src/features/blog/types/blog-public.types.ts +34 -0
  176. package/src/features/blog/types/blog.constants.ts +40 -0
  177. package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
  178. package/src/features/email-automation/EmailAutomationModule.ts +30 -0
  179. package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
  180. package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
  181. package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
  182. package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
  183. package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
  184. package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
  185. package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
  186. package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
  187. package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
  188. package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
  189. package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
  190. package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
  191. package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
  192. package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
  193. package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
  194. package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
  195. package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
  196. package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
  197. package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
  198. package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
  199. package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
  200. package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
  201. package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
  202. package/src/features/email-automation/components/index.ts +15 -0
  203. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
  204. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
  205. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
  206. package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
  207. package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
  208. package/src/features/email-automation/data/EmailAutomation.ts +87 -0
  209. package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
  210. package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
  211. package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
  212. package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
  213. package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
  214. package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
  215. package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
  216. package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
  217. package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
  218. package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
  219. package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
  220. package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
  221. package/src/features/email-automation/data/email-automation.fields.ts +9 -0
  222. package/src/features/email-automation/data/index.ts +13 -0
  223. package/src/features/email-automation/hooks/index.ts +2 -0
  224. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
  225. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
  226. package/src/features/email-automation/index.ts +5 -0
  227. package/src/features/s3/data/s3.service.ts +17 -0
  228. package/dist/BlockNoteEditor-2QJVK2HH.mjs.map +0 -1
  229. package/dist/BlockNoteEditor-EGY4ZAAM.js.map +0 -1
  230. package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
  231. package/dist/chunk-HNOWSFM5.mjs.map +0 -1
  232. package/dist/chunk-S5MQDWWC.js.map +0 -1
  233. package/dist/chunk-THKYH65N.js.map +0 -1
  234. package/dist/chunk-YMUPTXOT.mjs.map +0 -1
  235. /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-AEJKJ27Q.mjs.map} +0 -0
  236. /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
  237. /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-QMYQRPQC.mjs.map} +0 -0
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useState } from "react";
5
+ import { errorToast } from "../../../../components";
6
+ import { Switch, Tooltip, TooltipContent, TooltipTrigger } from "../../../../shadcnui";
7
+ import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
8
+ import { EmailAutomationService } from "../../data/EmailAutomationService";
9
+
10
+ /**
11
+ * Inline on/off for the automation page header, so pausing never needs the
12
+ * editor sheet. Off holds every enrolment in place (spec § "Sending" 3).
13
+ */
14
+ export function EmailAutomationActiveSwitch() {
15
+ const t = useTranslations();
16
+ const { automation, reload } = useEmailAutomation();
17
+ const [saving, setSaving] = useState<boolean>(false);
18
+
19
+ if (!automation) return null;
20
+
21
+ const toggle = async (checked: boolean) => {
22
+ setSaving(true);
23
+ try {
24
+ await EmailAutomationService.update({
25
+ id: automation.id,
26
+ name: automation.name,
27
+ trigger: automation.trigger,
28
+ exitTrigger: automation.exitTrigger,
29
+ replyTo: automation.replyTo,
30
+ isActive: checked,
31
+ });
32
+ await reload();
33
+ } catch (error) {
34
+ errorToast({ title: t("common.errors.error"), error });
35
+ } finally {
36
+ setSaving(false);
37
+ }
38
+ };
39
+
40
+ return (
41
+ <Tooltip>
42
+ <TooltipTrigger render={<label className="flex items-center gap-x-2" />}>
43
+ <Switch checked={automation.isActive} onCheckedChange={(checked) => void toggle(checked)} disabled={saving} />
44
+ <span className="text-xs">{t("emailAutomations.fields.isActive.label")}</span>
45
+ </TooltipTrigger>
46
+ <TooltipContent className="max-w-xs">{t("emailAutomations.fields.isActive.help")}</TooltipContent>
47
+ </Tooltip>
48
+ );
49
+ }
@@ -0,0 +1,474 @@
1
+ "use client";
2
+
3
+ import {
4
+ Background,
5
+ Connection,
6
+ Edge,
7
+ EdgeChange,
8
+ FinalConnectionState,
9
+ Node,
10
+ NodeChange,
11
+ OnConnectStart,
12
+ OnNodeDrag,
13
+ ReactFlow,
14
+ applyEdgeChanges,
15
+ applyNodeChanges,
16
+ useReactFlow,
17
+ } from "@xyflow/react";
18
+ import "@xyflow/react/dist/style.css";
19
+ import { PlusIcon, Trash2Icon } from "lucide-react";
20
+ import { useTranslations } from "next-intl";
21
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
+ import { v4 as uuidv4 } from "uuid";
23
+ import { errorToast } from "../../../../components/errors";
24
+ import { ConfirmDialog, ContextMenu, ContextMenuContent, ContextMenuItem } from "../../../../shadcnui";
25
+ import { useIsMobile } from "../../../../utils/use-mobile";
26
+ import { showError } from "../../../../utils/toast";
27
+ import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
28
+ import { EmailAutomationEditor } from "../forms/EmailAutomationEditor";
29
+ import {
30
+ STEP_NODE_HEIGHT,
31
+ STEP_NODE_PREFIX,
32
+ STEP_NODE_WIDTH,
33
+ STEP_SOURCE_HANDLE_PREFIX,
34
+ STEP_TARGET_HANDLE_PREFIX,
35
+ TRIGGER_NODE_ID,
36
+ TRIGGER_SOURCE_HANDLE,
37
+ buildCanvasGraph,
38
+ } from "./buildCanvasGraph";
39
+ import { DelayEdge } from "./edges/DelayEdge";
40
+ import { EmailAutomationCanvasInspector, useUserClosedGuard } from "./EmailAutomationCanvasInspector";
41
+ import { EmailStepNode } from "./nodes/EmailStepNode";
42
+ import { TriggerNode } from "./nodes/TriggerNode";
43
+ import { CanvasToolbar, EmptyCanvas, createLongPressTracker } from "./shared";
44
+
45
+ const DEFAULT_DELAY_HOURS = 24;
46
+
47
+ type PendingPaneMenu = {
48
+ flowX: number;
49
+ flowY: number;
50
+ screenX: number;
51
+ screenY: number;
52
+ };
53
+
54
+ type PendingNodeMenu = {
55
+ stepId: string;
56
+ screenX: number;
57
+ screenY: number;
58
+ };
59
+
60
+ const pointAnchor = (screenX: number, screenY: number) => ({
61
+ getBoundingClientRect: () =>
62
+ ({
63
+ x: screenX,
64
+ y: screenY,
65
+ top: screenY,
66
+ left: screenX,
67
+ right: screenX,
68
+ bottom: screenY,
69
+ width: 0,
70
+ height: 0,
71
+ toJSON: () => ({}),
72
+ }) as DOMRect,
73
+ });
74
+
75
+ const stepIdOf = (nodeId: string | null | undefined): string | null =>
76
+ nodeId?.startsWith(STEP_NODE_PREFIX) ? nodeId.slice(STEP_NODE_PREFIX.length) : null;
77
+
78
+ export function EmailAutomationCanvas() {
79
+ const { automation, steps, triggers, select, reload, createStep, deleteStep, setNext, updatePosition, tidy } =
80
+ useEmailAutomation();
81
+ const t = useTranslations();
82
+ // Phone-only: a looser zoom-out floor so "Fit to view" can show the whole
83
+ // graph. Desktop keeps React Flow's defaults untouched.
84
+ const isMobile = useIsMobile();
85
+
86
+ const [nodes, setNodes] = useState<Node[]>([]);
87
+ const [edges, setEdges] = useState<Edge[]>([]);
88
+ const { screenToFlowPosition } = useReactFlow();
89
+ const connectingFromRef = useRef<{ nodeId: string | null; handleId: string | null } | null>(null);
90
+ const [paneMenu, setPaneMenu] = useState<PendingPaneMenu | null>(null);
91
+ const [nodeMenu, setNodeMenu] = useState<PendingNodeMenu | null>(null);
92
+ const [pendingDeleteStepId, setPendingDeleteStepId] = useState<string | null>(null);
93
+ const [automationEditorOpen, setAutomationEditorOpen] = useState<boolean>(false);
94
+
95
+ const triggerLabel = useMemo(
96
+ () =>
97
+ triggers.find((trigger) => trigger.key === automation?.trigger)?.label ?? t("emailAutomations.canvas.noTrigger"),
98
+ [triggers, automation?.trigger, t],
99
+ );
100
+
101
+ const { laidNodes, laidEdges } = useMemo(() => {
102
+ const graph = buildCanvasGraph(steps, triggerLabel);
103
+ return { laidNodes: graph.nodes, laidEdges: graph.edges };
104
+ }, [steps, triggerLabel]);
105
+
106
+ useEffect(() => {
107
+ setNodes(laidNodes);
108
+ setEdges(laidEdges);
109
+ }, [laidNodes, laidEdges]);
110
+
111
+ const onNodesChange = useCallback((changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), []);
112
+ const onEdgesChange = useCallback((changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)), []);
113
+
114
+ // The trigger has no stored position: only steps are persisted.
115
+ const onNodeDragStop: OnNodeDrag = useCallback(
116
+ (_e, node) => {
117
+ const stepId = stepIdOf(node.id);
118
+ if (!stepId) return;
119
+ void updatePosition(stepId, node.position.x, node.position.y);
120
+ },
121
+ [updatePosition],
122
+ );
123
+
124
+ const onConnectStart: OnConnectStart = useCallback((_e, params) => {
125
+ connectingFromRef.current = { nodeId: params.nodeId ?? null, handleId: params.handleId ?? null };
126
+ }, []);
127
+
128
+ /** A rejected link (self-link, loop, step already linked) reloads the true chain. */
129
+ const linkSteps = useCallback(
130
+ async (stepId: string, nextStepId: string) => {
131
+ try {
132
+ await setNext(stepId, nextStepId);
133
+ } catch (error) {
134
+ errorToast({ title: t("emailAutomations.canvas.chainError"), error });
135
+ await reload();
136
+ }
137
+ },
138
+ [setNext, reload, t],
139
+ );
140
+
141
+ // onConnect fires only when React Flow finds a valid target handle.
142
+ const onConnect = useCallback(
143
+ async (conn: Connection) => {
144
+ const from = connectingFromRef.current;
145
+ connectingFromRef.current = null;
146
+ if (!from || !from.handleId) return;
147
+ if (!conn.targetHandle?.startsWith(STEP_TARGET_HANDLE_PREFIX)) return;
148
+ const targetStepId = conn.targetHandle.slice(STEP_TARGET_HANDLE_PREFIX.length);
149
+
150
+ // The trigger→head link is not stored: the API derives the head as the
151
+ // oldest step with no incoming NEXT. A drag from the trigger onto a step
152
+ // therefore cannot be saved, so it is refused with the chain error and
153
+ // nothing changes.
154
+ if (from.handleId === TRIGGER_SOURCE_HANDLE) {
155
+ showError(t("emailAutomations.canvas.chainError"));
156
+ return;
157
+ }
158
+
159
+ if (!from.handleId.startsWith(STEP_SOURCE_HANDLE_PREFIX)) return;
160
+ const sourceStepId = from.handleId.slice(STEP_SOURCE_HANDLE_PREFIX.length);
161
+ await linkSteps(sourceStepId, targetStepId);
162
+ },
163
+ [linkSteps, t],
164
+ );
165
+
166
+ /** Creates a step centred on a flow point and returns its id. */
167
+ const createStepAt = useCallback(
168
+ async (flowX: number, flowY: number): Promise<string | null> => {
169
+ if (!automation) return null;
170
+ const id = uuidv4();
171
+ await createStep({
172
+ id,
173
+ automationId: automation.id,
174
+ subject: t("emailAutomations.step.title"),
175
+ body: "",
176
+ delayHours: DEFAULT_DELAY_HOURS,
177
+ positionX: flowX - STEP_NODE_WIDTH / 2,
178
+ positionY: flowY - STEP_NODE_HEIGHT / 2,
179
+ });
180
+ return id;
181
+ },
182
+ [automation, createStep, t],
183
+ );
184
+
185
+ // onConnectEnd fires for EVERY drag end. If onConnect already cleared the
186
+ // ref, it handled the case. Otherwise a drop on empty canvas creates a new
187
+ // step there: from a step it is linked as that step's next; from the
188
+ // trigger it is simply the first step when none existed, otherwise an
189
+ // orphan (the trigger→head link is derived, never stored).
190
+ const onConnectEnd = useCallback(
191
+ async (event: MouseEvent | TouchEvent, connectionState: FinalConnectionState) => {
192
+ const from = connectingFromRef.current;
193
+ if (!from || !from.handleId || !automation) return;
194
+ connectingFromRef.current = null;
195
+ // Dropped on a node but not on its handle: not an empty-canvas drop.
196
+ if (connectionState.toNode) return;
197
+
198
+ const clientX =
199
+ "clientX" in event ? (event as MouseEvent).clientX : ((event as TouchEvent).changedTouches[0]?.clientX ?? 0);
200
+ const clientY =
201
+ "clientY" in event ? (event as MouseEvent).clientY : ((event as TouchEvent).changedTouches[0]?.clientY ?? 0);
202
+ const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
203
+
204
+ const sourceStepId = from.handleId.startsWith(STEP_SOURCE_HANDLE_PREFIX)
205
+ ? from.handleId.slice(STEP_SOURCE_HANDLE_PREFIX.length)
206
+ : null;
207
+ if (!sourceStepId && from.handleId !== TRIGGER_SOURCE_HANDLE) return;
208
+
209
+ const newStepId = await createStepAt(flowPos.x, flowPos.y);
210
+ if (!newStepId) return;
211
+ if (sourceStepId) await linkSteps(sourceStepId, newStepId);
212
+ select(newStepId);
213
+ },
214
+ [automation, screenToFlowPosition, createStepAt, linkSteps, select],
215
+ );
216
+
217
+ // --- Pane context menu ---
218
+
219
+ const handlePaneContextMenu = useCallback(
220
+ (event: React.MouseEvent | MouseEvent) => {
221
+ if (!automation) return;
222
+ event.preventDefault();
223
+ const clientX = (event as MouseEvent).clientX;
224
+ const clientY = (event as MouseEvent).clientY;
225
+ const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
226
+ setPaneMenu({
227
+ flowX: flowPos.x,
228
+ flowY: flowPos.y,
229
+ screenX: clientX,
230
+ screenY: clientY,
231
+ });
232
+ },
233
+ [automation, screenToFlowPosition],
234
+ );
235
+
236
+ const handleCreateStepFromPane = useCallback(async () => {
237
+ const menu = paneMenu;
238
+ if (!menu) return;
239
+ setPaneMenu(null);
240
+ const newStepId = await createStepAt(menu.flowX, menu.flowY);
241
+ if (newStepId) select(newStepId);
242
+ }, [paneMenu, createStepAt, select]);
243
+
244
+ // --- Node context menu ---
245
+ //
246
+ // Rendered through the same portal-based ContextMenu + fixed-anchor
247
+ // technique as the pane menu: Base UI menus do NOT work when mounted inside
248
+ // a React Flow node, so the menu lives outside the canvas and is anchored
249
+ // to the click coordinates. The trigger node has no menu.
250
+ const openNodeMenu = useCallback((nodeId: string | undefined, screenX: number, screenY: number) => {
251
+ const stepId = stepIdOf(nodeId);
252
+ if (!stepId) return;
253
+ setNodeMenu({ stepId, screenX, screenY });
254
+ }, []);
255
+
256
+ const handleNodeContextMenu = useCallback(
257
+ (event: React.MouseEvent, node: Node) => {
258
+ event.preventDefault();
259
+ openNodeMenu(node.id, event.clientX, event.clientY);
260
+ },
261
+ [openNodeMenu],
262
+ );
263
+
264
+ // --- Touch: long-press a node opens the same node menu ---
265
+ //
266
+ // Touch screens have no right-click. A ~500ms finger hold on a step node
267
+ // opens the menu at the finger; moving the finger (pan / node drag) cancels
268
+ // it, and the click produced by lifting the finger is swallowed so the hold
269
+ // does not also open the step editor.
270
+ const openNodeMenuRef = useRef(openNodeMenu);
271
+ openNodeMenuRef.current = openNodeMenu;
272
+ const longPress = useMemo(
273
+ () =>
274
+ createLongPressTracker({
275
+ onLongPress: ({ target, clientX, clientY }) => {
276
+ openNodeMenuRef.current(target, clientX, clientY);
277
+ },
278
+ }),
279
+ [],
280
+ );
281
+ useEffect(() => () => longPress.cancel(), [longPress]);
282
+
283
+ const handleTouchPointerDown = useCallback(
284
+ (event: React.PointerEvent) => {
285
+ if (event.pointerType !== "touch") return;
286
+ const el = event.target as Element | null;
287
+ // A press on a connection handle starts a link drag, never a menu.
288
+ if (!el || el.closest(".react-flow__handle")) return;
289
+ const nodeId = el.closest(".react-flow__node")?.getAttribute("data-id");
290
+ if (!stepIdOf(nodeId)) return;
291
+ longPress.down(nodeId!, event.clientX, event.clientY);
292
+ },
293
+ [longPress],
294
+ );
295
+
296
+ const handleNodeClick = useCallback(
297
+ (_event: React.MouseEvent, node: Node) => {
298
+ if (node.id === TRIGGER_NODE_ID) {
299
+ setAutomationEditorOpen(true);
300
+ return;
301
+ }
302
+ const stepId = stepIdOf(node.id);
303
+ if (stepId) select(stepId);
304
+ },
305
+ [select],
306
+ );
307
+
308
+ const handleDeleteStep = useCallback(async () => {
309
+ const stepId = pendingDeleteStepId;
310
+ if (!stepId) return;
311
+ try {
312
+ await deleteStep(stepId);
313
+ } catch (error) {
314
+ errorToast({ title: t("emailAutomations.canvas.deleteEmail"), error });
315
+ throw error; // keeps the dialog open — ConfirmDialog swallows rejections
316
+ }
317
+ }, [pendingDeleteStepId, deleteStep, t]);
318
+
319
+ const nodeTypes = useMemo(() => ({ triggerNode: TriggerNode, emailStepNode: EmailStepNode }), []);
320
+ const edgeTypes = useMemo(() => ({ delayEdge: DelayEdge }), []);
321
+
322
+ return (
323
+ <>
324
+ <ReactFlow
325
+ nodes={nodes}
326
+ edges={edges}
327
+ onNodesChange={onNodesChange}
328
+ onEdgesChange={onEdgesChange}
329
+ onNodeDragStop={onNodeDragStop}
330
+ onConnectStart={onConnectStart}
331
+ onConnect={onConnect}
332
+ onConnectEnd={onConnectEnd}
333
+ onPaneContextMenu={handlePaneContextMenu}
334
+ onNodeContextMenu={handleNodeContextMenu}
335
+ onNodeClick={handleNodeClick}
336
+ nodeTypes={nodeTypes}
337
+ edgeTypes={edgeTypes}
338
+ // Built-in React Flow click handler — independent of EdgeLabelRenderer
339
+ // pointer-events. The label button calls `select` too; either path
340
+ // produces the same selection state.
341
+ onEdgeClick={(_event, edge) => {
342
+ const targetStepId = (edge.data as { targetStepId?: string } | undefined)?.targetStepId;
343
+ if (targetStepId) select(targetStepId);
344
+ }}
345
+ fitView
346
+ // Never zoom past 100% on fit: with no emails the trigger is the only
347
+ // node, and the default ceiling (2x) blows it up over the empty state.
348
+ fitViewOptions={{ maxZoom: 1 }}
349
+ // React Flow's default floor is 0.5, which on a phone stops "Fit to
350
+ // view" before the graph fits. undefined keeps the desktop default.
351
+ minZoom={isMobile ? 0.15 : undefined}
352
+ onPointerDownCapture={handleTouchPointerDown}
353
+ onPointerMoveCapture={(event) => {
354
+ if (event.pointerType === "touch") longPress.move(event.clientX, event.clientY);
355
+ }}
356
+ onPointerUpCapture={() => longPress.up()}
357
+ onPointerCancelCapture={() => longPress.cancel()}
358
+ onClickCapture={(event) => {
359
+ if (!longPress.consumeClick()) return;
360
+ event.preventDefault();
361
+ event.stopPropagation();
362
+ }}
363
+ proOptions={{ hideAttribution: true }}
364
+ >
365
+ <Background gap={16} size={1} />
366
+ </ReactFlow>
367
+ {steps.length === 0 && (
368
+ // The canvas stays mounted under the empty state: the first email is
369
+ // added by right-clicking it or dragging from the trigger.
370
+ // Lower half only, so the text sits below the centred trigger node.
371
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
372
+ <EmptyCanvas>
373
+ <div className="flex flex-col gap-1">
374
+ <span className="font-medium">{t("emailAutomations.canvas.emptyTitle")}</span>
375
+ <span className="text-xs/relaxed">{t("emailAutomations.canvas.emptyBody")}</span>
376
+ </div>
377
+ </EmptyCanvas>
378
+ </div>
379
+ )}
380
+ <CanvasToolbar onTidy={tidy} />
381
+ <EmailAutomationCanvasInspector />
382
+ {automationEditorOpen && automation && (
383
+ <TriggerAutomationEditor
384
+ automation={automation}
385
+ onSaved={reload}
386
+ onClose={() => setAutomationEditorOpen(false)}
387
+ />
388
+ )}
389
+ <ConfirmDialog
390
+ open={pendingDeleteStepId !== null}
391
+ onOpenChange={(open) => {
392
+ if (!open) setPendingDeleteStepId(null);
393
+ }}
394
+ title={t("emailAutomations.canvas.deleteEmail")}
395
+ description={t("emailAutomations.canvas.deleteEmailConfirm")}
396
+ confirmLabel={t("emailAutomations.canvas.deleteEmail")}
397
+ cancelLabel={t("ui.buttons.cancel")}
398
+ destructive
399
+ onConfirm={handleDeleteStep}
400
+ />
401
+ {paneMenu && (
402
+ <ContextMenu
403
+ open={true}
404
+ onOpenChange={(open) => {
405
+ if (!open) setPaneMenu(null);
406
+ }}
407
+ >
408
+ <ContextMenuContent
409
+ anchor={pointAnchor(paneMenu.screenX, paneMenu.screenY)}
410
+ side="bottom"
411
+ align="start"
412
+ sideOffset={4}
413
+ >
414
+ <ContextMenuItem onClick={handleCreateStepFromPane}>
415
+ <PlusIcon />
416
+ {t("emailAutomations.canvas.addEmail")}
417
+ </ContextMenuItem>
418
+ </ContextMenuContent>
419
+ </ContextMenu>
420
+ )}
421
+ {nodeMenu && (
422
+ <ContextMenu
423
+ open={true}
424
+ onOpenChange={(open) => {
425
+ if (!open) setNodeMenu(null);
426
+ }}
427
+ >
428
+ <ContextMenuContent
429
+ anchor={pointAnchor(nodeMenu.screenX, nodeMenu.screenY)}
430
+ side="bottom"
431
+ align="start"
432
+ sideOffset={4}
433
+ >
434
+ <ContextMenuItem
435
+ onClick={() => {
436
+ const menu = nodeMenu;
437
+ setNodeMenu(null);
438
+ if (menu) setPendingDeleteStepId(menu.stepId);
439
+ }}
440
+ >
441
+ <Trash2Icon />
442
+ {t("emailAutomations.canvas.deleteEmail")}
443
+ </ContextMenuItem>
444
+ </ContextMenuContent>
445
+ </ContextMenu>
446
+ )}
447
+ </>
448
+ );
449
+ }
450
+
451
+ /**
452
+ * The trigger node's click opens the automation's own EditorSheet, controlled
453
+ * the same way the inspector controls the step editor.
454
+ */
455
+ function TriggerAutomationEditor(props: {
456
+ automation: NonNullable<ReturnType<typeof useEmailAutomation>["automation"]>;
457
+ onSaved: () => Promise<void> | void;
458
+ onClose: () => void;
459
+ }) {
460
+ const onDialogOpenChange = useUserClosedGuard(props.onClose);
461
+
462
+ return (
463
+ <EmailAutomationEditor
464
+ automation={props.automation}
465
+ forceShow
466
+ dialogOpen
467
+ onDialogOpenChange={onDialogOpenChange}
468
+ onSuccess={async () => {
469
+ await props.onSaved();
470
+ props.onClose();
471
+ }}
472
+ />
473
+ );
474
+ }
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useRef } from "react";
4
+ import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
5
+ import { EmailStepEditor } from "../forms/EmailStepEditor";
6
+ import { findHeadStepId } from "./buildCanvasGraph";
7
+
8
+ /**
9
+ * Mounts EmailStepEditor (the EditorSheet the step uses everywhere) on top of
10
+ * the canvas, driven by `selectedStepId` from EmailAutomationContext. Copy of
11
+ * the app's ScenarioCanvasInspector with one branch.
12
+ *
13
+ * - We control open state via `forceShow + dialogOpen + onDialogOpenChange`.
14
+ * - On `onSuccess` we reload the canvas.
15
+ * - The step comes from the context's `steps` (already loaded for the
16
+ * canvas), so there is no per-step fetch.
17
+ */
18
+ export function EmailAutomationCanvasInspector() {
19
+ const { automation, steps, selectedStepId, select, reload } = useEmailAutomation();
20
+
21
+ const close = useCallback(() => select(null), [select]);
22
+ const onSaved = useCallback(async () => {
23
+ await reload();
24
+ }, [reload]);
25
+
26
+ const headStepId = useMemo(() => findHeadStepId(steps), [steps]);
27
+ const step = useMemo(() => steps.find((s) => s.id === selectedStepId) ?? null, [steps, selectedStepId]);
28
+
29
+ if (!automation || !selectedStepId || !step) return null;
30
+
31
+ return (
32
+ <StepEditor
33
+ key={step.id}
34
+ step={step}
35
+ automationId={automation.id}
36
+ isHead={step.id === headStepId}
37
+ onSaved={onSaved}
38
+ onClose={close}
39
+ />
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Build an `onDialogOpenChange` callback that ignores the editor's
45
+ * initial `open=false` notification. `useEditorDialog` (the hook
46
+ * inside every EditorSheet) initialises `open` to `false`, then in
47
+ * an effect sets it to `dialogOpen` (true). The "open=false" effect
48
+ * fires synchronously on mount and would otherwise wipe our canvas
49
+ * selection before the editor ever paints.
50
+ */
51
+ export function useUserClosedGuard(onClose: () => void) {
52
+ const wasOpenRef = useRef(false);
53
+ return useCallback(
54
+ (open: boolean) => {
55
+ if (open) {
56
+ wasOpenRef.current = true;
57
+ return;
58
+ }
59
+ // open === false; only treat as "user closed" once we've seen open=true.
60
+ if (wasOpenRef.current) onClose();
61
+ },
62
+ [onClose],
63
+ );
64
+ }
65
+
66
+ function StepEditor(props: {
67
+ step: ReturnType<typeof useEmailAutomation>["steps"][number];
68
+ automationId: string;
69
+ isHead: boolean;
70
+ onSaved: () => Promise<void>;
71
+ onClose: () => void;
72
+ }) {
73
+ const onDialogOpenChange = useUserClosedGuard(props.onClose);
74
+
75
+ return (
76
+ <EmailStepEditor
77
+ step={props.step}
78
+ automationId={props.automationId}
79
+ isHead={props.isHead}
80
+ forceShow
81
+ dialogOpen
82
+ onDialogOpenChange={onDialogOpenChange}
83
+ onSuccess={async () => {
84
+ await props.onSaved();
85
+ props.onClose();
86
+ }}
87
+ />
88
+ );
89
+ }
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import { ReactFlowProvider } from "@xyflow/react";
4
+ import { useEffect } from "react";
5
+ import { useSidebar } from "../../../../shadcnui";
6
+ import { EmailAutomationCanvas } from "./EmailAutomationCanvas";
7
+
8
+ export function EmailAutomationCanvasTab() {
9
+ const { open, setOpen } = useSidebar();
10
+
11
+ // Performance mode: collapse the app sidebar on mount; restore on unmount.
12
+ // Mirrors the app's ScenarioCanvasPage shell.
13
+ useEffect(() => {
14
+ const prev = open;
15
+ if (prev) setOpen(false);
16
+ return () => {
17
+ if (prev) setOpen(true);
18
+ };
19
+ }, []);
20
+
21
+ return (
22
+ <main className="flex h-full w-full overflow-hidden">
23
+ {/* The canvas column keeps `relative` so CanvasToolbar's absolute
24
+ positioning anchors to the canvas, not to the whole row. */}
25
+ <div className="relative h-full min-w-0 flex-1">
26
+ <ReactFlowProvider>
27
+ <EmailAutomationCanvas />
28
+ </ReactFlowProvider>
29
+ </div>
30
+ </main>
31
+ );
32
+ }