@carlonicora/nextjs-jsonapi 1.108.0 → 1.109.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 (166) hide show
  1. package/dist/{AssistantInterface-BYgI5z1-.d.mts → AssistantInterface-B1c8FhGA.d.mts} +2 -0
  2. package/dist/{AssistantInterface-DfDcz0gJ.d.ts → AssistantInterface-BBUHxOCd.d.ts} +2 -0
  3. package/dist/{AssistantMessageInterface-BpEhx2pC.d.ts → AssistantMessageInterface-Cs1yb-gF.d.ts} +3 -1
  4. package/dist/{AssistantMessageInterface-DJ3Me16Y.d.mts → AssistantMessageInterface-DQ3mH5L8.d.mts} +3 -1
  5. package/dist/{AuthComponent-B6DIk8Vf.d.ts → AuthComponent-Cd7lcYif.d.ts} +1 -1
  6. package/dist/{AuthComponent-BKI0ZbtD.d.mts → AuthComponent-DdxCFgUZ.d.mts} +1 -1
  7. package/dist/{BlockNoteEditor-2AXSTGGG.js → BlockNoteEditor-3XYBZLWO.js} +20 -19
  8. package/dist/BlockNoteEditor-3XYBZLWO.js.map +1 -0
  9. package/dist/{BlockNoteEditor-XVIBGXHF.mjs → BlockNoteEditor-EBFZG7AL.mjs} +5 -4
  10. package/dist/{BlockNoteEditor-XVIBGXHF.mjs.map → BlockNoteEditor-EBFZG7AL.mjs.map} +1 -1
  11. package/dist/{auth.interface-BBUgMZzs.d.ts → auth.interface-8b601idJ.d.ts} +1 -1
  12. package/dist/{auth.interface-XYEREOD6.d.mts → auth.interface-CXBF8Mhi.d.mts} +1 -1
  13. package/dist/billing/index.js +347 -346
  14. package/dist/billing/index.js.map +1 -1
  15. package/dist/billing/index.mjs +4 -3
  16. package/dist/billing/index.mjs.map +1 -1
  17. package/dist/chunk-3J7RQBF3.js +123 -0
  18. package/dist/chunk-3J7RQBF3.js.map +1 -0
  19. package/dist/{chunk-VLDLERJN.js → chunk-7E3O52U5.js} +15 -8
  20. package/dist/chunk-7E3O52U5.js.map +1 -0
  21. package/dist/{chunk-RXXZGPC3.js → chunk-CFI4WZ5R.js} +159 -113
  22. package/dist/chunk-CFI4WZ5R.js.map +1 -0
  23. package/dist/chunk-CQID6RCF.mjs +38 -0
  24. package/dist/chunk-CQID6RCF.mjs.map +1 -0
  25. package/dist/{chunk-56XBGQGU.mjs → chunk-CRTVAQEK.mjs} +42 -27
  26. package/dist/chunk-CRTVAQEK.mjs.map +1 -0
  27. package/dist/{chunk-N3NVIPSU.mjs → chunk-MSNNAHDB.mjs} +129 -83
  28. package/dist/{chunk-N3NVIPSU.mjs.map → chunk-MSNNAHDB.mjs.map} +1 -1
  29. package/dist/chunk-MZTKPPET.mjs +123 -0
  30. package/dist/chunk-MZTKPPET.mjs.map +1 -0
  31. package/dist/{chunk-HC3JFN3C.js → chunk-UHO3KUUH.js} +838 -823
  32. package/dist/chunk-UHO3KUUH.js.map +1 -0
  33. package/dist/{chunk-CFECWLHH.mjs → chunk-UOYIWJEJ.mjs} +10 -3
  34. package/dist/chunk-UOYIWJEJ.mjs.map +1 -0
  35. package/dist/chunk-YQQHAFBS.js +38 -0
  36. package/dist/chunk-YQQHAFBS.js.map +1 -0
  37. package/dist/client/index.d.mts +8 -16
  38. package/dist/client/index.d.ts +8 -16
  39. package/dist/client/index.js +5 -4
  40. package/dist/client/index.js.map +1 -1
  41. package/dist/client/index.mjs +4 -3
  42. package/dist/components/index.d.mts +6 -5
  43. package/dist/components/index.d.ts +6 -5
  44. package/dist/components/index.js +5 -4
  45. package/dist/components/index.js.map +1 -1
  46. package/dist/components/index.mjs +4 -3
  47. package/dist/{config-CLQynoaa.d.ts → config-CN23v3eJ.d.ts} +4 -1
  48. package/dist/{config-k61pe_o2.d.mts → config-gh88Qn4h.d.mts} +4 -1
  49. package/dist/contexts/index.d.mts +18 -7
  50. package/dist/contexts/index.d.ts +18 -7
  51. package/dist/contexts/index.js +5 -4
  52. package/dist/contexts/index.js.map +1 -1
  53. package/dist/contexts/index.mjs +4 -3
  54. package/dist/core/index.d.mts +44 -11
  55. package/dist/core/index.d.ts +44 -11
  56. package/dist/core/index.js +2 -2
  57. package/dist/core/index.mjs +1 -1
  58. package/dist/features/help/index.css +29 -0
  59. package/dist/features/help/index.css.map +1 -0
  60. package/dist/features/help/index.d.mts +115 -0
  61. package/dist/features/help/index.d.ts +115 -0
  62. package/dist/features/help/index.js +532 -0
  63. package/dist/features/help/index.js.map +1 -0
  64. package/dist/features/help/index.mjs +532 -0
  65. package/dist/features/help/index.mjs.map +1 -0
  66. package/dist/features/help/server/createHelpAssetRouteHandler.d.mts +11 -0
  67. package/dist/features/help/server/createHelpAssetRouteHandler.d.ts +11 -0
  68. package/dist/features/help/server/createHelpAssetRouteHandler.js +43 -0
  69. package/dist/features/help/server/createHelpAssetRouteHandler.js.map +1 -0
  70. package/dist/features/help/server/createHelpAssetRouteHandler.mjs +43 -0
  71. package/dist/features/help/server/createHelpAssetRouteHandler.mjs.map +1 -0
  72. package/dist/features/help/server.d.mts +71 -0
  73. package/dist/features/help/server.d.ts +71 -0
  74. package/dist/features/help/server.js +123 -0
  75. package/dist/features/help/server.js.map +1 -0
  76. package/dist/features/help/server.mjs +123 -0
  77. package/dist/features/help/server.mjs.map +1 -0
  78. package/dist/help-content-config.interface-B9L02u9i.d.mts +50 -0
  79. package/dist/help-content-config.interface-B9L02u9i.d.ts +50 -0
  80. package/dist/index.d.mts +10 -8
  81. package/dist/index.d.ts +10 -8
  82. package/dist/index.js +4 -3
  83. package/dist/index.js.map +1 -1
  84. package/dist/index.mjs +3 -2
  85. package/dist/{notification.interface-aLEJbA_g.d.ts → notification.interface-C1T1C2ee.d.ts} +1 -100
  86. package/dist/{notification.interface-DLZGtV7Z.d.mts → notification.interface-DIxR23eS.d.mts} +1 -100
  87. package/dist/{s3.service-CVgLWaDc.d.mts → s3.service-0BTClOYO.d.mts} +2 -2
  88. package/dist/{s3.service-SLlX0Zbz.d.ts → s3.service-CT27Fm1s.d.ts} +2 -2
  89. package/dist/server/index.d.mts +4 -3
  90. package/dist/server/index.d.ts +4 -3
  91. package/dist/server/index.js +3 -3
  92. package/dist/server/index.mjs +1 -1
  93. package/dist/types-CQSjy7et.d.mts +101 -0
  94. package/dist/types-DHOxe8rc.d.ts +101 -0
  95. package/dist/usePageUrlGenerator-tjq2mlDV.d.ts +14 -0
  96. package/dist/usePageUrlGenerator-uOnyJ6j2.d.mts +14 -0
  97. package/dist/{useSocket-BkxHHujj.d.mts → useSocket-B1fMIr17.d.mts} +1 -1
  98. package/dist/{useSocket-CMDjWFYm.d.ts → useSocket-BdJTBXKv.d.ts} +1 -1
  99. package/package.json +20 -1
  100. package/src/client/config.ts +9 -1
  101. package/src/core/registry/helpStore.ts +45 -0
  102. package/src/features/assistant/contexts/AssistantContext.tsx +35 -19
  103. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +66 -6
  104. package/src/features/assistant/data/Assistant.ts +2 -0
  105. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  106. package/src/features/assistant/data/AssistantService.ts +18 -8
  107. package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +6 -4
  108. package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +5 -1
  109. package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -1
  110. package/src/features/assistant-message/data/AssistantMessage.ts +27 -1
  111. package/src/features/assistant-message/data/AssistantMessageInterface.ts +1 -0
  112. package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +7 -3
  113. package/src/features/help/components/HelpArticleBody.tsx +54 -0
  114. package/src/features/help/components/HelpAskAi.tsx +36 -0
  115. package/src/features/help/components/HelpAssistantSheet.tsx +53 -0
  116. package/src/features/help/components/HelpHeader.tsx +40 -0
  117. package/src/features/help/components/HelpHint.tsx +77 -0
  118. package/src/features/help/components/HelpSearchResultRow.tsx +51 -0
  119. package/src/features/help/components/HelpSideNav.tsx +84 -0
  120. package/src/features/help/components/HelpTOC.tsx +49 -0
  121. package/src/features/help/components/__tests__/HelpAskAi.spec.tsx +68 -0
  122. package/src/features/help/components/__tests__/HelpAssistantSheet.spec.tsx +36 -0
  123. package/src/features/help/components/__tests__/HelpHint.spec.tsx +50 -0
  124. package/src/features/help/components/__tests__/HelpSearchResultRow.spec.tsx +59 -0
  125. package/src/features/help/components/__tests__/HelpSideNav.spec.tsx +52 -0
  126. package/src/features/help/components/mdx/Callout.tsx +21 -0
  127. package/src/features/help/components/mdx/EntityRef.tsx +18 -0
  128. package/src/features/help/components/mdx/KeyBinding.tsx +6 -0
  129. package/src/features/help/components/mdx/Related.tsx +33 -0
  130. package/src/features/help/components/mdx/Screenshot.tsx +9 -0
  131. package/src/features/help/components/mdx/Steps.tsx +21 -0
  132. package/src/features/help/components/mdx/Video.tsx +8 -0
  133. package/src/features/help/components/mdx/mdx-server-components.ts +23 -0
  134. package/src/features/help/components/mdx/mdxComponents.ts +9 -0
  135. package/src/features/help/contexts/HelpContext.spec.tsx +28 -0
  136. package/src/features/help/contexts/HelpContext.tsx +24 -0
  137. package/src/features/help/hooks/useHelp.ts +1 -0
  138. package/src/features/help/hooks/useHelpArticle.ts +7 -0
  139. package/src/features/help/hooks/useHelpFilter.ts +27 -0
  140. package/src/features/help/hooks/useHelpManifest.ts +5 -0
  141. package/src/features/help/i18n-keys.ts +34 -0
  142. package/src/features/help/index.ts +27 -0
  143. package/src/features/help/interfaces/help-content-config.interface.ts +17 -0
  144. package/src/features/help/server/__tests__/createHelpAssetRouteHandler.spec.ts +43 -0
  145. package/src/features/help/server/createHelpAssetRouteHandler.ts +35 -0
  146. package/src/features/help/server/generateHelpArticleMetadata.ts +18 -0
  147. package/src/features/help/server/generateHelpArticleStaticParams.ts +7 -0
  148. package/src/features/help/server/generateHelpModeStaticParams.ts +5 -0
  149. package/src/features/help/server/getHelpContent.ts +17 -0
  150. package/src/features/help/server/index.ts +8 -0
  151. package/src/features/help/server/serializeHelpArticle.tsx +46 -0
  152. package/src/features/help/server-entry.ts +20 -0
  153. package/src/features/help/types/help-article.types.ts +37 -0
  154. package/src/features/help/utils/__tests__/helpNavigation.spec.ts +70 -0
  155. package/src/features/help/utils/articleUrl.ts +13 -0
  156. package/src/features/help/utils/helpNavigation.ts +29 -0
  157. package/src/features/how-to/HowToModule.ts +1 -1
  158. package/src/features/how-to/data/HowTo.ts +21 -3
  159. package/src/features/how-to/data/HowToInterface.ts +1 -0
  160. package/src/index.ts +4 -0
  161. package/dist/BlockNoteEditor-2AXSTGGG.js.map +0 -1
  162. package/dist/chunk-56XBGQGU.mjs.map +0 -1
  163. package/dist/chunk-CFECWLHH.mjs.map +0 -1
  164. package/dist/chunk-HC3JFN3C.js.map +0 -1
  165. package/dist/chunk-RXXZGPC3.js.map +0 -1
  166. package/dist/chunk-VLDLERJN.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { v4 as uuidv4 } from "uuid";
