@carlonicora/nextjs-jsonapi 5.1.3 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/dist/{BlockNoteEditor-KO6YFNIU.js → BlockNoteEditor-UJ6363LC.js} +19 -19
  2. package/dist/{BlockNoteEditor-KO6YFNIU.js.map → BlockNoteEditor-UJ6363LC.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-66FJUDL4.mjs → BlockNoteEditor-WTSQANKI.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-LK22AE7Z.js → BlockNoteViewer-EL4JFB6P.js} +3 -3
  5. package/dist/{BlockNoteViewer-LK22AE7Z.js.map → BlockNoteViewer-EL4JFB6P.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-SUK7OMWH.mjs → BlockNoteViewer-JVKWJZ6J.mjs} +2 -2
  7. package/dist/auth.interface-CSzJ1oP1.d.ts +64 -0
  8. package/dist/auth.interface-DD4uQv2K.d.mts +64 -0
  9. package/dist/billing/index.js +441 -441
  10. package/dist/billing/index.mjs +3 -3
  11. package/dist/{chunk-SZQNUGDG.js → chunk-3BAM23VA.js} +1005 -929
  12. package/dist/chunk-3BAM23VA.js.map +1 -0
  13. package/dist/{chunk-OKUGTXDN.mjs → chunk-6WU74VFU.mjs} +121 -3
  14. package/dist/chunk-6WU74VFU.mjs.map +1 -0
  15. package/dist/{chunk-VVJDWQEI.js → chunk-CRI4T47Y.js} +790 -179
  16. package/dist/chunk-CRI4T47Y.js.map +1 -0
  17. package/dist/{chunk-TO6IYGML.js → chunk-EO75CV76.js} +126 -8
  18. package/dist/chunk-EO75CV76.js.map +1 -0
  19. package/dist/{chunk-KL7CAQP3.mjs → chunk-FNTQQFLH.mjs} +2 -2
  20. package/dist/{chunk-OCJEXAGE.mjs → chunk-JQWI62NV.mjs} +617 -6
  21. package/dist/chunk-JQWI62NV.mjs.map +1 -0
  22. package/dist/{chunk-5JADF7ZA.mjs → chunk-MIZTWBPX.mjs} +171 -95
  23. package/dist/chunk-MIZTWBPX.mjs.map +1 -0
  24. package/dist/{chunk-XLVRAHUK.mjs → chunk-QKZUE5OF.mjs} +2 -2
  25. package/dist/{chunk-67NJY7SM.js → chunk-QQQE3N2X.js} +3 -3
  26. package/dist/{chunk-67NJY7SM.js.map → chunk-QQQE3N2X.js.map} +1 -1
  27. package/dist/{chunk-XCHXOCEJ.js → chunk-XGTQWBDJ.js} +4 -4
  28. package/dist/{chunk-XCHXOCEJ.js.map → chunk-XGTQWBDJ.js.map} +1 -1
  29. package/dist/client/index.d.mts +6 -39
  30. package/dist/client/index.d.ts +6 -39
  31. package/dist/client/index.js +4 -4
  32. package/dist/client/index.mjs +3 -3
  33. package/dist/components/index.d.mts +19 -5
  34. package/dist/components/index.d.ts +19 -5
  35. package/dist/components/index.js +6 -4
  36. package/dist/components/index.js.map +1 -1
  37. package/dist/components/index.mjs +5 -3
  38. package/dist/contexts/index.js +4 -4
  39. package/dist/contexts/index.mjs +3 -3
  40. package/dist/core/index.d.mts +214 -8
  41. package/dist/core/index.d.ts +214 -8
  42. package/dist/core/index.js +30 -2
  43. package/dist/core/index.js.map +1 -1
  44. package/dist/core/index.mjs +29 -1
  45. package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
  46. package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
  47. package/dist/email-automations/index.css +30 -0
  48. package/dist/email-automations/index.css.map +1 -0
  49. package/dist/email-automations/index.d.mts +303 -0
  50. package/dist/email-automations/index.d.ts +303 -0
  51. package/dist/email-automations/index.js +1705 -0
  52. package/dist/email-automations/index.js.map +1 -0
  53. package/dist/email-automations/index.mjs +1705 -0
  54. package/dist/email-automations/index.mjs.map +1 -0
  55. package/dist/features/analytics/index.js +122 -122
  56. package/dist/features/analytics/index.mjs +4 -4
  57. package/dist/features/analytics/server.js +4 -4
  58. package/dist/features/analytics/server.mjs +2 -2
  59. package/dist/features/help/index.js +37 -37
  60. package/dist/features/help/index.mjs +3 -3
  61. package/dist/features/tokenusage/index.js +125 -125
  62. package/dist/features/tokenusage/index.mjs +4 -4
  63. package/dist/index.d.mts +5 -4
  64. package/dist/index.d.ts +5 -4
  65. package/dist/index.js +41 -3
  66. package/dist/index.js.map +1 -1
  67. package/dist/index.mjs +41 -3
  68. package/dist/{s3.service-C-4YpEMu.d.ts → s3.service-CY-jgOAM.d.ts} +1 -1
  69. package/dist/{s3.service-dVj-nxMl.d.mts → s3.service-Sri0QP7Z.d.mts} +1 -1
  70. package/dist/server/index.d.mts +2 -2
  71. package/dist/server/index.d.ts +2 -2
  72. package/dist/server/index.js +3 -3
  73. package/dist/server/index.mjs +2 -2
  74. package/dist/types-70acd-8C.d.mts +39 -0
  75. package/dist/types-l8SFleXi.d.ts +39 -0
  76. package/dist/{waitlist.config-kPfjImle.d.mts → waitlist.config-Cagmgm7-.d.mts} +7 -0
  77. package/dist/{waitlist.config-kPfjImle.d.ts → waitlist.config-Cagmgm7-.d.ts} +7 -0
  78. package/messages/en.json +132 -1
  79. package/messages/it.json +132 -1
  80. package/package.json +11 -1
  81. package/scripts/validate-messages.mjs +1 -0
  82. package/src/components/forms/FormFieldWrapper.tsx +1 -1
  83. package/src/components/forms/FormTextarea.tsx +2 -1
  84. package/src/components/index.ts +1 -0
  85. package/src/core/index.ts +8 -0
  86. package/src/core/registry/ModuleRegistry.ts +5 -0
  87. package/src/email-automations/index.ts +5 -0
  88. package/src/features/auth/components/buttons/GoogleSignInButton.tsx +8 -4
  89. package/src/features/auth/components/forms/Register.tsx +20 -8
  90. package/src/features/auth/data/auth.interface.ts +2 -0
  91. package/src/features/auth/data/auth.ts +10 -0
  92. package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
  93. package/src/features/email-automation/EmailAutomationModule.ts +30 -0
  94. package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
  95. package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
  96. package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
  97. package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
  98. package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
  99. package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
  100. package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
  101. package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
  102. package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
  103. package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
  104. package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
  105. package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
  106. package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
  107. package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
  108. package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
  109. package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
  110. package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
  111. package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
  112. package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
  113. package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
  114. package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
  115. package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
  116. package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
  117. package/src/features/email-automation/components/index.ts +15 -0
  118. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
  119. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
  120. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
  121. package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
  122. package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
  123. package/src/features/email-automation/data/EmailAutomation.ts +87 -0
  124. package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
  125. package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
  126. package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
  127. package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
  128. package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
  129. package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
  130. package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
  131. package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
  132. package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
  133. package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
  134. package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
  135. package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
  136. package/src/features/email-automation/data/email-automation.fields.ts +9 -0
  137. package/src/features/email-automation/data/index.ts +13 -0
  138. package/src/features/email-automation/hooks/index.ts +2 -0
  139. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
  140. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
  141. package/src/features/email-automation/index.ts +5 -0
  142. package/src/features/signup-source/SignupSourceCapture.tsx +27 -0
  143. package/src/features/signup-source/__tests__/signup-source.spec.ts +161 -0
  144. package/src/features/signup-source/index.ts +9 -0
  145. package/src/features/signup-source/signup-source.ts +135 -0
  146. package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -0
  147. package/src/features/waitlist/components/forms/WaitlistQuestionnaireRenderer.tsx +15 -13
  148. package/src/features/waitlist/components/lists/WaitlistList.tsx +60 -22
  149. package/src/features/waitlist/components/sections/WaitlistHeroSection.tsx +6 -5
  150. package/src/features/waitlist/config/waitlist.config.ts +23 -0
  151. package/src/features/waitlist/data/Waitlist.ts +51 -3
  152. package/src/features/waitlist/data/WaitlistInterface.ts +9 -1
  153. package/src/features/waitlist/data/WaitlistService.ts +3 -2
  154. package/src/features/waitlist/data/__tests__/Waitlist.spec.ts +82 -0
  155. package/src/features/waitlist/data/__tests__/WaitlistService.spec.ts +48 -0
  156. package/src/features/waitlist/data/waitlist.fields.ts +1 -0
  157. package/src/features/waitlist/hooks/__tests__/formatSignupSource.spec.ts +24 -0
  158. package/src/features/waitlist/hooks/formatSignupSource.ts +14 -0
  159. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +26 -14
  160. package/src/hooks/__tests__/usePageTracker.spec.tsx +16 -10
  161. package/src/index.ts +3 -0
  162. package/dist/auth.interface-CeFCz9NU.d.mts +0 -34
  163. package/dist/auth.interface-D1R7VVky.d.ts +0 -34
  164. package/dist/chunk-5JADF7ZA.mjs.map +0 -1
  165. package/dist/chunk-OCJEXAGE.mjs.map +0 -1
  166. package/dist/chunk-OKUGTXDN.mjs.map +0 -1
  167. package/dist/chunk-SZQNUGDG.js.map +0 -1
  168. package/dist/chunk-TO6IYGML.js.map +0 -1
  169. package/dist/chunk-VVJDWQEI.js.map +0 -1
  170. /package/dist/{BlockNoteEditor-66FJUDL4.mjs.map → BlockNoteEditor-WTSQANKI.mjs.map} +0 -0
  171. /package/dist/{BlockNoteViewer-SUK7OMWH.mjs.map → BlockNoteViewer-JVKWJZ6J.mjs.map} +0 -0
  172. /package/dist/{chunk-KL7CAQP3.mjs.map → chunk-FNTQQFLH.mjs.map} +0 -0
  173. /package/dist/{chunk-XLVRAHUK.mjs.map → chunk-QKZUE5OF.mjs.map} +0 -0
