@carlonicora/nextjs-jsonapi 3.9.0 → 3.10.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 (100) hide show
  1. package/dist/{BlockNoteEditor-WY4LFMXQ.js → BlockNoteEditor-4UAIVF3G.js} +19 -19
  2. package/dist/{BlockNoteEditor-WY4LFMXQ.js.map → BlockNoteEditor-4UAIVF3G.js.map} +1 -1
  3. package/dist/BlockNoteEditor-K55A4FS2.css +30 -0
  4. package/dist/BlockNoteEditor-K55A4FS2.css.map +1 -0
  5. package/dist/{BlockNoteEditor-RGMU2O24.mjs → BlockNoteEditor-PFCHUFTD.mjs} +4 -4
  6. package/dist/{BlockNoteViewer-6N2GQWTI.js → BlockNoteViewer-C2UUOBAF.js} +17 -3
  7. package/dist/BlockNoteViewer-C2UUOBAF.js.map +1 -0
  8. package/dist/{BlockNoteViewer-IBQRJ3WM.mjs → BlockNoteViewer-TYLVTIUD.mjs} +17 -3
  9. package/dist/BlockNoteViewer-TYLVTIUD.mjs.map +1 -0
  10. package/dist/billing/index.css +22 -21
  11. package/dist/billing/index.css.map +1 -1
  12. package/dist/billing/index.js +398 -398
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/{chunk-DOLEXUO3.js → chunk-46CQ3RRQ.js} +1271 -1049
  15. package/dist/chunk-46CQ3RRQ.js.map +1 -0
  16. package/dist/{chunk-UYLS3LJW.js → chunk-6J2SQZ3G.js} +11 -1
  17. package/dist/chunk-6J2SQZ3G.js.map +1 -0
  18. package/dist/{chunk-2D4MFVDV.mjs → chunk-IMSIVIIC.mjs} +2 -2
  19. package/dist/{chunk-HDZ2BL6D.mjs → chunk-M5QAHSCY.mjs} +11 -1
  20. package/dist/{chunk-HDZ2BL6D.mjs.map → chunk-M5QAHSCY.mjs.map} +1 -1
  21. package/dist/{chunk-TULJCLZA.js → chunk-V6CGCARD.js} +7 -7
  22. package/dist/{chunk-TULJCLZA.js.map → chunk-V6CGCARD.js.map} +1 -1
  23. package/dist/{chunk-7PNGCZES.mjs → chunk-XZJJ4SZW.mjs} +382 -160
  24. package/dist/chunk-XZJJ4SZW.mjs.map +1 -0
  25. package/dist/client/index.css +22 -21
  26. package/dist/client/index.css.map +1 -1
  27. package/dist/client/index.js +4 -4
  28. package/dist/client/index.mjs +3 -3
  29. package/dist/components/index.css +22 -21
  30. package/dist/components/index.css.map +1 -1
  31. package/dist/components/index.d.mts +44 -13
  32. package/dist/components/index.d.ts +44 -13
  33. package/dist/components/index.js +8 -4
  34. package/dist/components/index.js.map +1 -1
  35. package/dist/components/index.mjs +7 -3
  36. package/dist/contexts/index.css +22 -21
  37. package/dist/contexts/index.css.map +1 -1
  38. package/dist/contexts/index.d.mts +11 -4
  39. package/dist/contexts/index.d.ts +11 -4
  40. package/dist/contexts/index.js +6 -4
  41. package/dist/contexts/index.js.map +1 -1
  42. package/dist/contexts/index.mjs +5 -3
  43. package/dist/core/index.d.mts +13 -0
  44. package/dist/core/index.d.ts +13 -0
  45. package/dist/core/index.js +2 -2
  46. package/dist/core/index.mjs +1 -1
  47. package/dist/features/help/index.css +22 -21
  48. package/dist/features/help/index.css.map +1 -1
  49. package/dist/features/help/index.d.mts +6 -1
  50. package/dist/features/help/index.d.ts +6 -1
  51. package/dist/features/help/index.js +39 -38
  52. package/dist/features/help/index.js.map +1 -1
  53. package/dist/features/help/index.mjs +4 -3
  54. package/dist/features/tokenusage/index.css +22 -21
  55. package/dist/features/tokenusage/index.css.map +1 -1
  56. package/dist/features/tokenusage/index.js +102 -102
  57. package/dist/features/tokenusage/index.js.map +1 -1
  58. package/dist/features/tokenusage/index.mjs +4 -4
  59. package/dist/features/tokenusage/index.mjs.map +1 -1
  60. package/dist/index.js +3 -3
  61. package/dist/index.mjs +2 -2
  62. package/dist/{onboarding.interface-Djyl9qYu.d.mts → onboarding.interface-DVUOUC4y.d.mts} +4 -1
  63. package/dist/{onboarding.interface-Djyl9qYu.d.ts → onboarding.interface-DVUOUC4y.d.ts} +4 -1
  64. package/dist/server/index.js +3 -3
  65. package/dist/server/index.mjs +1 -1
  66. package/package.json +2 -2
  67. package/src/components/containers/RoundPageContainer.tsx +2 -1
  68. package/src/components/containers/RoundPageContainerTitle.tsx +6 -1
  69. package/src/components/containers/TabsContainer.tsx +1 -0
  70. package/src/components/forms/EditorSheet.tsx +2 -1
  71. package/src/components/grids/ContentListGrid.tsx +1 -1
  72. package/src/components/navigations/MobileNavigationBar.tsx +1 -0
  73. package/src/components/tables/ContentListTable.tsx +1 -1
  74. package/src/components/tables/ContentTableSearch.tsx +1 -0
  75. package/src/components/viewers/BlockNoteViewer.tsx +13 -2
  76. package/src/components/viewers/BlockNoteViewerContainer.tsx +4 -1
  77. package/src/features/assistant/components/index.ts +2 -0
  78. package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
  79. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +2 -2
  80. package/src/features/assistant-action/components/ApprovalActionCard.tsx +325 -4
  81. package/src/features/assistant-action/components/__tests__/ApprovalActionCard.spec.tsx +268 -0
  82. package/src/features/assistant-action/data/AssistantAction.ts +12 -0
  83. package/src/features/assistant-action/data/AssistantActionInterface.ts +9 -0
  84. package/src/features/assistant-action/data/__tests__/AssistantAction.spec.ts +11 -0
  85. package/src/features/help/index.ts +1 -0
  86. package/src/features/onboarding/components/OnboardingCard.tsx +1 -2
  87. package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +191 -0
  88. package/src/features/onboarding/contexts/OnboardingContext.tsx +176 -125
  89. package/src/features/onboarding/interfaces/onboarding.interface.ts +4 -0
  90. package/src/features/onboarding/styles/onboarding.css +25 -30
  91. package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +1 -1
  92. package/dist/BlockNoteEditor-YOAJRPWU.css +0 -29
  93. package/dist/BlockNoteEditor-YOAJRPWU.css.map +0 -1
  94. package/dist/BlockNoteViewer-6N2GQWTI.js.map +0 -1
  95. package/dist/BlockNoteViewer-IBQRJ3WM.mjs.map +0 -1
  96. package/dist/chunk-7PNGCZES.mjs.map +0 -1
  97. package/dist/chunk-DOLEXUO3.js.map +0 -1
  98. package/dist/chunk-UYLS3LJW.js.map +0 -1
  99. /package/dist/{BlockNoteEditor-RGMU2O24.mjs.map → BlockNoteEditor-PFCHUFTD.mjs.map} +0 -0
  100. /package/dist/{chunk-2D4MFVDV.mjs.map → chunk-IMSIVIIC.mjs.map} +0 -0