1
2
  import { AbstractApiData, ApiDataInterface, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
3
  import { AssistantMessageInput, AssistantMessageInterface, AssistantMessageRole } from "./AssistantMessageInterface";
3
4
  import { resolveReferenceableModules } from "../../assistant/utils/resolveReferenceableModules";
@@ -12,6 +13,7 @@ export class AssistantMessage extends AbstractApiData implements AssistantMessag
12
13
  private _outputTokens?: number;
13
14
  private _references?: ApiDataInterface[];
14
15
  private _citations?: (ChunkInterface & ChunkRelationshipMeta)[];
16
+ private _isOptimistic = false;
15
17
 
16
18
  get role(): AssistantMessageRole {
17
19
  return this._role ?? "assistant";
@@ -45,6 +47,10 @@ export class AssistantMessage extends AbstractApiData implements AssistantMessag
45
47
  return this._citations ?? [];
46
48
  }
47
49
 
50
+ get isOptimistic(): boolean {
51
+ return this._isOptimistic;
52
+ }
53
+
48
54
  rehydrate(data: JsonApiHydratedDataInterface): this {
49
55
  super.rehydrate(data);
50
56
  const attrs = data.jsonApi.attributes ?? {};
@@ -93,10 +99,29 @@ export class AssistantMessage extends AbstractApiData implements AssistantMessag
93
99
  };
94
100
  }
