@carlonicora/nextjs-jsonapi 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/{BlockNoteEditor-EGY4ZAAM.js → BlockNoteEditor-UJ6363LC.js} +19 -19
  2. package/dist/{BlockNoteEditor-EGY4ZAAM.js.map → BlockNoteEditor-UJ6363LC.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-WTSQANKI.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-EL4JFB6P.js} +3 -3
  5. package/dist/{BlockNoteViewer-I2MMFH3S.js.map → BlockNoteViewer-EL4JFB6P.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-JVKWJZ6J.mjs} +2 -2
  7. package/dist/billing/index.js +441 -441
  8. package/dist/billing/index.mjs +3 -3
  9. package/dist/{chunk-3VEQ5JZJ.js → chunk-3BAM23VA.js} +847 -847
  10. package/dist/chunk-3BAM23VA.js.map +1 -0
  11. package/dist/{chunk-JEZQTZQ4.mjs → chunk-6WU74VFU.mjs} +2 -2
  12. package/dist/{chunk-S5MQDWWC.js → chunk-CRI4T47Y.js} +736 -176
  13. package/dist/chunk-CRI4T47Y.js.map +1 -0
  14. package/dist/{chunk-75MLT74B.js → chunk-EO75CV76.js} +8 -8
  15. package/dist/{chunk-75MLT74B.js.map → chunk-EO75CV76.js.map} +1 -1
  16. package/dist/{chunk-NPKFMRH5.mjs → chunk-FNTQQFLH.mjs} +2 -2
  17. package/dist/{chunk-HNOWSFM5.mjs → chunk-JQWI62NV.mjs} +563 -3
  18. package/dist/chunk-JQWI62NV.mjs.map +1 -0
  19. package/dist/{chunk-2Z3ZCYXX.mjs → chunk-MIZTWBPX.mjs} +5 -5
  20. package/dist/chunk-MIZTWBPX.mjs.map +1 -0
  21. package/dist/{chunk-YMUPTXOT.mjs → chunk-QKZUE5OF.mjs} +2 -2
  22. package/dist/{chunk-EXICQC4A.js → chunk-QQQE3N2X.js} +3 -3
  23. package/dist/{chunk-EXICQC4A.js.map → chunk-QQQE3N2X.js.map} +1 -1
  24. package/dist/{chunk-THKYH65N.js → chunk-XGTQWBDJ.js} +4 -4
  25. package/dist/{chunk-THKYH65N.js.map → chunk-XGTQWBDJ.js.map} +1 -1
  26. package/dist/client/index.d.mts +6 -39
  27. package/dist/client/index.d.ts +6 -39
  28. package/dist/client/index.js +4 -4
  29. package/dist/client/index.mjs +3 -3
  30. package/dist/components/index.d.mts +2 -2
  31. package/dist/components/index.d.ts +2 -2
  32. package/dist/components/index.js +4 -4
  33. package/dist/components/index.mjs +3 -3
  34. package/dist/contexts/index.js +4 -4
  35. package/dist/contexts/index.mjs +3 -3
  36. package/dist/core/index.d.mts +187 -1
  37. package/dist/core/index.d.ts +187 -1
  38. package/dist/core/index.js +30 -2
  39. package/dist/core/index.js.map +1 -1
  40. package/dist/core/index.mjs +29 -1
  41. package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
  42. package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
  43. package/dist/email-automations/index.css +30 -0
  44. package/dist/email-automations/index.css.map +1 -0
  45. package/dist/email-automations/index.d.mts +303 -0
  46. package/dist/email-automations/index.d.ts +303 -0
  47. package/dist/email-automations/index.js +1705 -0
  48. package/dist/email-automations/index.js.map +1 -0
  49. package/dist/email-automations/index.mjs +1705 -0
  50. package/dist/email-automations/index.mjs.map +1 -0
  51. package/dist/features/analytics/index.js +122 -122
  52. package/dist/features/analytics/index.mjs +4 -4
  53. package/dist/features/analytics/server.js +4 -4
  54. package/dist/features/analytics/server.mjs +2 -2
  55. package/dist/features/help/index.js +37 -37
  56. package/dist/features/help/index.mjs +3 -3
  57. package/dist/features/tokenusage/index.js +125 -125
  58. package/dist/features/tokenusage/index.mjs +4 -4
  59. package/dist/index.d.mts +2 -1
  60. package/dist/index.d.ts +2 -1
  61. package/dist/index.js +31 -3
  62. package/dist/index.js.map +1 -1
  63. package/dist/index.mjs +30 -2
  64. package/dist/server/index.js +3 -3
  65. package/dist/server/index.mjs +2 -2
  66. package/dist/types-70acd-8C.d.mts +39 -0
  67. package/dist/types-l8SFleXi.d.ts +39 -0
  68. package/messages/en.json +117 -0
  69. package/messages/it.json +117 -0
  70. package/package.json +11 -1
  71. package/src/components/forms/FormFieldWrapper.tsx +1 -1
  72. package/src/components/forms/FormTextarea.tsx +2 -1
  73. package/src/core/index.ts +8 -0
  74. package/src/core/registry/ModuleRegistry.ts +5 -0
  75. package/src/email-automations/index.ts +5 -0
  76. package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
  77. package/src/features/email-automation/EmailAutomationModule.ts +30 -0
  78. package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
  79. package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
  80. package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
  81. package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
  82. package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
  83. package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
  84. package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
  85. package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
  86. package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
  87. package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
  88. package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
  89. package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
  90. package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
  91. package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
  92. package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
  93. package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
  94. package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
  95. package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
  96. package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
  97. package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
  98. package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
  99. package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
  100. package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
  101. package/src/features/email-automation/components/index.ts +15 -0
  102. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
  103. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
  104. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
  105. package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
  106. package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
  107. package/src/features/email-automation/data/EmailAutomation.ts +87 -0
  108. package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
  109. package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
  110. package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
  111. package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
  112. package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
  113. package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
  114. package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
  115. package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
  116. package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
  117. package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
  118. package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
  119. package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
  120. package/src/features/email-automation/data/email-automation.fields.ts +9 -0
  121. package/src/features/email-automation/data/index.ts +13 -0
  122. package/src/features/email-automation/hooks/index.ts +2 -0
  123. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
  124. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
  125. package/src/features/email-automation/index.ts +5 -0
  126. package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
  127. package/dist/chunk-3VEQ5JZJ.js.map +0 -1
  128. package/dist/chunk-HNOWSFM5.mjs.map +0 -1
  129. package/dist/chunk-S5MQDWWC.js.map +0 -1
  130. /package/dist/{BlockNoteEditor-2QJVK2HH.mjs.map → BlockNoteEditor-WTSQANKI.mjs.map} +0 -0
  131. /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-JVKWJZ6J.mjs.map} +0 -0
  132. /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-6WU74VFU.mjs.map} +0 -0
  133. /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-FNTQQFLH.mjs.map} +0 -0
  134. /package/dist/{chunk-YMUPTXOT.mjs.map → chunk-QKZUE5OF.mjs.map} +0 -0