@@ -18,6 +18,15 @@ export type AssistantActionInput = {
18
18
  export interface AssistantActionInterface extends ApiDataInterface {
19
19
  get status(): AssistantActionStatus;
20
20
  get toolName(): string;
21
+ /** Raw JSON string of the frozen tool call's arguments. Audit copy — never rendered. */
22
+ get toolArgs(): string;
23
+ /**
24
+ * JSON string of the operator's presentation payload: the record being
25
+ * written with every referenced id already resolved to a display name.
26
+ * Empty when the action predates the payload (the card then falls back to
27
+ * the attributes in `toolArgs`).
28
+ */
29
+ get proposal(): string;
21
30
  get summary(): string;
22
31
  get resolvedAt(): Date | undefined;
23
32
  get expiresAt(): Date | undefined;
@@ -50,6 +50,7 @@ function makeHydratedData(overrides: Record<string, any> = {}) {
50
50
  status: "pending",
51
51
  toolName: "createNpc",
52
52
  toolArgs: '{"name":"A"}',
53
+ proposal: '{"type":"npcs","attributes":{"name":"A"}}',
53
54
  summary: "Create a new record",
54
55
  threadId: "thread-1",
55
56
  userModuleIds: ["module-1"],
@@ -74,6 +75,14 @@ describe("AssistantAction model", () => {
74
75
  expect(action.summary).toBe("Create a new record");
75
76
  });
76
77
 
78
+ it("rehydrates the resolved proposal payload", () => {
79
+ const action = new AssistantAction();
80
+ action.rehydrate(makeHydratedData() as any);
81
+
82
+ expect(action.toolArgs).toBe('{"name":"A"}');
83
+ expect(action.proposal).toBe('{"type":"npcs","attributes":{"name":"A"}}');
84
+ });
85
+
77
86
  it("parses expiresAt into a Date", () => {
78
87
  const action = new AssistantAction();
79
88
  action.rehydrate(makeHydratedData() as any);
@@ -114,6 +123,8 @@ describe("AssistantAction model", () => {
114
123
  // Missing status defaults to the fail-safe, non-actionable "expired" — never the actionable "pending".
115
124
  expect(action.status).toBe("expired");
116
125
  expect(action.toolName).toBe("");
126
+ // A pre-`proposal` action must degrade to the empty string, never to undefined.
127
+ expect(action.proposal).toBe("");
117
128
  expect(action.summary).toBe("");
118
129
  expect(action.resolvedAt).toBeUndefined();
119
130
  expect(action.expiresAt).toBeUndefined();
@@ -15,6 +15,7 @@ export { HelpArticleBody } from "./components/HelpArticleBody";
15
15
  export { HelpTOC } from "./components/HelpTOC";
16
16
  export { HelpHint } from "./components/HelpHint";
17
17
  export { HelpAskAi } from "./components/HelpAskAi";
18
+ export { HelpAssistantSheet } from "./components/HelpAssistantSheet";
18
19
  export { HelpSearchResultRow } from "./components/HelpSearchResultRow";
19
20
  export { useHelpFilter } from "./hooks/useHelpFilter";
20
21
  export { articleUrl, modeUrl } from "./utils/articleUrl";
@@ -4,7 +4,6 @@ import { X } from "lucide-react";
4
4
  import { Button, Card, CardContent, CardHeader, CardTitle } from "../../../shadcnui";
5
5
  import { OnboardingCardRenderProps } from "../interfaces";
6
6
 
7
- // Matches a360ai ShepherdCard design exactly
8
7
  export function OnboardingCard({
9
8
  step,
10
9
  currentIndex,
@@ -17,7 +16,7 @@ export function OnboardingCard({
17
16
  isLast,
18
17
  }: OnboardingCardRenderProps) {
19
18
  return (
20
- <Card className="w-[320px] relative" data-shepherd-card>
19
+ <Card className="w-[320px] relative" data-onboarding-card>
21
20
  <Button variant="ghost" size="icon" className="absolute end-2 top-2" onClick={onClose} aria-label={labels.close}>
22
21
  <X className="h-4 w-4" />
23
22
  </Button>
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+
5
+ type Hooks = {
6
+ onDestroyed?: () => void;
7
+ steps: Array<{
8
+ element?: () => Element | undefined;
9
+ disableActiveInteraction?: boolean;
10
+ popover: { side: string; align: string; onPopoverRender: (p: { wrapper: HTMLElement }) => void };
11
+ onHighlightStarted?: () => void;
12
+ onDeselected?: () => void;
13
+ }>;
14
+ };
15
+
16
+ const driverMock = vi.hoisted(() => {
17
+ const instance = {
18
+ drive: vi.fn(),
19
+ moveNext: vi.fn(),
20
+ movePrevious: vi.fn(),
21
+ moveTo: vi.fn(),
22
+ destroy: vi.fn(),
23
+ };
24
+ const state: { config?: Hooks } = {};
25
+ const driver = vi.fn((config: Hooks) => {
26
+ state.config = config;
27
+ return instance;
28
+ });
29
+ return { driver, instance, state };
30
+ });
31
+
32
+ vi.mock("driver.js", () => ({ driver: driverMock.driver }));
33
+ vi.mock("driver.js/dist/driver.css", () => ({}));
34
+ vi.mock("../styles/onboarding.css", () => ({}));
35
+
36
+ import { OnboardingProvider, useOnboarding } from "./OnboardingContext";
37
+ import type { OnboardingStepConfig } from "../interfaces";
38
+
39
+ const steps: OnboardingStepConfig[] = [
40
+ { id: "a", title: "First", content: "one", selector: "[data-help='x']", side: "bottom-start" },
41
+ { id: "b", title: "Second", content: "two" },
42
+ ];
43
+
44
+ function wrap(onTourEnd = vi.fn()) {
45
+ const wrapper = ({ children }: { children: ReactNode }) => (
46
+ <OnboardingProvider onTourEnd={onTourEnd}>{children}</OnboardingProvider>
47
+ );
48
+ return { wrapper, onTourEnd };
49
+ }
50
+
51
+ describe("OnboardingProvider on driver.js", () => {
52
+ beforeEach(() => {
53
+ driverMock.driver.mockClear();
54
+ driverMock.instance.drive.mockClear();
55
+ driverMock.instance.destroy.mockClear();
56
+ driverMock.instance.moveNext.mockClear();
57
+ driverMock.state.config = undefined;
58
+ });
59
+
60
+ it("maps steps to driver steps and starts the tour", () => {
61
+ const { wrapper } = wrap();
62
+ const { result } = renderHook(() => useOnboarding(), { wrapper });
63
+ act(() => result.current.startTour("t", steps));
64
+
65
+ const config = driverMock.state.config!;
66
+ expect(driverMock.instance.drive).toHaveBeenCalledTimes(1);
67
+ expect(config.steps).toHaveLength(2);
68
+ const resolveElement = config.steps[0].element!;
69
+ expect(typeof resolveElement).toBe("function");
70
+ expect(resolveElement()).toBeUndefined();
71
+ const target = document.createElement("div");
72
+ target.setAttribute("data-help", "x");
73
+ document.body.appendChild(target);
74
+ // jsdom lays nothing out, so getClientRects() is always empty; the resolver
75
+ // reads it to catch a display:none ancestor, so give it one box here.
76
+ const rects = vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
77
+ expect(resolveElement()).toBe(target);
78
+ rects.mockRestore();
79
+ target.remove();
80
+ expect(resolveElement()).toBeUndefined();
81
+ expect(config.steps[0].popover.side).toBe("bottom");
82
+ expect(config.steps[0].popover.align).toBe("start");
83
+ expect(config.steps[0].disableActiveInteraction).toBe(true);
84
+ expect(config.steps[1].element).toBeUndefined();
85
+ expect(config.steps[1].popover.align).toBe("center");
86
+ expect(result.current.totalSteps).toBe(2);
87
+ expect(result.current.activeTourId).toBe("t");
88
+ });
89
+
90
+ it("renders the card into the popover wrapper and tracks the active step", () => {
91
+ const { wrapper } = wrap();
92
+ const { result } = renderHook(() => useOnboarding(), { wrapper });
93
+ act(() => result.current.startTour("t", steps));
94
+ const config = driverMock.state.config!;
95
+
96
+ const popoverWrapper = document.createElement("div");
97
+ act(() => {
98
+ config.steps[0].onHighlightStarted?.();
99
+ config.steps[0].popover.onPopoverRender({ wrapper: popoverWrapper });
100
+ });
101
+
102
+ expect(result.current.isTourActive).toBe(true);
103
+ expect(result.current.currentStepIndex).toBe(0);
104
+ expect(popoverWrapper.querySelector("[data-onboarding-card]")).not.toBeNull();
105
+ expect(popoverWrapper.textContent).toContain("First");
106
+ });
107
+
108
+ it("mounts the card synchronously, before driver.js positions the popover", () => {
109
+ const { wrapper } = wrap();
110
+ const { result } = renderHook(() => useOnboarding(), { wrapper });
111
+ act(() => result.current.startTour("t", steps));
112
+ const config = driverMock.state.config!;
113
+
114
+ // driver.js calls onPopoverRender, then measures and positions the popover in the
115
+ // same synchronous pass; the card must already be there at full size.
116
+ const popoverWrapper = document.createElement("div");
117
+ config.steps[0].popover.onPopoverRender({ wrapper: popoverWrapper });
118
+ expect(popoverWrapper.querySelector("[data-onboarding-card]")).not.toBeNull();
119
+ expect(popoverWrapper.textContent).toContain("First");
120
+ });
121
+
122
+ it("reports 'finished' when the last step's Finish is pressed", () => {
123
+ const { wrapper, onTourEnd } = wrap();
124
+ const { result } = renderHook(() => useOnboarding(), { wrapper });
125
+ act(() => result.current.startTour("t", steps));
126
+ const config = driverMock.state.config!;
127
+ const popoverWrapper = document.createElement("div");
128
+ act(() => {
129
+ config.steps[1].onHighlightStarted?.();
130
+ config.steps[1].popover.onPopoverRender({ wrapper: popoverWrapper });
131
+ });
132
+ const finish = Array.from(popoverWrapper.querySelectorAll("button")).find((b) => b.textContent === "Finish")!;
133
+ act(() => finish.click());
134
+ act(() => config.onDestroyed?.());
135
+
136
+ expect(driverMock.instance.destroy).toHaveBeenCalled();
137
+ expect(onTourEnd).toHaveBeenCalledWith("t", "finished");
138
+ expect(result.current.isTourActive).toBe(false);
139
+ expect(result.current.activeTourId).toBeNull();
140
+ });
141
+
142
+ it("ends the tour even when driver.js never fires onDestroyed", () => {
143
+ /* driver.js skips onDeselected/onDestroyed unless its 400ms transition callback has
144
+ set __activeElement and __activeStep, so a fast Finish click destroys the tour
145
+ without the hook ever running. The provider must still tear itself down. */
146
+ const { wrapper, onTourEnd } = wrap();
147
+ const { result } = renderHook(() => useOnboarding(), { wrapper });
148
+ act(() => result.current.startTour("t", steps));
149
+ const config = driverMock.state.config!;
150
+ const popoverWrapper = document.createElement("div");
151
+ act(() => {
152
+ config.steps[1].onHighlightStarted?.();
153
+ config.steps[1].popover.onPopoverRender({ wrapper: popoverWrapper });
154
+ });
155
+ expect(result.current.isTourActive).toBe(true);
156
+
157
+ const finish = Array.from(popoverWrapper.querySelectorAll("button")).find((b) => b.textContent === "Finish")!;
158
+ act(() => finish.click());
159
+
160
+ expect(driverMock.instance.destroy).toHaveBeenCalled();
161
+ expect(result.current.isTourActive).toBe(false);
162
+ expect(result.current.activeTourId).toBeNull();
163
+ expect(onTourEnd).toHaveBeenCalledWith("t", "finished");
164
+
165
+ // A late onDestroyed must not end the tour a second time.
166
+ act(() => config.onDestroyed?.());
167
+ expect(onTourEnd).toHaveBeenCalledTimes(1);
168
+ });
169
+
170
+ it("reports 'dismissed' when driver is destroyed without a card action", () => {
171
+ const { wrapper, onTourEnd } = wrap();
172
+ const { result } = renderHook(() => useOnboarding(), { wrapper });
173
+ act(() => result.current.startTour("t", steps));
174
+ act(() => driverMock.state.config!.onDestroyed?.());
175
+ expect(onTourEnd).toHaveBeenCalledWith("t", "dismissed");
176
+ });
177
+
178
+ it("warns and does nothing for an unknown tour id", () => {
179
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
180
+ const { wrapper } = wrap();
181
+ const { result } = renderHook(() => useOnboarding(), { wrapper });
182
+ act(() => result.current.startTour("missing"));
183
+ expect(driverMock.driver).not.toHaveBeenCalled();
184
+ expect(warn).toHaveBeenCalled();
185
+ warn.mockRestore();
186
+ });
187
+
188
+ it("throws when used outside the provider", () => {
189
+ expect(() => renderHook(() => useOnboarding())).toThrow(/OnboardingProvider/);
190
+ });
191
+ });
@@ -1,20 +1,61 @@
1
1
  "use client";
2
2
 
3
3
  import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
4
+ import { flushSync } from "react-dom";
4
5
  import { createRoot, Root } from "react-dom/client";
5
- import Shepherd from "shepherd.js";
6
- import "shepherd.js/dist/css/shepherd.css";
6
+ import { driver, type Driver, type DriveStep, type PopoverDOM } from "driver.js";
7
+ import "driver.js/dist/driver.css";
7
8
  import { OnboardingCard } from "../components/OnboardingCard";
8
9
  import {
9
10
  DEFAULT_ONBOARDING_LABELS,
10
11
  OnboardingContextValue,
11
12
  OnboardingProviderProps,
12
13
  OnboardingStepConfig,
14
+ OnboardingStepSide,
15
+ OnboardingTourEndReason,
13
16
  } from "../interfaces";
14
17
  import "../styles/onboarding.css";
15
18
 
16
19
  const OnboardingContext = createContext<OnboardingContextValue | null>(null);
17
20
 
21
+ type DriverSide = "top" | "right" | "bottom" | "left";
22
+ type DriverAlign = "start" | "center" | "end";
23
+
24
+ /** "bottom-start" → { side: "bottom", align: "start" }; bare side → align "center". */
25
+ export function mapOnboardingSide(side?: OnboardingStepSide): { side: DriverSide; align: DriverAlign } {
26
+ if (!side) return { side: "bottom", align: "center" };
27
+ const [s, a] = side.split("-") as [DriverSide, DriverAlign | undefined];
28
+ return { side: s, align: a ?? "center" };
29
+ }
30
+
31
+ /**
32
+ * Resolves a step's target, treating a hidden element as a missing one.
33
+ *
34
+ * driver.js only honours `skipMissingElement` when the element resolves to
35
+ * nothing. A selector string cannot express "present in the DOM but not
36
+ * displayed", so targets that exist yet are `display: none` (the section rail
37
+ * below `md`, for instance) would otherwise highlight an empty box. Returning
38
+ * `undefined` from the resolver takes the exact same path as an absent
39
+ * selector, so the step is skipped.
40
+ *
41
+ * The check is `getClientRects()`, not the element's own computed style: an
42
+ * element inherits invisibility from a `display: none` ancestor, whose own
43
+ * style says nothing. An element with no client rects generates no boxes, so
44
+ * it is not displayed, whatever its own `display` value is.
45
+ *
46
+ * The cast exists because driver.js types the resolver as `() => Element`,
47
+ * while its runtime (`typeof e === "function" ? e() : ...`) treats any falsy
48
+ * result as missing.
49
+ */
50
+ function resolveVisibleElement(selector: string): DriveStep["element"] {
51
+ return (() => {
52
+ const element = document.querySelector(selector);
53
+ if (!element) return undefined;
54
+ if (element.getClientRects().length === 0) return undefined;
55
+ return element;
56
+ }) as DriveStep["element"];
57
+ }
58
+
18
59
  export function OnboardingProvider({
19
60
  children,
20
61
  tours = [],
@@ -22,72 +63,78 @@ export function OnboardingProvider({
22
63
  labels = DEFAULT_ONBOARDING_LABELS,
23
64
  renderCard,
24
65
  zIndex: _zIndex = 9999,
66
+ onTourEnd,
25
67
  }: OnboardingProviderProps) {
26
68
  const [isTourActive, setIsTourActive] = useState(false);
27
69
  const [activeTourId, setActiveTourId] = useState<string | null>(null);
28
70
  const [currentStepIndex, setCurrentStepIndex] = useState(0);
29
71
  const [totalSteps, setTotalSteps] = useState(0);
30
72
 
31
- const tourRef = useRef<InstanceType<typeof Shepherd.Tour> | null>(null);
32
- const rootsRef = useRef<Map<string, Root>>(new Map());
73
+ const driverRef = useRef<Driver | null>(null);
74
+ const rootsRef = useRef<Map<number, Root>>(new Map());
75
+ const endReasonRef = useRef<OnboardingTourEndReason>("dismissed");
76
+ const activeTourIdRef = useRef<string | null>(null);
77
+
78
+ // Unmount on the next tick: a root cannot unmount synchronously from inside
79
+ // an event handler it rendered (React warns and skips the unmount).
80
+ const unmountRoot = (root: Root) => setTimeout(() => root.unmount(), 0);
33
81
 
34
82
  const cleanupRoots = useCallback(() => {
35
- rootsRef.current.forEach((root) => {
36
- try {
37
- root.unmount();
38
- } catch (_e) {
39
- // Root may already be unmounted
40
- }
41
- });
83
+ rootsRef.current.forEach((root) => unmountRoot(root));
42
84
  rootsRef.current.clear();
43
85
  }, []);
44
86
 
45
- const closeTour = useCallback(() => {
46
- if (tourRef.current) {
47
- tourRef.current.cancel();
48
- tourRef.current = null;
49
- }
50
- cleanupRoots();
87
+ const resetState = useCallback(() => {
51
88
  setIsTourActive(false);
52
89
  setActiveTourId(null);
53
90
  setCurrentStepIndex(0);
54
91
  setTotalSteps(0);
55
- }, [cleanupRoots]);
56
-
57
- const nextStep = useCallback(() => {
58
- if (tourRef.current) {
59
- tourRef.current.next();
60
- }
92
+ driverRef.current = null;
93
+ activeTourIdRef.current = null;
61
94
  }, []);
62
95
 
63
- const previousStep = useCallback(() => {
64
- if (tourRef.current) {
65
- tourRef.current.back();
66
- }
67
- }, []);
96
+ /**
97
+ * Ends the tour on our side: unmount the cards, reset the state, report the reason.
98
+ *
99
+ * It cannot be left to driver.js's `onDestroyed`: driver only fires that hook when
100
+ * it holds both an active element and an active step, and both are set by a step's
101
+ * 400ms transition callback. A Next/Finish click that lands before the animation
102
+ * settles therefore destroys the DOM without ever calling the hook, and the provider
103
+ * would stay `isTourActive` forever. Every path that calls `destroy()` calls this
104
+ * straight after; `resetState()` nulls `driverRef.current`, so a later `onDestroyed`
105
+ * is a no-op.
106
+ */
107
+ const teardown = useCallback(
108
+ (reason: OnboardingTourEndReason) => {
109
+ if (!driverRef.current) return;
110
+ const id = activeTourIdRef.current;
111
+ cleanupRoots();
112
+ resetState();
113
+ if (id) onTourEnd?.(id, reason);
114
+ },
115
+ [cleanupRoots, resetState, onTourEnd],
116
+ );
68
117
 
69
- const goToStep = useCallback((index: number) => {
70
- if (tourRef.current) {
71
- tourRef.current.show(index);
118
+ const closeTour = useCallback(() => {
119
+ if (driverRef.current) {
120
+ driverRef.current.destroy();
121
+ teardown("dismissed");
122
+ return;
72
123
  }
73
- }, []);
124
+ cleanupRoots();
125
+ resetState();
126
+ }, [cleanupRoots, resetState, teardown]);
127
+
128
+ const nextStep = useCallback(() => driverRef.current?.moveNext(), []);
129
+ const previousStep = useCallback(() => driverRef.current?.movePrevious(), []);
130
+ const goToStep = useCallback((index: number) => driverRef.current?.moveTo(index), []);
74
131
 
75
132
  const startTour = useCallback(
76
133
  (tourId: string, steps?: OnboardingStepConfig[]) => {
77
134
  if (typeof window === "undefined") return;
135
+ if (driverRef.current) closeTour();
78
136
 
79
- // Close any existing tour
80
- if (tourRef.current) {
81
- closeTour();
82
- }
83
-
84
- // Get steps from provided steps or find tour by ID
85
- let tourSteps = steps;
86
- if (!tourSteps) {
87
- const tour = tours.find((t) => t.id === tourId);
88
- tourSteps = tour?.steps;
89
- }
90
-
137
+ const tourSteps = steps ?? tours.find((t) => t.id === tourId)?.steps;
91
138
  if (!tourSteps || tourSteps.length === 0) {
92
139
  console.warn(`No steps found for tour: ${tourId}`);
93
140
  return;
@@ -95,96 +142,102 @@ export function OnboardingProvider({
95
142
 
96
143
  setTotalSteps(tourSteps.length);
97
144
  setActiveTourId(tourId);
145
+ activeTourIdRef.current = tourId;
146
+ endReasonRef.current = "dismissed";
98
147
 
99
- const tour = new Shepherd.Tour({
100
- useModalOverlay: true,
101
- defaultStepOptions: {
102
- classes: "shepherd-theme-custom",
103
- scrollTo: { behavior: "smooth", block: "center" },
104
- cancelIcon: { enabled: false },
105
- },
106
- });
148
+ const move = (direction: "next" | "previous", from: number) => {
149
+ const target = tourSteps[direction === "next" ? from + 1 : from - 1];
150
+ const go = () => (direction === "next" ? driverRef.current?.moveNext() : driverRef.current?.movePrevious());
151
+ if (target?.showDelay) setTimeout(go, target.showDelay);
152
+ else go();
153
+ };
107
154
 
108
- // Set up tour event listeners
109
- tour.on("show", () => setIsTourActive(true));
110
- tour.on("cancel", () => {
111
- cleanupRoots();
112
- setIsTourActive(false);
113
- setActiveTourId(null);
114
- setCurrentStepIndex(0);
115
- setTotalSteps(0);
116
- tourRef.current = null;
117
- });
118
- tour.on("complete", () => {
119
- cleanupRoots();
120
- setIsTourActive(false);
121
- setActiveTourId(null);
122
- setCurrentStepIndex(0);
123
- setTotalSteps(0);
124
- tourRef.current = null;
125
- });
155
+ const end = (reason: OnboardingTourEndReason) => {
156
+ endReasonRef.current = reason;
157
+ driverRef.current?.destroy();
158
+ teardown(reason);
159
+ };
126
160
 
127
- tourSteps.forEach((stepConfig, index) => {
128
- const stepId = stepConfig.id || `step-${index}`;
129
-
130
- tour.addStep({
131
- id: stepId,
132
- attachTo: stepConfig.selector ? { element: stepConfig.selector, on: stepConfig.side || "bottom" } : undefined,
133
- arrow: true,
134
- // Use text callback to return DOM element with React content
135
- text: () => {
136
- const container = document.createElement("div");
137
- const root = createRoot(container);
138
- rootsRef.current.set(stepId, root);
139
-
140
- const cardProps = {
141
- step: stepConfig,
142
- currentIndex: index,
143
- totalSteps: tourSteps!.length,
144
- labels,
145
- onNext: () => tour.next(),
146
- onPrevious: () => tour.back(),
147
- onClose: () => tour.cancel(),
148
- onSkip: () => tour.cancel(),
149
- isFirst: index === 0,
150
- isLast: index === tourSteps!.length - 1,
151
- };
152
-
153
- root.render(renderCard ? renderCard(cardProps) : <OnboardingCard {...cardProps} />);
154
-
155
- return container;
156
- },
157
- buttons: [], // Empty - our card handles navigation
158
- beforeShowPromise: stepConfig.showDelay
159
- ? () => new Promise((resolve) => setTimeout(resolve, stepConfig.showDelay))
160
- : undefined,
161
- canClickTarget: stepConfig.canClickTarget ?? false,
162
- modalOverlayOpeningPadding: 8,
163
- modalOverlayOpeningRadius: 8,
164
- when: {
165
- show: () => {
166
- setCurrentStepIndex(index);
167
- stepConfig.onShow?.();
168
- },
169
- hide: () => {
170
- stepConfig.onHide?.();
161
+ const driveSteps: DriveStep[] = tourSteps.map((stepConfig, index) => {
162
+ const { side, align } = mapOnboardingSide(stepConfig.side);
163
+ return {
164
+ element: stepConfig.selector ? resolveVisibleElement(stepConfig.selector) : undefined,
165
+ disableActiveInteraction: !(stepConfig.canClickTarget ?? false),
166
+ popover: {
167
+ side,
168
+ align,
169
+ showButtons: [],
170
+ popoverClass: ["onboarding-popover", stepConfig.className].filter(Boolean).join(" "),
171
+ onPopoverRender: (popover: PopoverDOM) => {
172
+ popover.wrapper.querySelectorAll("[data-onboarding-root]").forEach((n) => n.remove());
173
+ const container = document.createElement("div");
174
+ container.setAttribute("data-onboarding-root", "");
175
+ const root = createRoot(container);
176
+ /* driver.js re-renders the popover on refresh(), so the same step can
177
+ render twice; drop the root the previous render left behind. */
178
+ const prev = rootsRef.current.get(index);
179
+ if (prev) unmountRoot(prev);
180
+ rootsRef.current.set(index, root);
181
+ const cardProps = {
182
+ step: stepConfig,
183
+ currentIndex: index,
184
+ totalSteps: tourSteps.length,
185
+ labels,
186
+ onNext: () => move("next", index),
187
+ onPrevious: () => move("previous", index),
188
+ onClose: () => end(index === tourSteps.length - 1 ? "finished" : "skipped"),
189
+ onSkip: () => end("skipped"),
190
+ isFirst: index === 0,
191
+ isLast: index === tourSteps.length - 1,
192
+ };
193
+ /* driver.js positions the popover right after this hook returns (renderPopover
194
+ calls onRender, then repositionPopover), so the card has to be in the DOM at
195
+ its real size by then — an async render would be measured as an empty box and
196
+ the card would overflow the viewport near a page edge. */
197
+ flushSync(() => {
198
+ root.render(renderCard ? renderCard(cardProps) : <OnboardingCard {...cardProps} />);
199
+ });
200
+ popover.wrapper.appendChild(container);
171
201
  },
172
202
  },
173
- });
203
+ onHighlightStarted: () => {
204
+ setIsTourActive(true);
205
+ setCurrentStepIndex(index);
206
+ stepConfig.onShow?.();
207
+ },
208
+ onDeselected: () => {
209
+ stepConfig.onHide?.();
210
+ const root = rootsRef.current.get(index);
211
+ if (root) {
212
+ rootsRef.current.delete(index);
213
+ unmountRoot(root);
214
+ }
215
+ },
216
+ };
217
+ });
218
+
219
+ const instance = driver({
220
+ steps: driveSteps,
221
+ animate: true,
222
+ allowClose: true,
223
+ overlayOpacity: 0.75,
224
+ stagePadding: 8,
225
+ stageRadius: 8,
226
+ smoothScroll: true,
227
+ skipMissingElement: true,
228
+ showButtons: [],
229
+ onDestroyed: () => teardown(endReasonRef.current),
174
230
  });
175
231
 
176
- tourRef.current = tour;
177
- tour.start();
232
+ driverRef.current = instance;
233
+ instance.drive();
178
234
  },
179
- [tours, labels, renderCard, closeTour, cleanupRoots],
235
+ [tours, labels, renderCard, closeTour, teardown],
180
236
  );
181
237
 
182
- // Cleanup on unmount
183
238
  useEffect(() => {
184
239
  return () => {
185
- if (tourRef.current) {
186
- tourRef.current.cancel();
187
- }
240
+ driverRef.current?.destroy();
188
241
  cleanupRoots();
189
242
  };
190
243
  }, [cleanupRoots]);
@@ -206,8 +259,6 @@ export function OnboardingProvider({
206
259
 
207
260
  export function useOnboarding(): OnboardingContextValue {
208
261
  const context = useContext(OnboardingContext);
209
- if (!context) {
210
- throw new Error("useOnboarding must be used within an OnboardingProvider");
211
- }
262
+ if (!context) throw new Error("useOnboarding must be used within an OnboardingProvider");
212
263
  return context;
213
264
  }
@@ -70,6 +70,8 @@ export interface OnboardingContextValue {
70
70
  totalSteps: number;
71
71
  }
72
72
 
73
+ export type OnboardingTourEndReason = "finished" | "skipped" | "dismissed";
74
+
73
75
  export interface OnboardingProviderProps {
74
76
  children: React.ReactNode;
75
77
  tours?: OnboardingTourConfig[];
@@ -77,6 +79,8 @@ export interface OnboardingProviderProps {
77
79
  labels?: OnboardingLabels;
78
80
  renderCard?: (props: OnboardingCardRenderProps) => ReactNode;
79
81
  zIndex?: number;
82
+ /** Fires once when a tour ends. "finished" = last-step Finish, "skipped" = card Skip/close, "dismissed" = overlay click or Escape. */
83
+ onTourEnd?: (tourId: string, reason: OnboardingTourEndReason) => void;
80
84
  }
81
85
 
82
86
  export interface OnboardingCardRenderProps {