@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.
- package/dist/{AssistantInterface-BYgI5z1-.d.mts → AssistantInterface-B1c8FhGA.d.mts} +2 -0
- package/dist/{AssistantInterface-DfDcz0gJ.d.ts → AssistantInterface-BBUHxOCd.d.ts} +2 -0
- package/dist/{AssistantMessageInterface-BpEhx2pC.d.ts → AssistantMessageInterface-Cs1yb-gF.d.ts} +3 -1
- package/dist/{AssistantMessageInterface-DJ3Me16Y.d.mts → AssistantMessageInterface-DQ3mH5L8.d.mts} +3 -1
- package/dist/{AuthComponent-B6DIk8Vf.d.ts → AuthComponent-Cd7lcYif.d.ts} +1 -1
- package/dist/{AuthComponent-BKI0ZbtD.d.mts → AuthComponent-DdxCFgUZ.d.mts} +1 -1
- package/dist/{BlockNoteEditor-2AXSTGGG.js → BlockNoteEditor-3XYBZLWO.js} +20 -19
- package/dist/BlockNoteEditor-3XYBZLWO.js.map +1 -0
- package/dist/{BlockNoteEditor-XVIBGXHF.mjs → BlockNoteEditor-EBFZG7AL.mjs} +5 -4
- package/dist/{BlockNoteEditor-XVIBGXHF.mjs.map → BlockNoteEditor-EBFZG7AL.mjs.map} +1 -1
- package/dist/{auth.interface-BBUgMZzs.d.ts → auth.interface-8b601idJ.d.ts} +1 -1
- package/dist/{auth.interface-XYEREOD6.d.mts → auth.interface-CXBF8Mhi.d.mts} +1 -1
- package/dist/billing/index.js +347 -346
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +4 -3
- package/dist/billing/index.mjs.map +1 -1
- package/dist/chunk-3J7RQBF3.js +123 -0
- package/dist/chunk-3J7RQBF3.js.map +1 -0
- package/dist/{chunk-VLDLERJN.js → chunk-7E3O52U5.js} +15 -8
- package/dist/chunk-7E3O52U5.js.map +1 -0
- package/dist/{chunk-RXXZGPC3.js → chunk-CFI4WZ5R.js} +159 -113
- package/dist/chunk-CFI4WZ5R.js.map +1 -0
- package/dist/chunk-CQID6RCF.mjs +38 -0
- package/dist/chunk-CQID6RCF.mjs.map +1 -0
- package/dist/{chunk-56XBGQGU.mjs → chunk-CRTVAQEK.mjs} +42 -27
- package/dist/chunk-CRTVAQEK.mjs.map +1 -0
- package/dist/{chunk-N3NVIPSU.mjs → chunk-MSNNAHDB.mjs} +129 -83
- package/dist/{chunk-N3NVIPSU.mjs.map → chunk-MSNNAHDB.mjs.map} +1 -1
- package/dist/chunk-MZTKPPET.mjs +123 -0
- package/dist/chunk-MZTKPPET.mjs.map +1 -0
- package/dist/{chunk-HC3JFN3C.js → chunk-UHO3KUUH.js} +838 -823
- package/dist/chunk-UHO3KUUH.js.map +1 -0
- package/dist/{chunk-CFECWLHH.mjs → chunk-UOYIWJEJ.mjs} +10 -3
- package/dist/chunk-UOYIWJEJ.mjs.map +1 -0
- package/dist/chunk-YQQHAFBS.js +38 -0
- package/dist/chunk-YQQHAFBS.js.map +1 -0
- package/dist/client/index.d.mts +8 -16
- package/dist/client/index.d.ts +8 -16
- package/dist/client/index.js +5 -4
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +4 -3
- package/dist/components/index.d.mts +6 -5
- package/dist/components/index.d.ts +6 -5
- package/dist/components/index.js +5 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +4 -3
- package/dist/{config-CLQynoaa.d.ts → config-CN23v3eJ.d.ts} +4 -1
- package/dist/{config-k61pe_o2.d.mts → config-gh88Qn4h.d.mts} +4 -1
- package/dist/contexts/index.d.mts +18 -7
- package/dist/contexts/index.d.ts +18 -7
- package/dist/contexts/index.js +5 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +4 -3
- package/dist/core/index.d.mts +44 -11
- package/dist/core/index.d.ts +44 -11
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/help/index.css +29 -0
- package/dist/features/help/index.css.map +1 -0
- package/dist/features/help/index.d.mts +115 -0
- package/dist/features/help/index.d.ts +115 -0
- package/dist/features/help/index.js +532 -0
- package/dist/features/help/index.js.map +1 -0
- package/dist/features/help/index.mjs +532 -0
- package/dist/features/help/index.mjs.map +1 -0
- package/dist/features/help/server/createHelpAssetRouteHandler.d.mts +11 -0
- package/dist/features/help/server/createHelpAssetRouteHandler.d.ts +11 -0
- package/dist/features/help/server/createHelpAssetRouteHandler.js +43 -0
- package/dist/features/help/server/createHelpAssetRouteHandler.js.map +1 -0
- package/dist/features/help/server/createHelpAssetRouteHandler.mjs +43 -0
- package/dist/features/help/server/createHelpAssetRouteHandler.mjs.map +1 -0
- package/dist/features/help/server.d.mts +71 -0
- package/dist/features/help/server.d.ts +71 -0
- package/dist/features/help/server.js +123 -0
- package/dist/features/help/server.js.map +1 -0
- package/dist/features/help/server.mjs +123 -0
- package/dist/features/help/server.mjs.map +1 -0
- package/dist/help-content-config.interface-B9L02u9i.d.mts +50 -0
- package/dist/help-content-config.interface-B9L02u9i.d.ts +50 -0
- package/dist/index.d.mts +10 -8
- package/dist/index.d.ts +10 -8
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3 -2
- package/dist/{notification.interface-aLEJbA_g.d.ts → notification.interface-C1T1C2ee.d.ts} +1 -100
- package/dist/{notification.interface-DLZGtV7Z.d.mts → notification.interface-DIxR23eS.d.mts} +1 -100
- package/dist/{s3.service-CVgLWaDc.d.mts → s3.service-0BTClOYO.d.mts} +2 -2
- package/dist/{s3.service-SLlX0Zbz.d.ts → s3.service-CT27Fm1s.d.ts} +2 -2
- package/dist/server/index.d.mts +4 -3
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/types-CQSjy7et.d.mts +101 -0
- package/dist/types-DHOxe8rc.d.ts +101 -0
- package/dist/usePageUrlGenerator-tjq2mlDV.d.ts +14 -0
- package/dist/usePageUrlGenerator-uOnyJ6j2.d.mts +14 -0
- package/dist/{useSocket-BkxHHujj.d.mts → useSocket-B1fMIr17.d.mts} +1 -1
- package/dist/{useSocket-CMDjWFYm.d.ts → useSocket-BdJTBXKv.d.ts} +1 -1
- package/package.json +20 -1
- package/src/client/config.ts +9 -1
- package/src/core/registry/helpStore.ts +45 -0
- package/src/features/assistant/contexts/AssistantContext.tsx +35 -19
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +66 -6
- package/src/features/assistant/data/Assistant.ts +2 -0
- package/src/features/assistant/data/AssistantInterface.ts +2 -0
- package/src/features/assistant/data/AssistantService.ts +18 -8
- package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +6 -4
- package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +5 -1
- package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -1
- package/src/features/assistant-message/data/AssistantMessage.ts +27 -1
- package/src/features/assistant-message/data/AssistantMessageInterface.ts +1 -0
- package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +7 -3
- package/src/features/help/components/HelpArticleBody.tsx +54 -0
- package/src/features/help/components/HelpAskAi.tsx +36 -0
- package/src/features/help/components/HelpAssistantSheet.tsx +53 -0
- package/src/features/help/components/HelpHeader.tsx +40 -0
- package/src/features/help/components/HelpHint.tsx +77 -0
- package/src/features/help/components/HelpSearchResultRow.tsx +51 -0
- package/src/features/help/components/HelpSideNav.tsx +84 -0
- package/src/features/help/components/HelpTOC.tsx +49 -0
- package/src/features/help/components/__tests__/HelpAskAi.spec.tsx +68 -0
- package/src/features/help/components/__tests__/HelpAssistantSheet.spec.tsx +36 -0
- package/src/features/help/components/__tests__/HelpHint.spec.tsx +50 -0
- package/src/features/help/components/__tests__/HelpSearchResultRow.spec.tsx +59 -0
- package/src/features/help/components/__tests__/HelpSideNav.spec.tsx +52 -0
- package/src/features/help/components/mdx/Callout.tsx +21 -0
- package/src/features/help/components/mdx/EntityRef.tsx +18 -0
- package/src/features/help/components/mdx/KeyBinding.tsx +6 -0
- package/src/features/help/components/mdx/Related.tsx +33 -0
- package/src/features/help/components/mdx/Screenshot.tsx +9 -0
- package/src/features/help/components/mdx/Steps.tsx +21 -0
- package/src/features/help/components/mdx/Video.tsx +8 -0
- package/src/features/help/components/mdx/mdx-server-components.ts +23 -0
- package/src/features/help/components/mdx/mdxComponents.ts +9 -0
- package/src/features/help/contexts/HelpContext.spec.tsx +28 -0
- package/src/features/help/contexts/HelpContext.tsx +24 -0
- package/src/features/help/hooks/useHelp.ts +1 -0
- package/src/features/help/hooks/useHelpArticle.ts +7 -0
- package/src/features/help/hooks/useHelpFilter.ts +27 -0
- package/src/features/help/hooks/useHelpManifest.ts +5 -0
- package/src/features/help/i18n-keys.ts +34 -0
- package/src/features/help/index.ts +27 -0
- package/src/features/help/interfaces/help-content-config.interface.ts +17 -0
- package/src/features/help/server/__tests__/createHelpAssetRouteHandler.spec.ts +43 -0
- package/src/features/help/server/createHelpAssetRouteHandler.ts +35 -0
- package/src/features/help/server/generateHelpArticleMetadata.ts +18 -0
- package/src/features/help/server/generateHelpArticleStaticParams.ts +7 -0
- package/src/features/help/server/generateHelpModeStaticParams.ts +5 -0
- package/src/features/help/server/getHelpContent.ts +17 -0
- package/src/features/help/server/index.ts +8 -0
- package/src/features/help/server/serializeHelpArticle.tsx +46 -0
- package/src/features/help/server-entry.ts +20 -0
- package/src/features/help/types/help-article.types.ts +37 -0
- package/src/features/help/utils/__tests__/helpNavigation.spec.ts +70 -0
- package/src/features/help/utils/articleUrl.ts +13 -0
- package/src/features/help/utils/helpNavigation.ts +29 -0
- package/src/features/how-to/HowToModule.ts +1 -1
- package/src/features/how-to/data/HowTo.ts +21 -3
- package/src/features/how-to/data/HowToInterface.ts +1 -0
- package/src/index.ts +4 -0
- package/dist/BlockNoteEditor-2AXSTGGG.js.map +0 -1
- package/dist/chunk-56XBGQGU.mjs.map +0 -1
- package/dist/chunk-CFECWLHH.mjs.map +0 -1
- package/dist/chunk-HC3JFN3C.js.map +0 -1
- package/dist/chunk-RXXZGPC3.js.map +0 -1
- 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:
|
|
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
|
}
|
|
@@ -136,14 +136,17 @@ describe("AssistantMessage.rehydrate", () => {
|
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
describe("AssistantMessage.buildOptimistic", () => {
|
|
139
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|