@@ -0,0 +1,11 @@
1
+ import { MailIcon } from "lucide-react";
2
+ import { ModuleFactory } from "../../permissions";
3
+ import { EmailAutomationStep } from "./data/EmailAutomationStep";
4
+
5
+ export const EmailAutomationStepModule = (factory: ModuleFactory) =>
6
+ factory({
7
+ pageUrl: undefined,
8
+ name: "email-automation-steps",
9
+ model: EmailAutomationStep,
10
+ icon: MailIcon,
11
+ });
@@ -0,0 +1,11 @@
1
+ import { ZapIcon } from "lucide-react";
2
+ import { ModuleFactory } from "../../permissions";
3
+ import { EmailAutomationTrigger } from "./data/EmailAutomationTrigger";
4
+
5
+ export const EmailAutomationTriggerModule = (factory: ModuleFactory) =>
6
+ factory({
7
+ pageUrl: undefined,
8
+ name: "email-automation-triggers",
9
+ model: EmailAutomationTrigger,
10
+ icon: ZapIcon,
11
+ });
@@ -0,0 +1,104 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
4
+ import { configureI18n } from "../../../../i18n";
5
+ import { EmailAutomationEditor } from "../forms/EmailAutomationEditor";
6
+
7
+ // Hoisted so the vi.mock factories below (which run before the module body)
8
+ // can close over them.
9
+ const mocks = vi.hoisted(() => ({
10
+ findTriggers: vi.fn(),
11
+ create: vi.fn(),
12
+ update: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("../../data/EmailAutomationService", () => ({
16
+ EmailAutomationService: {
17
+ findTriggers: mocks.findTriggers,
18
+ create: mocks.create,
19
+ update: mocks.update,
20
+ },
21
+ }));
22
+
23
+ // Base UI renders a closed Select's options nowhere in the DOM, so FormSelect
24
+ // is replaced by a stub that prints its label, its empty option and its
25
+ // values. Everything else in the barrel stays real.
26
+ vi.mock("../../../../components", async () => {
27
+ const actual = await vi.importActual<typeof import("../../../../components")>("../../../../components");
28
+ return {
29
+ ...actual,
30
+ FormSelect: (props: {
31
+ id: string;
32
+ name?: string;
33
+ placeholder?: string;
34
+ allowEmpty?: boolean;
35
+ values: { id: string; text: string }[];
36
+ }) => (
37
+ <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
+ ))}
45
+ </div>
46
+ ),
47
+ };
48
+ });
49
+
50
+ const TRIGGERS = [
51
+ { id: "waitlist-confirmed", key: "waitlist-confirmed", label: "Waitlist confirmed" },
52
+ { id: "registration-completed", key: "registration-completed", label: "Registration completed" },
53
+ ];
54
+
55
+ // `Modules.EmailAutomation` resolves through the registry at render time.
56
+ beforeAll(() => {
57
+ ModuleRegistry.register("EmailAutomation", {
58
+ name: "email-automations",
59
+ pageUrl: "/administration/email-automations",
60
+ } as any);
61
+ });
62
+
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ mocks.findTriggers.mockResolvedValue(TRIGGERS);
66
+ configureI18n({
67
+ useRouter: () => ({
68
+ push: vi.fn(),
69
+ replace: vi.fn(),
70
+ back: vi.fn(),
71
+ forward: vi.fn(),
72
+ refresh: vi.fn(),
73
+ prefetch: vi.fn(),
74
+ }),
75
+ useTranslations: () => (key: string) => key,
76
+ Link: ({ children }: any) => children,
77
+ usePathname: () => "/",
78
+ });
79
+ });
80
+
81
+ describe("EmailAutomationEditor", () => {
82
+ it("renders the five fields by their labels", async () => {
83
+ render(<EmailAutomationEditor forceShow dialogOpen />);
84
+
85
+ expect(await screen.findByText("emailAutomations.fields.name.label")).toBeInTheDocument();
86
+ expect(screen.getByText("emailAutomations.fields.trigger.label")).toBeInTheDocument();
87
+ expect(screen.getByText("emailAutomations.fields.exitTrigger.label")).toBeInTheDocument();
88
+ expect(screen.getByText("emailAutomations.fields.replyTo.label")).toBeInTheDocument();
89
+ expect(screen.getByText("emailAutomations.fields.isActive.label")).toBeInTheDocument();
90
+ });
91
+
92
+ it("lists the none option plus the trigger labels returned by the API", async () => {
93
+ render(<EmailAutomationEditor forceShow dialogOpen />);
94
+
95
+ const triggerSelect = await screen.findByTestId("form-select-trigger");
96
+ await waitFor(() => expect(within(triggerSelect).getByText("Waitlist confirmed")).toBeInTheDocument());
97
+
98
+ expect(mocks.findTriggers).toHaveBeenCalled();
99
+ expect(within(triggerSelect).getByText("emailAutomations.fields.trigger.none")).toBeInTheDocument();
100
+ expect(within(triggerSelect).getByText("Registration completed")).toBeInTheDocument();
101
+ // The option values are the trigger keys, the visible text the plain label.
102
+ expect(within(triggerSelect).getByText("Waitlist confirmed")).toHaveAttribute("data-value", "waitlist-confirmed");
103
+ });
104
+ });
@@ -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
+ }