95
101
 
102
+ /**
103
+ * JSON:API envelope for POST /assistants/:id/assistant-messages.
104
+ * Different from `createJsonApi` (which expects a full message with role/position/assistant ref);
105
+ * the append-to-thread endpoint derives those server-side, so we only send `content` and
106
+ * the optional retrieval-mode flags.
107
+ */
108
+ createAppendMessageJsonApi(params: { content: string; howToMode?: boolean; limitToHowToId?: string }) {
109
+ return {
110
+ data: {
111
+ type: Modules.AssistantMessage.name,
112
+ attributes: {
113
+ content: params.content,
114
+ ...(params.howToMode !== undefined ? { howToMode: params.howToMode } : {}),
115
+ ...(params.limitToHowToId !== undefined ? { limitToHowToId: params.limitToHowToId } : {}),
116
+ },
117
+ },
118
+ };
119
+ }
120
+
96
121
  static buildOptimistic(params: { content: string; position: number; assistantId?: string }): AssistantMessage {
97
122
  const msg = new AssistantMessage();
98
123
  const jsonApi: Record<string, unknown> = {
99
- id: `tmp-${crypto.randomUUID()}`,
124
+ id: uuidv4(),
100
125
  type: Modules.AssistantMessage.name,
101
126
  attributes: {
102
127
  role: "user",
@@ -110,6 +135,7 @@ export class AssistantMessage extends AbstractApiData implements AssistantMessag
110
135
  };
111
136
  }
112
137
  msg.rehydrate({ jsonApi: jsonApi as any, included: [] });
138
+ msg._isOptimistic = true;
113
139
  return msg;
114
140
  }