@@ -0,0 +1,141 @@
1
+ import type { Edge, Node } from "@xyflow/react";
2
+ import { autoLayout, LayoutEdge, LayoutNode } from "./shared";
3
+
4
+ export const TRIGGER_NODE_ID = "trigger";
5
+ export const TRIGGER_SOURCE_HANDLE = "trigger-source";
6
+ export const STEP_NODE_PREFIX = "step:";
7
+ export const STEP_SOURCE_HANDLE_PREFIX = "step-source-";
8
+ export const STEP_TARGET_HANDLE_PREFIX = "step-target-";
9
+
10
+ export const TRIGGER_NODE_WIDTH = 200;
11
+ export const TRIGGER_NODE_HEIGHT = 56;
12
+ export const STEP_NODE_WIDTH = 200;
13
+ export const STEP_NODE_HEIGHT = 72;
14
+
15
+ /**
16
+ * The fields the canvas reads from a step. `EmailAutomationStepInterface`
17
+ * satisfies it structurally; the narrow shape keeps this function pure and
18
+ * testable with plain objects.
19
+ */
20
+ export type CanvasStep = {
21
+ id: string;
22
+ subject: string;
23
+ delayHours: number;
24
+ positionX?: number;
25
+ positionY?: number;
26
+ sentCount: number;
27
+ next?: { id: string };
28
+ };
29
+
30
+ export type TriggerNodeData = { label: string };
31
+
32
+ export type EmailStepNodeData = {
33
+ stepId: string;
34
+ subject: string;
35
+ sentCount: number;
36
+ orphan: boolean;
37
+ manuallyPositioned: boolean;
38
+ };
39
+
40
+ export type DelayEdgeData = { targetStepId: string; delayHours: number };
41
+
42
+ export type CanvasGraph = { nodes: Node[]; edges: Edge[]; orphanIds: string[] };
43
+
44
+ /**
45
+ * The head is the oldest step with no incoming NEXT, exactly as the API picks
46
+ * it (`findHead`). `steps` arrive ordered by createdAt ASC from
47
+ * `findByAutomation`, so the first step nobody points at is the head.
48
+ */
49
+ export function findHeadStepId(steps: CanvasStep[]): string | null {
50
+ const pointedAt = new Set(steps.map((s) => s.next?.id).filter((id): id is string => !!id));
51
+ return steps.find((s) => !pointedAt.has(s.id))?.id ?? null;
52
+ }
53
+
54
+ const stepNodeId = (stepId: string) => `${STEP_NODE_PREFIX}${stepId}`;
55
+
56
+ /**
57
+ * Turns the automation's steps into React Flow nodes and edges: the trigger
58
+ * node, one node per step, a trigger→head edge, and one edge per NEXT link.
59
+ * Steps not reachable from the head (walking next→next) are orphans: they
60
+ * never send and render dashed.
61
+ */
62
+ export function buildCanvasGraph(steps: CanvasStep[], triggerLabel: string): CanvasGraph {
63
+ const byId = new Map(steps.map((s) => [s.id, s]));
64
+ const headId = findHeadStepId(steps);
65
+
66
+ const chain = new Set<string>();
67
+ let cursor = headId ? byId.get(headId) : undefined;
68
+ while (cursor && !chain.has(cursor.id)) {
69
+ chain.add(cursor.id);
70
+ cursor = cursor.next ? byId.get(cursor.next.id) : undefined;
71
+ }
72
+ const orphanIds = steps.filter((s) => !chain.has(s.id)).map((s) => s.id);
73
+
74
+ const layoutNodes: LayoutNode[] = [
75
+ { id: TRIGGER_NODE_ID, width: TRIGGER_NODE_WIDTH, height: TRIGGER_NODE_HEIGHT, manuallyPositioned: false },
76
+ ...steps.map((s) => ({
77
+ id: stepNodeId(s.id),
78
+ width: STEP_NODE_WIDTH,
79
+ height: STEP_NODE_HEIGHT,
80
+ manuallyPositioned: typeof s.positionX === "number" && typeof s.positionY === "number",
81
+ x: s.positionX,
82
+ y: s.positionY,
83
+ })),
84
+ ];
85
+
86
+ const edges: Edge[] = [];
87
+ const layoutEdges: LayoutEdge[] = [];
88
+
89
+ const pushEdge = (source: string, sourceHandle: string, target: CanvasStep) => {
90
+ const data: DelayEdgeData = { targetStepId: target.id, delayHours: target.delayHours };
91
+ edges.push({
92
+ id: `next:${source}->${target.id}`,
93
+ source,
94
+ sourceHandle,
95
+ target: stepNodeId(target.id),
96
+ targetHandle: `${STEP_TARGET_HANDLE_PREFIX}${target.id}`,
97
+ type: "delayEdge",
98
+ label: `${target.delayHours}h`,
99
+ data,
100
+ });
101
+ layoutEdges.push({ source, target: stepNodeId(target.id) });
102
+ };
103
+
104
+ const head = headId ? byId.get(headId) : undefined;
105
+ if (head) pushEdge(TRIGGER_NODE_ID, TRIGGER_SOURCE_HANDLE, head);
106
+ for (const s of steps) {
107
+ const next = s.next ? byId.get(s.next.id) : undefined;
108
+ if (next) pushEdge(stepNodeId(s.id), `${STEP_SOURCE_HANDLE_PREFIX}${s.id}`, next);
109
+ }
110
+
111
+ const positions = autoLayout(layoutNodes, layoutEdges);
112
+
113
+ const nodes: Node[] = [
114
+ {
115
+ id: TRIGGER_NODE_ID,
116
+ type: "triggerNode",
117
+ position: positions[TRIGGER_NODE_ID],
118
+ deletable: false,
119
+ data: { data: { label: triggerLabel } satisfies TriggerNodeData },
120
+ },
121
+ ...steps.map((s) => {
122
+ const id = stepNodeId(s.id);
123
+ return {
124
+ id,
125
+ type: "emailStepNode",
126
+ position: positions[id],
127
+ data: {
128
+ data: {
129
+ stepId: s.id,
130
+ subject: s.subject,
131
+ sentCount: s.sentCount,
132
+ orphan: !chain.has(s.id),
133
+ manuallyPositioned: typeof s.positionX === "number" && typeof s.positionY === "number",
134
+ } satisfies EmailStepNodeData,
135
+ },
136
+ } satisfies Node;
137
+ }),
138
+ ];
139
+
140
+ return { nodes, edges, orphanIds };
141
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { BaseEdge, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath } from "@xyflow/react";
4
+ import { useEmailAutomation } from "../../../contexts/EmailAutomationContext";
5
+ import { DelayEdgeData } from "../buildCanvasGraph";
6
+
7
+ type DelayEdgeType = Edge<DelayEdgeData>;
8
+
9
+ /**
10
+ * ClueEdge recipe: a bezier with a label chip at its midpoint. The chip shows
11
+ * the target step's delay and opens that step's editor. It is a plain
12
+ * <button>: Base UI popovers do not work inside the React Flow canvas.
13
+ */
14
+ export function DelayEdge({
15
+ id,
16
+ sourceX,
17
+ sourceY,
18
+ targetX,
19
+ targetY,
20
+ sourcePosition,
21
+ targetPosition,
22
+ label,
23
+ data,
24
+ }: EdgeProps<DelayEdgeType>) {
25
+ const { select } = useEmailAutomation();
26
+ const [edgePath, labelX, labelY] = getBezierPath({
27
+ sourceX,
28
+ sourceY,
29
+ sourcePosition,
30
+ targetX,
31
+ targetY,
32
+ targetPosition,
33
+ });
34
+
35
+ const targetStepId = data?.targetStepId;
36
+
37
+ return (
38
+ <>
39
+ <BaseEdge id={id} path={edgePath} className="stroke-foreground/60" interactionWidth={24} />
40
+ <EdgeLabelRenderer>
41
+ {/* Inline position + pointer-events per React Flow docs: the label
42
+ portal sets pointer-events:none, so the label sets it back to
43
+ 'all' and carries `nodrag nopan nowheel` for event isolation. */}
44
+ <div
45
+ className="nodrag nopan nowheel"
46
+ style={{
47
+ position: "absolute",
48
+ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
49
+ pointerEvents: "all",
50
+ zIndex: 1000,
51
+ }}
52
+ onMouseDown={(e) => e.stopPropagation()}
53
+ >
54
+ <button
55
+ type="button"
56
+ className="nodrag nopan bg-background hover:bg-accent text-foreground border-border h-auto rounded border px-2 py-1 text-xs tabular-nums shadow-sm"
57
+ onClick={(e) => {
58
+ e.stopPropagation();
59
+ if (targetStepId) select(targetStepId);
60
+ }}
61
+ >
62
+ {label}
63
+ </button>
64
+ </div>
65
+ </EdgeLabelRenderer>
66
+ </>
67
+ );
68
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { Handle, Node, NodeProps, Position } from "@xyflow/react";
4
+ import { MailIcon } from "lucide-react";
5
+ import { useTranslations } from "next-intl";
6
+ import { EmailStepNodeData, STEP_SOURCE_HANDLE_PREFIX, STEP_TARGET_HANDLE_PREFIX } from "../buildCanvasGraph";
7
+
8
+ type EmailStepNodeType = Node<{ data: EmailStepNodeData }>;
9
+
10
+ /**
11
+ * 200px card (SceneNode recipe): subject bold, "sent N" muted. An orphan (not
12
+ * reachable from the trigger, so it never sends) is the dashed, faded variant
13
+ * with a caption. Plain elements only: Base UI menus/popovers do not work
14
+ * inside React Flow nodes; clicks and context menus are handled by the canvas.
15
+ */
16
+ export function EmailStepNode({ data }: NodeProps<EmailStepNodeType>) {
17
+ const d = data.data;
18
+ const t = useTranslations();
19
+
20
+ const shellClass = d.orphan
21
+ ? "border border-dashed border-foreground/30 bg-muted/40 opacity-60"
22
+ : "border bg-background border-foreground/25 text-foreground";
23
+
24
+ return (
25
+ <div className={`w-[200px] cursor-pointer rounded-md p-2 shadow-sm ${shellClass}`}>
26
+ <Handle type="target" position={Position.Left} id={`${STEP_TARGET_HANDLE_PREFIX}${d.stepId}`} />
27
+ <Handle type="source" position={Position.Right} id={`${STEP_SOURCE_HANDLE_PREFIX}${d.stepId}`} />
28
+
29
+ <div className="flex items-center gap-1 text-sm font-medium">
30
+ <MailIcon className="size-3 shrink-0" />
31
+ <span className="truncate">{d.subject}</span>
32
+ </div>
33
+
34
+ <div className="text-muted-foreground mt-1 text-xs tabular-nums">
35
+ {t("emailAutomations.canvas.sent", { count: d.sentCount })}
36
+ </div>
37
+
38
+ {d.orphan && (
39
+ <div className="text-muted-foreground mt-1 text-xs italic">{t("emailAutomations.canvas.orphan")}</div>
40
+ )}
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { Handle, Node, NodeProps, Position } from "@xyflow/react";
4
+ import { ZapIcon } from "lucide-react";
5
+ import { TRIGGER_SOURCE_HANDLE, TriggerNodeData } from "../buildCanvasGraph";
6
+
7
+ // React Flow v12 requires the Node generic to carry the data shape.
8
+ type TriggerNodeType = Node<{ data: TriggerNodeData }>;
9
+
10
+ /**
11
+ * The fixed head of the chain (SecretTargetNode recipe). Source handle only:
12
+ * people enter here. Plain element, no Base UI inside the node; the click that
13
+ * opens EmailAutomationEditor is handled by the canvas (`onNodeClick`).
14
+ */
15
+ export function TriggerNode({ data }: NodeProps<TriggerNodeType>) {
16
+ return (
17
+ <div className="bg-foreground text-background flex w-[200px] cursor-pointer items-center justify-center gap-1 rounded-full px-4 py-2 text-center shadow-md">
18
+ <ZapIcon className="size-3 shrink-0 opacity-60" />
19
+ <div className="truncate text-sm font-medium">{data.data.label}</div>
20
+ <Handle type="source" position={Position.Right} id={TRIGGER_SOURCE_HANDLE} />
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { useReactFlow } from "@xyflow/react";
4
+ import { ListRestartIcon, MaximizeIcon, MinusIcon, PlusIcon } from "lucide-react";
5
+ import { useTranslations } from "next-intl";
6
+ import { Button } from "../../../../../shadcnui";
7
+
8
+ export function CanvasToolbar({ onTidy }: { onTidy: () => void | Promise<void> }) {
9
+ const t = useTranslations();
10
+ const { zoomIn, zoomOut, fitView } = useReactFlow();
11
+
12
+ return (
13
+ <div data-help="canvas.toolbar" className="absolute end-2 top-2 z-10 flex flex-col gap-1 max-md:top-14">
14
+ <Button
15
+ type="button"
16
+ variant="outline"
17
+ size="icon"
18
+ onClick={() => zoomIn()}
19
+ aria-label={t("canvas.toolbar.zoomIn")}
20
+ >
21
+ <PlusIcon className="h-4 w-4" />
22
+ </Button>
23
+ <Button
24
+ type="button"
25
+ variant="outline"
26
+ size="icon"
27
+ onClick={() => zoomOut()}
28
+ aria-label={t("canvas.toolbar.zoomOut")}
29
+ >
30
+ <MinusIcon className="h-4 w-4" />
31
+ </Button>
32
+ <Button
33
+ type="button"
34
+ variant="outline"
35
+ size="icon"
36
+ onClick={() => fitView({ duration: 300 })}
37
+ aria-label={t("canvas.toolbar.fitView")}
38
+ >
39
+ <MaximizeIcon className="h-4 w-4" />
40
+ </Button>
41
+ <Button
42
+ type="button"
43
+ variant="outline"
44
+ size="icon"
45
+ onClick={() => void onTidy()}
46
+ title={t("canvas.toolbar.tidyTooltip")}
47
+ aria-label={t("canvas.toolbar.tidy")}
48
+ >
49
+ <ListRestartIcon className="h-4 w-4" />
50
+ </Button>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,11 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ export function EmptyCanvas({ children }: { children: ReactNode }) {
6
+ return (
7
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center px-6 text-center text-sm">
8
+ {children}
9
+ </div>
10
+ );
11
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { autoLayout, LayoutEdge, LayoutNode } from "../autoLayout";
3
+
4
+ describe("autoLayout", () => {
5
+ it("returns a position for every node", () => {
6
+ const nodes: LayoutNode[] = [
7
+ { id: "a", width: 200, height: 120, manuallyPositioned: false },
8
+ { id: "b", width: 200, height: 120, manuallyPositioned: false },
9
+ ];
10
+ const edges: LayoutEdge[] = [{ source: "a", target: "b" }];
11
+ const result = autoLayout(nodes, edges);
12
+ expect(Object.keys(result).sort()).toEqual(["a", "b"]);
13
+ expect(typeof result.a.x).toBe("number");
14
+ expect(typeof result.b.x).toBe("number");
15
+ });
16
+
17
+ it("respects manuallyPositioned: keeps pinned x/y verbatim", () => {
18
+ const nodes: LayoutNode[] = [
19
+ { id: "a", width: 200, height: 120, manuallyPositioned: true, x: 999, y: 888 },
20
+ { id: "b", width: 200, height: 120, manuallyPositioned: false },
21
+ ];
22
+ const edges: LayoutEdge[] = [{ source: "a", target: "b" }];
23
+ const result = autoLayout(nodes, edges);
24
+ expect(result.a).toEqual({ x: 999, y: 888 });
25
+ expect(result.b).not.toEqual({ x: 999, y: 888 });
26
+ });
27
+
28
+ it("is deterministic on identical input", () => {
29
+ const nodes: LayoutNode[] = [
30
+ { id: "a", width: 200, height: 120, manuallyPositioned: false },
31
+ { id: "b", width: 200, height: 120, manuallyPositioned: false },
32
+ { id: "c", width: 200, height: 120, manuallyPositioned: false },
33
+ ];
34
+ const edges: LayoutEdge[] = [
35
+ { source: "a", target: "b" },
36
+ { source: "b", target: "c" },
37
+ ];
38
+ const r1 = autoLayout(nodes, edges);
39
+ const r2 = autoLayout(nodes, edges);
40
+ expect(r1).toEqual(r2);
41
+ });
42
+ });
@@ -0,0 +1,51 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createLongPressTracker } from "../longPress";
3
+
4
+ describe("createLongPressTracker", () => {
5
+ beforeEach(() => vi.useFakeTimers());
6
+ afterEach(() => vi.useRealTimers());
7
+
8
+ it("fires after the hold delay with the press point", () => {
9
+ const onLongPress = vi.fn();
10
+ const tracker = createLongPressTracker({ onLongPress });
11
+ tracker.down("scene:1", 40, 50);
12
+ vi.advanceTimersByTime(499);
13
+ expect(onLongPress).not.toHaveBeenCalled();
14
+ vi.advanceTimersByTime(1);
15
+ expect(onLongPress).toHaveBeenCalledWith({ target: "scene:1", clientX: 40, clientY: 50 });
16
+ });
17
+
18
+ it("does not fire when the finger lifts early", () => {
19
+ const onLongPress = vi.fn();
20
+ const tracker = createLongPressTracker({ onLongPress });
21
+ tracker.down("scene:1", 0, 0);
22
+ vi.advanceTimersByTime(300);
23
+ tracker.up();
24
+ vi.advanceTimersByTime(500);
25
+ expect(onLongPress).not.toHaveBeenCalled();
26
+ expect(tracker.consumeClick()).toBe(false);
27
+ });
28
+
29
+ it("tolerates jitter but cancels on a real move (pan or drag)", () => {
30
+ const onLongPress = vi.fn();
31
+ const tracker = createLongPressTracker({ onLongPress });
32
+ tracker.down("scene:1", 0, 0);
33
+ tracker.move(5, 5);
34
+ vi.advanceTimersByTime(500);
35
+ expect(onLongPress).toHaveBeenCalledTimes(1);
36
+
37
+ tracker.down("scene:1", 0, 0);
38
+ tracker.move(20, 0);
39
+ vi.advanceTimersByTime(500);
40
+ expect(onLongPress).toHaveBeenCalledTimes(1);
41
+ });
42
+
43
+ it("swallows exactly one click after a fired press", () => {
44
+ const tracker = createLongPressTracker({ onLongPress: () => {} });
45
+ tracker.down("scene:1", 0, 0);
46
+ vi.advanceTimersByTime(500);
47
+ tracker.up();
48
+ expect(tracker.consumeClick()).toBe(true);
49
+ expect(tracker.consumeClick()).toBe(false);
50
+ });
51
+ });
@@ -0,0 +1,55 @@
1
+ import dagre from "dagre";
2
+
3
+ export type LayoutNode = {
4
+ id: string;
5
+ /** node body dimensions in canvas px — used by dagre to allocate space */
6
+ width: number;
7
+ height: number;
8
+ /** if true, dagre leaves position alone and uses these (x, y) verbatim */
9
+ manuallyPositioned: boolean;
10
+ x?: number;
11
+ y?: number;
12
+ };
13
+
14
+ export type LayoutEdge = {
15
+ source: string;
16
+ target: string;
17
+ };
18
+
19
+ export type LayoutResult = Record<string, { x: number; y: number }>;
20
+
21
+ /**
22
+ * Deterministic LR layout. Pinned nodes keep their (x, y). Unpinned nodes
23
+ * are laid out by dagre and returned in the result map.
24
+ */
25
+ export function autoLayout(nodes: LayoutNode[], edges: LayoutEdge[]): LayoutResult {
26
+ const g = new dagre.graphlib.Graph();
27
+ g.setGraph({ rankdir: "LR", nodesep: 60, ranksep: 100, marginx: 24, marginy: 24 });
28
+ g.setDefaultEdgeLabel(() => ({}));
29
+
30
+ for (const n of nodes) {
31
+ g.setNode(n.id, { width: n.width, height: n.height });
32
+ }
33
+ for (const e of edges) {
34
+ if (g.hasNode(e.source) && g.hasNode(e.target)) {
35
+ g.setEdge(e.source, e.target);
36
+ }
37
+ }
38
+
39
+ dagre.layout(g);
40
+
41
+ const result: LayoutResult = {};
42
+ for (const n of nodes) {
43
+ if (n.manuallyPositioned && typeof n.x === "number" && typeof n.y === "number") {
44
+ result[n.id] = { x: n.x, y: n.y };
45
+ } else {
46
+ const laid = g.node(n.id);
47
+ // dagre returns center coords; React Flow expects top-left, so subtract half dims.
48
+ result[n.id] = {
49
+ x: laid.x - n.width / 2,
50
+ y: laid.y - n.height / 2,
51
+ };
52
+ }
53
+ }
54
+ return result;
55
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./CanvasToolbar";
2
+ export * from "./EmptyCanvas";
3
+ export * from "./autoLayout";
4
+ export * from "./longPress";
@@ -0,0 +1,57 @@
1
+ export const LONG_PRESS_MS = 500;
2
+ export const LONG_PRESS_MOVE_TOLERANCE_PX = 10;
3
+
4
+ /**
5
+ * Touch long-press detector for canvas nodes. A finger held still for
6
+ * LONG_PRESS_MS on a node fires `onLongPress` with the press point; any move
7
+ * beyond the tolerance (a pan or a node drag), a lift or a cancel aborts it.
8
+ * After a fired press, `consumeClick()` returns true once, so the caller can
9
+ * swallow the click that the finger lift produces.
10
+ */
11
+ export function createLongPressTracker(params: {
12
+ onLongPress: (press: { target: string; clientX: number; clientY: number }) => void;
13
+ delayMs?: number;
14
+ moveTolerancePx?: number;
15
+ setTimer?: (fn: () => void, ms: number) => unknown;
16
+ clearTimer?: (handle: unknown) => void;
17
+ }) {
18
+ const delay = params.delayMs ?? LONG_PRESS_MS;
19
+ const tolerance = params.moveTolerancePx ?? LONG_PRESS_MOVE_TOLERANCE_PX;
20
+ const setTimer = params.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
21
+ const clearTimer = params.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
22
+
23
+ let timer: unknown = null;
24
+ let start: { x: number; y: number } | null = null;
25
+ let fired = false;
26
+
27
+ const cancel = () => {
28
+ if (timer !== null) clearTimer(timer);
29
+ timer = null;
30
+ start = null;
31
+ };
32
+
33
+ return {
34
+ down(target: string, clientX: number, clientY: number) {
35
+ cancel();
36
+ fired = false;
37
+ start = { x: clientX, y: clientY };
38
+ timer = setTimer(() => {
39
+ timer = null;
40
+ start = null;
41
+ fired = true;
42
+ params.onLongPress({ target, clientX, clientY });
43
+ }, delay);
44
+ },
45
+ move(clientX: number, clientY: number) {
46
+ if (!start) return;
47
+ if (Math.hypot(clientX - start.x, clientY - start.y) > tolerance) cancel();
48
+ },
49
+ up: cancel,
50
+ cancel,
51
+ consumeClick(): boolean {
52
+ const wasFired = fired;
53
+ fired = false;
54
+ return wasFired;
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { RoundPageContainer } from "../../../../components";
5
+ import { SharedProvider } from "../../../../contexts/SharedContext";
6
+ import { Modules } from "../../../../core";
7
+ import { usePageUrlGenerator } from "../../../../hooks";
8
+ import { BreadcrumbItemData } from "../../../../interfaces";
9
+ import { EmailAutomationProvider, useEmailAutomation } from "../../contexts/EmailAutomationContext";
10
+ import { EmailAutomationActiveSwitch } from "../canvas/EmailAutomationActiveSwitch";
11
+ import { EmailAutomationCanvasTab } from "../canvas/EmailAutomationCanvasTab";
12
+ import { EmailAutomationEditor } from "../forms/EmailAutomationEditor";
13
+ import { EmailAutomationRecipientsList } from "../lists/EmailAutomationRecipientsList";
14
+
15
+ export type EmailAutomationContainerProps = {
16
+ automationId: string;
17
+ };
18
+
19
+ export function EmailAutomationContainer({ automationId }: EmailAutomationContainerProps) {
20
+ return (
21
+ <EmailAutomationProvider automationId={automationId}>
22
+ <EmailAutomationShell />
23
+ </EmailAutomationProvider>
24
+ );
25
+ }
26
+
27
+ function EmailAutomationShell() {
28
+ const t = useTranslations();
29
+ const generateUrl = usePageUrlGenerator();
30
+ const { automation, reload } = useEmailAutomation();
31
+
32
+ if (!automation) return null;
33
+
34
+ const breadcrumbs: BreadcrumbItemData[] = [
35
+ { name: t("emailAutomations.admin.title"), href: generateUrl({ page: Modules.EmailAutomation }) },
36
+ { name: automation.name },
37
+ ];
38
+
39
+ // The page chrome (title + header functions) is published here rather than by
40
+ // AdministrationProvider: SharedProvider replaces its value for the subtree,
41
+ // so this one carries the administration title type plus the automation name.
42
+ return (
43
+ <SharedProvider
44
+ value={{
45
+ breadcrumbs,
46
+ title: {
47
+ type: t("emailAutomations.admin.title"),
48
+ element: automation.name,
49
+ functions: [
50
+ <EmailAutomationActiveSwitch key="emailAutomationActiveSwitch" />,
51
+ <EmailAutomationEditor key="emailAutomationEditor" automation={automation} onSuccess={reload} />,
52
+ ],
53
+ },
54
+ }}
55
+ >
56
+ <RoundPageContainer
57
+ module={Modules.EmailAutomation}
58
+ id={automation.id}
59
+ fullWidth
60
+ forceHeader
61
+ // The canvas is sized h-full: it needs the viewport-bound shell and a
62
+ // fillHeight tab, or it resolves to zero height.
63
+ scroll="fixed"
64
+ tabs={[
65
+ {
66
+ sectionKey: "canvas",
67
+ label: t("emailAutomations.tabs.canvas"),
68
+ content: <EmailAutomationCanvasTab />,
69
+ fillHeight: true,
70
+ },
71
+ {
72
+ sectionKey: "recipients",
73
+ label: t("emailAutomations.tabs.recipients"),
74
+ content: <EmailAutomationRecipientsList />,
75
+ },
76
+ ]}
77
+ />
78
+ </SharedProvider>
79
+ );
80
+ }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { RoundPageContainer } from "../../../../components";
4
+ import { Modules } from "../../../../core";
5
+ import { AdministrationProvider } from "../../../administration/contexts/AdministrationContext";
6
+ import { EmailAutomationsList } from "../lists/EmailAutomationsList";
7
+
8
+ /**
9
+ * Page container for the administrative email-automation list.
10
+ *
11
+ * Client component by necessity: `module={Modules.EmailAutomation}` is a
12
+ * registry entry carrying an icon component and methods, which a Server
13
+ * Component cannot pass across the boundary. Routes mount this container
14
+ * instead — the same shape as every other list page.
15
+ *
16
+ * No `forceHeader`: ContentListTable draws its own header row with the title
17
+ * and the create action, exactly as the company and user list pages do.
18
+ */
19
+ export function EmailAutomationsListContainer() {
20
+ return (
21
+ <AdministrationProvider>
22
+ <RoundPageContainer module={Modules.EmailAutomation} fullWidth>
23
+ <EmailAutomationsList fullWidth />
24
+ </RoundPageContainer>
25
+ </AdministrationProvider>
26
+ );
27
+ }