115
141
  }
@@ -20,4 +20,5 @@ export interface AssistantMessageInterface extends ApiDataInterface {
20
20
  get outputTokens(): number | undefined;
21
21
  get references(): ApiDataInterface[];
22
22
  get citations(): (ChunkInterface & ChunkRelationshipMeta)[];
23
+ get isOptimistic(): boolean;
23
24
  }
@@ -136,14 +136,17 @@ describe("AssistantMessage.rehydrate", () => {
136
136
  });
137
137
 
138
138
  describe("AssistantMessage.buildOptimistic", () => {
139
- it("creates a user message with a tmp-prefixed id and the given content + position", () => {
139
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
140
+
141
+ it("creates a user message with a plain UUID id and the given content + position", () => {
140
142
  const msg = AssistantMessage.buildOptimistic({
141
143
  content: "hello",
142
144
  assistantId: "a-1",
143
145
  position: 3,
144
146
  });
145
147
 
146
- expect(msg.id.startsWith("tmp-")).toBe(true);
148
+ expect(msg.id).toMatch(UUID_RE);
149
+ expect(msg.isOptimistic).toBe(true);
147
150
  expect(msg.role).toBe("user");
148
151
  expect(msg.content).toBe("hello");
149
152
  expect(msg.position).toBe(3);
@@ -152,7 +155,8 @@ describe("AssistantMessage.buildOptimistic", () => {
152
155
  it("allows an omitted assistantId (first-message case)", () => {
153
156
  const msg = AssistantMessage.buildOptimistic({ content: "first", position: 1 });
154
157
 
155
- expect(msg.id.startsWith("tmp-")).toBe(true);
158
+ expect(msg.id).toMatch(UUID_RE);
159
+ expect(msg.isOptimistic).toBe(true);
156
160
  expect(msg.role).toBe("user");
157
161
  expect(msg.content).toBe("first");
158
162
  expect(msg.position).toBe(1);
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { ReactNode } from "react";
3
+ import Link from "next/link";
4
+ import { useFormatter, useTranslations } from "next-intl";
5
+ import type { HelpArticle } from "../types/help-article.types";
6
+ import { usePageUrlGenerator } from "../../../client";
7
+ import { useHelp } from "../contexts/HelpContext";
8
+ import { articleUrl } from "../utils/articleUrl";
9
+ import { prevNextWithinMode } from "../utils/helpNavigation";
10
+
11
+ export function HelpArticleBody({ article, children }: { article: HelpArticle; children: ReactNode }) {
12
+ const t = useTranslations();
13
+ const format = useFormatter();
14
+ const generateUrl = usePageUrlGenerator();
15
+ const { manifest } = useHelp();
16
+ const { prev, next } = prevNextWithinMode(manifest, article);
17
+
18
+ return (
19
+ <article className="prose dark:prose-invert max-w-none">
20
+ <nav className="text-muted-foreground mb-2 text-xs">
21
+ <Link href={generateUrl({ page: "/help" })}>Help</Link>
22
+ {" · "}
23
+ <Link href={generateUrl({ page: `/help/${article.mode}` })}>{t(`help.modes.${article.mode}`)}</Link>
24
+ </nav>
25
+ <h1>{article.title}</h1>
26
+ <p className="text-muted-foreground !mt-0 text-base">{article.summary}</p>
27
+ {children}
28
+ <hr className="my-6" />
29
+ <div className="text-muted-foreground text-xs">
30
+ <span>
31
+ {t("help.article.lastUpdated", {
32
+ date: format.dateTime(new Date(article.lastUpdated), { dateStyle: "short" }),
33
+ })}
34
+ </span>
35
+ </div>
36
+ <div className="mt-4 flex justify-between text-sm">
37
+ {prev ? (
38
+ <Link href={articleUrl(generateUrl, prev)} className="hover:underline">
39
+ ← {t("help.article.previous")}: {prev.title}
40
+ </Link>
41
+ ) : (
42
+ <span />
43
+ )}
44
+ {next ? (
45
+ <Link href={articleUrl(generateUrl, next)} className="hover:underline">
46
+ {t("help.article.next")}: {next.title} →
47
+ </Link>
48
+ ) : (
49
+ <span />
50
+ )}
51
+ </div>
52
+ </article>
53
+ );
54
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { SparklesIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useCurrentUserContext } from "../../user/contexts/CurrentUserContext";
6
+ import { Button, Tooltip, TooltipTrigger, TooltipContent } from "../../../shadcnui";
7
+ import { HelpAssistantSheet } from "./HelpAssistantSheet";
8
+
9
+ export function HelpAskAi() {
10
+ const t = useTranslations();
11
+ const { currentUser } = useCurrentUserContext();
12
+ const [open, setOpen] = useState(false);
13
+ const disabled = !currentUser;
14
+
15
+ if (disabled) {
16
+ return (
17
+ <Tooltip>
18
+ <TooltipTrigger render={<Button variant="outline" size="sm" disabled />}>
19
+ <SparklesIcon className="h-4 w-4" />
20
+ {t("help.askAi.button")}
21
+ </TooltipTrigger>
22
+ <TooltipContent>{t("help.askAi.loginTooltip")}</TooltipContent>
23
+ </Tooltip>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <>
29
+ <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
30
+ <SparklesIcon className="h-4 w-4" />
31
+ {t("help.askAi.button")}
32
+ </Button>
33
+ <HelpAssistantSheet open={open} onOpenChange={setOpen} />
34
+ </>
35
+ );
36
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+ import { useTranslations } from "next-intl";
3
+ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "../../../shadcnui";
4
+ import { AssistantProvider, useAssistantContext } from "../../assistant/contexts/AssistantContext";
5
+ import { AssistantThread } from "../../assistant/components/parts/AssistantThread";
6
+ import { AssistantComposer } from "../../assistant/components/parts/AssistantComposer";
7
+ import { AssistantEmptyState } from "../../assistant/components/parts/AssistantEmptyState";
8
+
9
+ export function HelpAssistantSheet({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
10
+ return (
11
+ <Sheet open={open} onOpenChange={onOpenChange}>
12
+ <SheetContent
13
+ side="right"
14
+ className="flex w-full flex-col data-[side=right]:sm:max-w-2xl data-[side=right]:lg:max-w-3xl"
15
+ >
16
+ <AssistantProvider manageUrl={false}>
17
+ <HelpAssistantSheetBody />
18
+ </AssistantProvider>
19
+ </SheetContent>
20
+ </Sheet>
21
+ );
22
+ }
23
+
24
+ function HelpAssistantSheetBody() {
25
+ const t = useTranslations();
26
+ const ctx = useAssistantContext();
27
+ const showThread = !!ctx.assistant || ctx.sending || ctx.messages.length > 0;
28
+ const send = (content: string) => ctx.sendMessage(content, { howToMode: true });
29
+
30
+ return (
31
+ <>
32
+ <SheetHeader>
33
+ <SheetTitle>{t("help.askAi.sheet.title")}</SheetTitle>
34
+ <SheetDescription>{t("help.askAi.sheet.subtitle")}</SheetDescription>
35
+ </SheetHeader>
36
+ {!showThread ? (
37
+ <AssistantEmptyState onSend={send} />
38
+ ) : (
39
+ <>
40
+ <AssistantThread
41
+ messages={ctx.messages}
42
+ sending={ctx.sending}
43
+ status={ctx.status}
44
+ onSelectFollowUp={send}
45
+ failedMessageIds={ctx.failedMessageIds}
46
+ onRetry={ctx.retrySend}
47
+ />
48
+ <AssistantComposer onSend={send} disabled={ctx.sending} />
49
+ </>
50
+ )}
51
+ </>
52
+ );
53
+ }
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import Link from "next/link";
3
+ import Image from "next/image";
4
+ import { useTranslations } from "next-intl";
5
+ import { useCurrentUserContext } from "../../user/contexts/CurrentUserContext";
6
+ import { Button } from "../../../shadcnui";
7
+ import { useHelp } from "../contexts/HelpContext";
8
+ import { HelpAskAi } from "./HelpAskAi";
9
+
10
+ export function HelpHeader() {
11
+ const t = useTranslations();
12
+ const { currentUser } = useCurrentUserContext();
13
+ const { brand } = useHelp();
14
+ const logo = brand?.logo;
15
+ const label = brand?.label ?? "Help";
16
+ const appHref = brand?.appHref ?? "/";
17
+
18
+ return (
19
+ <header className="border-border bg-background flex items-center justify-between border-b px-4 py-3">
20
+ <Link href="/help" className="flex items-center gap-2">
21
+ {logo ? <Image src={logo} alt={label} width={28} height={28} /> : null}
22
+ <span className="text-base font-semibold">
23
+ {label} · {t("help.footerLink")}
24
+ </span>
25
+ </Link>
26
+ <div className="flex items-center gap-2">
27
+ <HelpAskAi />
28
+ {currentUser ? (
29
+ <Button render={<Link href={appHref} />} nativeButton={false} variant="outline" size="sm">
30
+ {t("help.header.openApp")}
31
+ </Button>
32
+ ) : (
33
+ <Button render={<Link href="/login" />} nativeButton={false} variant="outline" size="sm">
34
+ {t("help.header.login")}
35
+ </Button>
36
+ )}
37
+ </div>
38
+ </header>
39
+ );
40
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import { useMemo, useState } from "react";
3
+ import { useTranslations } from "next-intl";
4
+ import Link from "next/link";
5
+ import { HelpCircleIcon } from "lucide-react";
6
+ import {
7
+ Button,
8
+ Sheet,
9
+ SheetContent,
10
+ SheetTrigger,
11
+ SheetHeader,
12
+ SheetTitle,
13
+ SheetDescription,
14
+ } from "../../../shadcnui";
15
+ import { usePageUrlGenerator } from "../../../client";
16
+ import { useHelp } from "../contexts/HelpContext";
17
+ import { articleUrl } from "../utils/articleUrl";
18
+ import type { HelpArticle } from "../types/help-article.types";
19
+
20
+ export function HelpHint({ contextKey }: { contextKey: string }) {
21
+ const t = useTranslations();
22
+ const generateUrl = usePageUrlGenerator();
23
+ const { manifest } = useHelp();
24
+ const [open, setOpen] = useState(false);
25
+ const [picked, setPicked] = useState<HelpArticle | null>(null);
26
+
27
+ const matches = useMemo(
28
+ () => manifest.filter((a) => a.contextualKeys.includes(contextKey) && !a.draft),
29
+ [manifest, contextKey],
30
+ );
31
+
32
+ if (matches.length === 0) return null;
33
+ const active = picked ?? (matches.length === 1 ? matches[0] : null);
34
+
35
+ return (
36
+ <Sheet
37
+ open={open}
38
+ onOpenChange={(o) => {
39
+ setOpen(o);
40
+ if (!o) setPicked(null);
41
+ }}
42
+ >
43
+ <SheetTrigger render={<Button variant="ghost" size="icon-sm" aria-label={t("help.hint.trigger")} />}>
44
+ <HelpCircleIcon className="h-4 w-4" />
45
+ </SheetTrigger>
46
+ <SheetContent side="right" className="w-full max-w-md sm:max-w-lg">
47
+ <SheetHeader>
48
+ <SheetTitle>{active ? active.title : t("help.hint.pickArticle")}</SheetTitle>
49
+ <SheetDescription>{active?.summary ?? ""}</SheetDescription>
50
+ </SheetHeader>
51
+ {active ? (
52
+ <div className="mt-4 space-y-3">
53
+ <p className="text-muted-foreground text-sm">{active.summary}</p>
54
+ <Link href={articleUrl(generateUrl, active)} className="text-sm">
55
+ {t("help.hint.viewArticle")}
56
+ </Link>
57
+ </div>
58
+ ) : (
59
+ <ul className="mt-4 space-y-1">
60
+ {matches.map((a) => (
61
+ <li key={a.id}>
62
+ <button
63
+ type="button"
64
+ onClick={() => setPicked(a)}
65
+ className="hover:bg-muted block w-full rounded px-2 py-1 text-left text-sm"
66
+ >
67
+ <div className="font-medium">{a.title}</div>
68
+ <div className="text-muted-foreground text-xs">{a.summary}</div>
69
+ </button>
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ )}
74
+ </SheetContent>
75
+ </Sheet>
76
+ );
77
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { useTranslations } from "next-intl";
4
+ import { LifeBuoyIcon, ArrowUpRightIcon } from "lucide-react";
5
+ import { CommandItem } from "../../../shadcnui";
6
+ import { usePageUrlGenerator } from "../../../client";
7
+ import { useHelp } from "../contexts/HelpContext";
8
+
9
+ export interface HelpSearchResultRowProps {
10
+ result: { id: string; name: string; entityType: string };
11
+ onSelect?: () => void;
12
+ }
13
+
14
+ export function HelpSearchResultRow({ result, onSelect }: HelpSearchResultRowProps) {
15
+ const t = useTranslations();
16
+ const generateUrl = usePageUrlGenerator();
17
+ const { manifest } = useHelp();
18
+ const article = manifest.find((a) => a.id === result.id);
19
+
20
+ if (!article) {
21
+ return (
22
+ <CommandItem
23
+ value={result.id}
24
+ disabled
25
+ className="text-muted-foreground cursor-not-allowed px-3 py-1.5 text-sm italic"
26
+ >
27
+ {t("help.search.removed")}
28
+ </CommandItem>
29
+ );
30
+ }
31
+
32
+ return (
33
+ <CommandItem value={result.id} onSelect={onSelect} className="cursor-pointer p-0">
34
+ <Link
35
+ href={generateUrl({ page: `/help/${article.mode}/${article.slug}` })}
36
+ className="hover:bg-muted flex w-full items-center gap-3 rounded px-3 py-2"
37
+ >
38
+ <div className="bg-muted text-muted-foreground flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
39
+ <LifeBuoyIcon className="h-5 w-5" />
40
+ </div>
41
+ <div className="flex min-w-0 flex-1 flex-col">
42
+ <span className="text-foreground truncate text-sm font-semibold">{article.title}</span>
43
+ <span className="text-muted-foreground truncate text-xs">
44
+ {t(`help.modes.${article.mode}`)} · {article.summary}
45
+ </span>
46
+ </div>
47
+ <ArrowUpRightIcon className="text-muted-foreground h-5 w-5 shrink-0" />
48
+ </Link>
49
+ </CommandItem>
50
+ );
51
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+ import { useTranslations } from "next-intl";
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { HELP_MODES } from "../types/help-article.types";
6
+ import { usePageUrlGenerator } from "../../../client";
7
+ import { useHelp } from "../contexts/HelpContext";
8
+ import { useHelpFilter } from "../hooks/useHelpFilter";
9
+ import { buildHelpNav } from "../utils/helpNavigation";
10
+ import { articleUrl, modeUrl } from "../utils/articleUrl";
11
+
12
+ export function HelpSideNav() {
13
+ const t = useTranslations();
14
+ const generateUrl = usePageUrlGenerator();
15
+ const pathname = usePathname();
16
+ const { manifest } = useHelp();
17
+ const groups = buildHelpNav(manifest);
18
+ const allArticles = groups.flatMap((g) => g.articles);
19
+ const { query, setQuery, filtered } = useHelpFilter(allArticles);
20
+ const filtering = query.trim().length > 0;
21
+
22
+ return (
23
+ <nav aria-label="Help navigation" className="flex h-full flex-col gap-3 overflow-y-auto p-4">
24
+ <input
25
+ type="search"
26
+ value={query}
27
+ onChange={(e) => setQuery(e.target.value)}
28
+ placeholder={t("help.sideNav.filterPlaceholder")}
29
+ className="border-input bg-background w-full rounded-md border px-3 py-2 text-sm"
30
+ />
31
+ {filtering ? (
32
+ filtered.length === 0 ? (
33
+ <p className="text-muted-foreground text-sm">{t("help.sideNav.noMatches")}</p>
34
+ ) : (
35
+ <ul className="space-y-1">
36
+ {filtered.map((a) => (
37
+ <li key={a.id}>
38
+ <Link
39
+ href={articleUrl(generateUrl, a)}
40
+ className={
41
+ "hover:bg-muted block rounded px-2 py-1 text-sm " +
42
+ (pathname.endsWith(`/help/${a.mode}/${a.slug}`) ? "bg-muted font-medium" : "")
43
+ }
44
+ >
45
+ {a.title}
46
+ </Link>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ )
51
+ ) : (
52
+ HELP_MODES.map((mode) => {
53
+ const group = groups.find((g) => g.mode === mode)!;
54
+ if (group.articles.length === 0) return null;
55
+ return (
56
+ <div key={mode}>
57
+ <Link
58
+ href={modeUrl(generateUrl, mode)}
59
+ className="text-muted-foreground hover:text-foreground mb-1 block text-xs font-semibold tracking-wider uppercase"
60
+ >
61
+ {t(`help.modes.${mode}`)}
62
+ </Link>
63
+ <ul className="space-y-0.5">
64
+ {group.articles.map((a) => (
65
+ <li key={a.id}>
66
+ <Link
67
+ href={articleUrl(generateUrl, a)}
68
+ className={
69
+ "hover:bg-muted block rounded px-2 py-1 text-sm " +
70
+ (pathname.endsWith(`/help/${a.mode}/${a.slug}`) ? "bg-muted font-medium" : "")
71
+ }
72
+ >
73
+ {a.title}
74
+ </Link>
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ </div>
79
+ );
80
+ })
81
+ )}
82
+ </nav>
83
+ );
84
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { useTranslations } from "next-intl";
4
+ import type { HelpHeading } from "../types/help-article.types";
5
+
6
+ export function HelpTOC({ headings }: { headings: readonly HelpHeading[] }) {
7
+ const t = useTranslations();
8
+ const [active, setActive] = useState<string | null>(null);
9
+
10
+ useEffect(() => {
11
+ const observer = new IntersectionObserver(
12
+ (entries) => {
13
+ for (const e of entries) {
14
+ if (e.isIntersecting) setActive(e.target.id);
15
+ }
16
+ },
17
+ { rootMargin: "0px 0px -70% 0px", threshold: 0 },
18
+ );
19
+ headings.forEach((h) => {
20
+ const el = document.getElementById(h.slug);
21
+ if (el) observer.observe(el);
22
+ });
23
+ return () => observer.disconnect();
24
+ }, [headings]);
25
+
26
+ if (headings.length === 0) return null;
27
+ return (
28
+ <nav aria-label={t("help.toc.title")} className="hidden lg:block">
29
+ <div className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
30
+ {t("help.toc.title")}
31
+ </div>
32
+ <ul className="space-y-1 text-sm">
33
+ {headings.map((h) => (
34
+ <li key={h.slug} style={{ paddingLeft: `${(h.depth - 2) * 0.75}rem` }}>
35
+ <a
36
+ href={`#${h.slug}`}
37
+ className={
38
+ "block truncate hover:text-foreground " +
39
+ (active === h.slug ? "text-foreground font-medium" : "text-muted-foreground")
40
+ }
41
+ >
42
+ {h.text}
43
+ </a>
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ </nav>
48
+ );
49
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { configureJsonApi } from "../../../../client/config";
4
+ import { HelpProvider } from "../../contexts/HelpContext";
5
+ import { HelpAskAi } from "../HelpAskAi";
6
+
7
+ vi.mock("../../../user/contexts/CurrentUserContext", () => ({
8
+ useCurrentUserContext: () => ({ currentUser: (globalThis as any).__mockUser ?? null }),
9
+ }));
10
+
11
+ // HelpAssistantSheet is intentionally rendered ONLY when logged-in to keep the
12
+ // disabled-state DOM minimal. We mock it to a marker so the test asserts presence.
13
+ vi.mock("../HelpAssistantSheet", () => ({
14
+ HelpAssistantSheet: ({ open }: { open: boolean }) => (open ? <div data-testid="sheet" /> : null),
15
+ }));
16
+
17
+ function setupConfig() {
18
+ configureJsonApi({
19
+ apiUrl: "http://localhost",
20
+ helpContent: { manifest: [], namespaceUuid: "00000000-0000-5000-8000-000000000000" },
21
+ });
22
+ }
23
+
24
+ describe("HelpAskAi", () => {
25
+ it("renders a disabled button + tooltip for anonymous users", () => {
26
+ (globalThis as any).__mockUser = null;
27
+ setupConfig();
28
+
29
+ render(
30
+ <HelpProvider>
31
+ <HelpAskAi />
32
+ </HelpProvider>,
33
+ );
34
+
35
+ const button = screen.getByRole("button");
36
+ expect(button).toHaveAttribute("disabled");
37
+ // vitest.setup.ts stubs next-intl's useTranslations to return the key as-is.
38
+ // The TooltipTrigger renders as a <span>, so the disabled <button> is the
39
+ // only <button> in the DOM (no nested-button hydration risk).
40
+ expect(screen.queryByTestId("sheet")).toBeNull();
41
+ });
42
+
43
+ it("clicking the disabled button does not open the sheet", () => {
44
+ (globalThis as any).__mockUser = null;
45
+ setupConfig();
46
+ render(
47
+ <HelpProvider>
48
+ <HelpAskAi />
49
+ </HelpProvider>,
50
+ );
51
+ fireEvent.click(screen.getByRole("button"));
52
+ expect(screen.queryByTestId("sheet")).toBeNull();
53
+ });
54
+
55
+ it("renders an enabled button for logged-in users; clicking opens the sheet", () => {
56
+ (globalThis as any).__mockUser = { id: "u-1" };
57
+ setupConfig();
58
+ render(
59
+ <HelpProvider>
60
+ <HelpAskAi />
61
+ </HelpProvider>,
62
+ );
63
+ const button = screen.getByRole("button");
64
+ expect(button).not.toHaveAttribute("disabled");
65
+ fireEvent.click(button);
66
+ expect(screen.getByTestId("sheet")).toBeInTheDocument();
67
+ });
68
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { HelpAssistantSheet } from "../HelpAssistantSheet";
4
+
5
+ const sendMessage = vi.fn().mockResolvedValue(undefined);
6
+
7
+ vi.mock("../../../assistant/contexts/AssistantContext", () => ({
8
+ AssistantProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
9
+ useAssistantContext: () => ({
10
+ assistant: undefined,
11
+ messages: [],
12
+ sending: false,
13
+ status: undefined,
14
+ failedMessageIds: new Set(),
15
+ sendMessage,
16
+ retrySend: vi.fn(),
17
+ }),
18
+ }));
19
+
20
+ describe("HelpAssistantSheet", () => {
21
+ beforeEach(() => sendMessage.mockClear());
22
+
23
+ it("submitting the composer calls sendMessage with howToMode: true", async () => {
24
+ render(<HelpAssistantSheet open={true} onOpenChange={() => {}} />);
25
+
26
+ // AssistantEmptyState renders the composer (a textarea + send button).
27
+ const textarea = screen.getByPlaceholderText("features.assistant.composer_placeholder");
28
+ fireEvent.change(textarea, { target: { value: "how do scenes work?" } });
29
+
30
+ // The empty-state composer's send button is found by its visible text.
31
+ const sendButton = screen.getByRole("button", { name: /save|send/i });
32
+ fireEvent.click(sendButton);
33
+
34
+ expect(sendMessage).toHaveBeenCalledWith("how do scenes work?", { howToMode: true });
35
+ });
36
+ });