@nualang/nualang-ui-components 0.1.1415 → 0.1.1417

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.
@@ -217,6 +217,7 @@ function App({
217
217
  isCollapsed,
218
218
  setIsCollapsed,
219
219
  isUserInternal,
220
+ isCommunityAccessRestricted,
220
221
  handleQuickCreateClassroom,
221
222
  handleQuickCreateCourse,
222
223
  learnLang,
@@ -303,6 +304,18 @@ function App({
303
304
  };
304
305
  const isSearchActive = searchText || isMobileSearchExpanded;
305
306
  const isTeacher = user && user["custom:role"] === "teach";
307
+ const isSearchAllowed = isTeacher && !isCommunityAccessRestricted;
308
+
309
+ // If the restriction flag loads/flips async while search is already active
310
+ // (e.g. mobile search expanded, or text typed in), force-close it rather
311
+ // than only gating the initial render - otherwise a user already mid-search
312
+ // keeps access until they manually close it.
313
+ useEffect(() => {
314
+ if (!isSearchAllowed && (searchText || isMobileSearchExpanded)) {
315
+ setSearchText("");
316
+ setIsMobileSearchExpanded(false);
317
+ }
318
+ }, [isSearchAllowed, searchText, isMobileSearchExpanded]);
306
319
  const isUpgradePossible = subscription && subscription.isUpgradePossible;
307
320
  const hidden = useMediaQuery(theme => theme.breakpoints.down("sm"));
308
321
  return /*#__PURE__*/_jsxs("div", {
@@ -334,7 +347,7 @@ function App({
334
347
  })]
335
348
  }) : /*#__PURE__*/_jsxs(_Fragment, {
336
349
  children: [isLgScreen ? /*#__PURE__*/_jsx(_Fragment, {
337
- children: isTeacher && /*#__PURE__*/_jsxs(_Fragment, {
350
+ children: isSearchAllowed && /*#__PURE__*/_jsxs(_Fragment, {
338
351
  children: [/*#__PURE__*/_jsx(AppSearch, {
339
352
  t: t,
340
353
  navigate: navigate,
@@ -348,7 +361,7 @@ function App({
348
361
  })]
349
362
  })
350
363
  }) : /*#__PURE__*/_jsx(_Fragment, {
351
- children: isTeacher && /*#__PURE__*/_jsx(_Fragment, {
364
+ children: isSearchAllowed && /*#__PURE__*/_jsx(_Fragment, {
352
365
  children: /*#__PURE__*/_jsx(Tooltip, {
353
366
  title: t("search"),
354
367
  enterDelay: 300,
@@ -29,6 +29,7 @@ export default function FindChatbotDialog({
29
29
  onLoadMoreCommunity,
30
30
  hasMoreCommunity = false,
31
31
  isLoadingMoreCommunity = false,
32
+ isCommunityAccessRestricted = false,
32
33
  t = text => text
33
34
  }) {
34
35
  const scrollRef = useRef(null);
@@ -39,6 +40,8 @@ export default function FindChatbotDialog({
39
40
  threshold: 0
40
41
  });
41
42
  const [tab, setTab] = useState(0);
43
+ // Restricted users are always kept on the library tab, regardless of state.
44
+ const effectiveTab = isCommunityAccessRestricted ? 0 : tab;
42
45
  const [selectedBotIds, setSelectedBotIds] = useState([]);
43
46
  const [search, setSearch] = useState("");
44
47
  const [filters, setFilters] = useState({
@@ -81,12 +84,22 @@ export default function FindChatbotDialog({
81
84
  reset();
82
85
  handleClose();
83
86
  };
87
+
88
+ // If restriction flips on while the dialog is already open (e.g. the flag
89
+ // loads async), a previously-selected community chatbot could otherwise
90
+ // stay selected/added even though the Community tab is now locked out.
84
91
  useEffect(() => {
85
- if (sentinelInView && tab === 1 && hasMoreCommunity && !isLoadingMoreCommunity) {
92
+ if (isCommunityAccessRestricted) {
93
+ reset();
94
+ }
95
+ }, [isCommunityAccessRestricted]);
96
+ useEffect(() => {
97
+ if (sentinelInView && effectiveTab === 1 && hasMoreCommunity && !isLoadingMoreCommunity) {
86
98
  onLoadMoreCommunity?.();
87
99
  }
88
- }, [sentinelInView, tab, hasMoreCommunity, isLoadingMoreCommunity, onLoadMoreCommunity]);
100
+ }, [sentinelInView, effectiveTab, hasMoreCommunity, isLoadingMoreCommunity, onLoadMoreCommunity]);
89
101
  const handleTabChange = (_, v) => {
102
+ if (isCommunityAccessRestricted) return;
90
103
  setTab(v);
91
104
  setSearch("");
92
105
  setFilters({
@@ -114,9 +127,9 @@ export default function FindChatbotDialog({
114
127
  }
115
128
  return result;
116
129
  };
117
- const currentChatbots = tab === 0 ? filterChatbots(libraryChatbots) : applyCommunityFilters(communityChatbots);
118
- const isLoading = tab === 0 ? isLoadingLibrary : isLoadingCommunity;
119
- const sectionLabel = tab === 0 ? t("add_own_exercise_message", {
130
+ const currentChatbots = effectiveTab === 0 ? filterChatbots(libraryChatbots) : applyCommunityFilters(communityChatbots);
131
+ const isLoading = effectiveTab === 0 ? isLoadingLibrary : isLoadingCommunity;
132
+ const sectionLabel = effectiveTab === 0 ? t("add_own_exercise_message", {
120
133
  exerciseName: t("chatbots").toLowerCase()
121
134
  }) : t("or_select_a_community_template_desc");
122
135
  const addButtonLabel = selectedBotIds.length > 0 ? `${t("add_chatbots")} (${selectedBotIds.length})` : t("add_chatbots");
@@ -166,17 +179,18 @@ export default function FindChatbotDialog({
166
179
  borderColor: "divider"
167
180
  },
168
181
  children: /*#__PURE__*/_jsxs(Tabs, {
169
- value: tab,
182
+ value: effectiveTab,
170
183
  onChange: handleTabChange,
171
184
  indicatorColor: "primary",
172
185
  textColor: "primary",
173
186
  children: [/*#__PURE__*/_jsx(Tab, {
174
187
  label: t("my_library") || "My Library"
175
188
  }), /*#__PURE__*/_jsx(Tab, {
176
- label: t("community") || "Community"
189
+ label: t("community") || "Community",
190
+ disabled: isCommunityAccessRestricted
177
191
  })]
178
192
  })
179
- }), tab === 1 && /*#__PURE__*/_jsx(Box, {
193
+ }), effectiveTab === 1 && /*#__PURE__*/_jsx(Box, {
180
194
  sx: {
181
195
  px: 3,
182
196
  py: {
@@ -232,7 +246,7 @@ export default function FindChatbotDialog({
232
246
  isSelected: bot => selectedBotIds.includes(bot.botId),
233
247
  compact: true,
234
248
  handleViewUserProfile: () => {}
235
- }), tab === 1 && /*#__PURE__*/_jsx(Box, {
249
+ }), effectiveTab === 1 && /*#__PURE__*/_jsx(Box, {
236
250
  ref: sentinelRef,
237
251
  sx: {
238
252
  height: 40,
@@ -17,6 +17,7 @@ export default function FindScenarioDialog({
17
17
  onLoadMoreCommunity,
18
18
  hasMoreCommunity = false,
19
19
  isLoadingMoreCommunity = false,
20
+ isCommunityAccessRestricted = false,
20
21
  t = text => text
21
22
  }) {
22
23
  const scrollRef = useRef(null);
@@ -27,6 +28,8 @@ export default function FindScenarioDialog({
27
28
  threshold: 0
28
29
  });
29
30
  const [tab, setTab] = useState(0);
31
+ // Restricted users are always kept on the library tab, regardless of state.
32
+ const effectiveTab = isCommunityAccessRestricted ? 0 : tab;
30
33
  const [selectedRoleplayIds, setSelectedRoleplayIds] = useState([]);
31
34
  const [search, setSearch] = useState("");
32
35
  const [filters, setFilters] = useState({
@@ -69,12 +72,22 @@ export default function FindScenarioDialog({
69
72
  reset();
70
73
  handleClose();
71
74
  };
75
+
76
+ // If restriction flips on while the dialog is already open (e.g. the flag
77
+ // loads async), a previously-selected community scenario could otherwise
78
+ // stay selected/added even though the Community tab is now locked out.
72
79
  useEffect(() => {
73
- if (sentinelInView && tab === 1 && hasMoreCommunity && !isLoadingMoreCommunity) {
80
+ if (isCommunityAccessRestricted) {
81
+ reset();
82
+ }
83
+ }, [isCommunityAccessRestricted]);
84
+ useEffect(() => {
85
+ if (sentinelInView && effectiveTab === 1 && hasMoreCommunity && !isLoadingMoreCommunity) {
74
86
  onLoadMoreCommunity?.();
75
87
  }
76
- }, [sentinelInView, tab, hasMoreCommunity, isLoadingMoreCommunity, onLoadMoreCommunity]);
88
+ }, [sentinelInView, effectiveTab, hasMoreCommunity, isLoadingMoreCommunity, onLoadMoreCommunity]);
77
89
  const handleTabChange = (_, v) => {
90
+ if (isCommunityAccessRestricted) return;
78
91
  setTab(v);
79
92
  setSearch("");
80
93
  setFilters({
@@ -102,9 +115,9 @@ export default function FindScenarioDialog({
102
115
  }
103
116
  return result;
104
117
  };
105
- const currentScenarios = tab === 0 ? filterScenarios(libraryScenarios) : applyCommunityFilters(communityScenarios);
106
- const isLoading = tab === 0 ? isLoadingLibrary : isLoadingCommunity;
107
- const sectionLabel = tab === 0 ? t("add_own_exercise_message", {
118
+ const currentScenarios = effectiveTab === 0 ? filterScenarios(libraryScenarios) : applyCommunityFilters(communityScenarios);
119
+ const isLoading = effectiveTab === 0 ? isLoadingLibrary : isLoadingCommunity;
120
+ const sectionLabel = effectiveTab === 0 ? t("add_own_exercise_message", {
108
121
  exerciseName: t("scenarios").toLowerCase()
109
122
  }) : t("or_select_a_community_template_desc");
110
123
  const addButtonLabel = selectedRoleplayIds.length > 0 ? `${t("add_scenarios")} (${selectedRoleplayIds.length})` : t("add_scenarios");
@@ -154,17 +167,18 @@ export default function FindScenarioDialog({
154
167
  borderColor: "divider"
155
168
  },
156
169
  children: /*#__PURE__*/_jsxs(Tabs, {
157
- value: tab,
170
+ value: effectiveTab,
158
171
  onChange: handleTabChange,
159
172
  indicatorColor: "primary",
160
173
  textColor: "primary",
161
174
  children: [/*#__PURE__*/_jsx(Tab, {
162
175
  label: t("my_library") || "My Library"
163
176
  }), /*#__PURE__*/_jsx(Tab, {
164
- label: t("community") || "Community"
177
+ label: t("community") || "Community",
178
+ disabled: isCommunityAccessRestricted
165
179
  })]
166
180
  })
167
- }), tab === 1 && /*#__PURE__*/_jsx(Box, {
181
+ }), effectiveTab === 1 && /*#__PURE__*/_jsx(Box, {
168
182
  sx: {
169
183
  px: 3,
170
184
  py: {
@@ -220,7 +234,7 @@ export default function FindScenarioDialog({
220
234
  isSelected: roleplay => selectedRoleplayIds.includes(roleplay.roleplayId),
221
235
  compact: true,
222
236
  handleViewUserProfile: () => {}
223
- }), tab === 1 && /*#__PURE__*/_jsx(Box, {
237
+ }), effectiveTab === 1 && /*#__PURE__*/_jsx(Box, {
224
238
  ref: sentinelRef,
225
239
  sx: {
226
240
  height: 40,
@@ -150,6 +150,7 @@ export default function FindTopicsDialog({
150
150
  onLoadMoreCommunity,
151
151
  hasMoreCommunity = false,
152
152
  isLoadingMoreCommunity = false,
153
+ isCommunityAccessRestricted = false,
153
154
  t = text => text
154
155
  }) {
155
156
  const scrollRef = useRef(null);
@@ -160,6 +161,8 @@ export default function FindTopicsDialog({
160
161
  threshold: 0
161
162
  });
162
163
  const [tab, setTab] = useState(0);
164
+ // Restricted users are always kept on the library tab, regardless of state.
165
+ const effectiveTab = isCommunityAccessRestricted ? 0 : tab;
163
166
  const [selectedTopicIds, setSelectedTopicIds] = useState([]);
164
167
  const [search, setSearch] = useState("");
165
168
  const [filters, setFilters] = useState({
@@ -202,12 +205,22 @@ export default function FindTopicsDialog({
202
205
  reset();
203
206
  handleClose();
204
207
  };
208
+
209
+ // If restriction flips on while the dialog is already open (e.g. the flag
210
+ // loads async), a previously-selected community topic could otherwise stay
211
+ // selected/added even though the Community tab is now locked out - clear it.
205
212
  useEffect(() => {
206
- if (sentinelInView && tab === 1 && hasMoreCommunity && !isLoadingMoreCommunity) {
213
+ if (isCommunityAccessRestricted) {
214
+ reset();
215
+ }
216
+ }, [isCommunityAccessRestricted]);
217
+ useEffect(() => {
218
+ if (sentinelInView && effectiveTab === 1 && hasMoreCommunity && !isLoadingMoreCommunity) {
207
219
  onLoadMoreCommunity?.();
208
220
  }
209
- }, [sentinelInView, tab, hasMoreCommunity, isLoadingMoreCommunity, onLoadMoreCommunity]);
221
+ }, [sentinelInView, effectiveTab, hasMoreCommunity, isLoadingMoreCommunity, onLoadMoreCommunity]);
210
222
  const handleTabChange = (_, v) => {
223
+ if (isCommunityAccessRestricted) return;
211
224
  setTab(v);
212
225
  setSearch("");
213
226
  setFilters({
@@ -235,9 +248,9 @@ export default function FindTopicsDialog({
235
248
  }
236
249
  return result;
237
250
  };
238
- const currentTopics = tab === 0 ? filterTopics(libraryTopics) : applyCommunityFilters(communityTopics);
239
- const isLoading = tab === 0 ? isLoadingLibrary : isLoadingCommunity;
240
- const sectionLabel = tab === 0 ? t("add_own_exercise_message", {
251
+ const currentTopics = effectiveTab === 0 ? filterTopics(libraryTopics) : applyCommunityFilters(communityTopics);
252
+ const isLoading = effectiveTab === 0 ? isLoadingLibrary : isLoadingCommunity;
253
+ const sectionLabel = effectiveTab === 0 ? t("add_own_exercise_message", {
241
254
  exerciseName: t("topic").toLowerCase()
242
255
  }) : t("or_select_a_community_template_desc") || "Community Topics";
243
256
  const addButtonLabel = selectedTopicIds.length > 0 ? `${t("add_topics") || "Add Topics"} (${selectedTopicIds.length})` : t("add_topics") || "Add Topics";
@@ -281,7 +294,7 @@ export default function FindTopicsDialog({
281
294
  borderColor: "divider"
282
295
  },
283
296
  children: /*#__PURE__*/_jsxs(Tabs, {
284
- value: tab,
297
+ value: effectiveTab,
285
298
  onChange: handleTabChange,
286
299
  indicatorColor: "primary",
287
300
  textColor: "primary",
@@ -291,10 +304,11 @@ export default function FindTopicsDialog({
291
304
  "data-cy": "find-topics-tab-library"
292
305
  }), /*#__PURE__*/_jsx(Tab, {
293
306
  label: t("community") || "Community",
307
+ disabled: isCommunityAccessRestricted,
294
308
  "data-cy": "find-topics-tab-community"
295
309
  })]
296
310
  })
297
- }), tab === 1 && /*#__PURE__*/_jsx(Box, {
311
+ }), effectiveTab === 1 && /*#__PURE__*/_jsx(Box, {
298
312
  sx: {
299
313
  px: 3,
300
314
  py: {
@@ -350,7 +364,7 @@ export default function FindTopicsDialog({
350
364
  selectionEnabled: true,
351
365
  isSelected: topic => selectedTopicIds.includes(topic.topicId),
352
366
  compact: true
353
- }), tab === 1 && /*#__PURE__*/_jsx(Box, {
367
+ }), effectiveTab === 1 && /*#__PURE__*/_jsx(Box, {
354
368
  ref: sentinelRef,
355
369
  sx: {
356
370
  height: 40,
@@ -9,7 +9,9 @@ import NualaCreating from "../../Misc/NualaCreating/NualaCreating";
9
9
  import ResponsiveDialog from "../ResponsiveDialog/ResponsiveDialog";
10
10
  import InputHelper from "../../Forms/InputHelper/InputHelper";
11
11
  import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
12
+ import { getBotContentTemplateType, multipleOptionsRegex, sanitizeBotContent } from "../../utils/index";
12
13
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
14
+ const QUESTION_ANSWER_TEMPLATES = ["nualang.quiz.0.0.1", "nualang.emoji-quiz.0.0.1", "nualang.picture-quiz.0.0.1", "nualang.picture-quiz.0.0.2"];
13
15
  function validateResponse(response, template) {
14
16
  if (!Array.isArray(response)) {
15
17
  return false;
@@ -18,7 +20,7 @@ function validateResponse(response, template) {
18
20
  if (typeof item !== "object" || item === null || Array.isArray(item)) {
19
21
  return false;
20
22
  }
21
- if (template === "nualang.quiz.0.0.1") {
23
+ if (QUESTION_ANSWER_TEMPLATES.includes(template)) {
22
24
  if (!Object.hasOwn(item, "question") || !Object.hasOwn(item, "answer")) {
23
25
  return false;
24
26
  }
@@ -29,18 +31,27 @@ function validateResponse(response, template) {
29
31
  return false;
30
32
  }
31
33
  }
32
- if (template === "nualang.open-ended.0.0.1" && !Object.hasOwn(item, "text")) {
33
- return false;
34
+ if (template === "nualang.open-ended.0.0.1") {
35
+ if (typeof item.text !== "string" || item.text.trim() === "") {
36
+ return false;
37
+ }
34
38
  }
35
39
  }
36
40
  return true;
37
41
  }
42
+
43
+ // Splits a RiveScript-style answer string, e.g. "(Tuesday)" or "(7|seven)", into a plain array.
44
+ function parseAnswerString(answer) {
45
+ if (typeof answer !== "string") return [];
46
+ const match = answer.match(multipleOptionsRegex);
47
+ const inner = match ? match[1] : answer;
48
+ return inner.split("|").map(entry => entry.trim()).filter(Boolean);
49
+ }
38
50
  function GenerateBotDialog({
39
51
  t = text => text,
40
52
  isGenerateBotDialogOpen,
41
53
  setIsGenerateBotDialogOpen,
42
54
  makeChatGptApiRequest,
43
- generateRiveFile,
44
55
  learnLang,
45
56
  initialValues = {
46
57
  botTopic: "",
@@ -52,7 +63,7 @@ function GenerateBotDialog({
52
63
  siteLanguage,
53
64
  topicPhrases,
54
65
  phraseLists,
55
- updateRivescriptJSON,
66
+ updateBotContent,
56
67
  initialTopicGoal
57
68
  }) {
58
69
  const [usePhrasesContext, setUsePhrasesContext] = useState(true);
@@ -205,11 +216,21 @@ function GenerateBotDialog({
205
216
  };
206
217
  const handleSubmit = async () => {
207
218
  const generatedBot = await handleGenerateBotScript(autoSelect);
208
- const generatedRiveFile = generateRiveFile(values.botLengthNumber, generatedBot);
219
+ const templateType = getBotContentTemplateType(bot.selectedTemplate);
220
+ const botContent = sanitizeBotContent({
221
+ schemaVersion: 1,
222
+ templateType,
223
+ items: generatedBot.map((item, index) => ({
224
+ id: `q${index + 1}`,
225
+ question: templateType === "open-ended" ? item.text : item.question,
226
+ answers: templateType === "open-ended" ? [] : parseAnswerString(item.answer),
227
+ image: null
228
+ }))
229
+ });
209
230
  await handleUpdateBot({
210
- riveFile: generatedRiveFile
231
+ botContent
211
232
  });
212
- updateRivescriptJSON(generatedRiveFile);
233
+ updateBotContent(botContent);
213
234
  handleCloseDialog();
214
235
  };
215
236
  return /*#__PURE__*/_jsx(ResponsiveDialog, {
@@ -404,7 +404,7 @@ export default function QuickCreateChatbotDialog({
404
404
  const [difficulty, setDifficulty] = useState(topicDifficulty || "");
405
405
  const [templateId, setTemplateId] = useState(getDefaultTemplateId(templates));
406
406
  const [template, setTemplate] = useState(templates.find(tpl => tpl.botId === getDefaultTemplateId(templates)) || {});
407
- const [riveFile, setRiveFile] = useState("");
407
+ const [botContent, setBotContent] = useState(null);
408
408
  const [numberOfQuestions, setNumberOfQuestions] = useState(defaultNumberOfQuestions);
409
409
  const [isLoading, setIsLoading] = useState(false);
410
410
  const [selectedVoice, setSelectedVoice] = useState(null);
@@ -428,7 +428,24 @@ export default function QuickCreateChatbotDialog({
428
428
  useEffect(() => {
429
429
  const matched = templates.find(tpl => tpl.botId === templateId);
430
430
  setTemplate(matched || null);
431
- setRiveFile(matched ? matched.generateRiveFile(numberOfQuestions) : "");
431
+ if (matched && matched.sampleBotContent) {
432
+ const sampleItems = matched.sampleBotContent.items;
433
+ // Cycle the sample items to match the requested question count, reassigning
434
+ // sequential ids - this is placeholder content the teacher edits immediately
435
+ // after creation, not a precise question bank.
436
+ const items = Array.from({
437
+ length: numberOfQuestions
438
+ }, (_, index) => ({
439
+ ...sampleItems[index % sampleItems.length],
440
+ id: `q${index + 1}`
441
+ }));
442
+ setBotContent({
443
+ ...matched.sampleBotContent,
444
+ items
445
+ });
446
+ } else {
447
+ setBotContent(null);
448
+ }
432
449
  }, [templateId, templates, numberOfQuestions]);
433
450
  const resetAndClose = () => {
434
451
  setBotTitle("");
@@ -472,7 +489,7 @@ export default function QuickCreateChatbotDialog({
472
489
  description: t(template.description) || "",
473
490
  difficulty,
474
491
  tags: [],
475
- riveFile,
492
+ botContent,
476
493
  learnLang: template.learnLang,
477
494
  selectedTemplate: templateId,
478
495
  numberOfQuestions: Number(numberOfQuestions),
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useRef } from "react";
2
2
  import { Joyride, STATUS } from "react-joyride";
3
3
  import useMediaQuery from "@mui/material/useMediaQuery";
4
4
  import { useTheme } from "@mui/material/styles";
@@ -25,7 +25,7 @@ import Tutorial from "./Tutorial";
25
25
  import Editor from "./Editor/Editor";
26
26
 
27
27
  // utils
28
- import { jsonToRivescript, rivescriptToJson } from "../../utils/index";
28
+ import { jsonToRivescript, rivescriptToJson, getBotContentTemplateType, buildInitialBotContent, decompileBotContent, sanitizeBotContent, AUTHOR_FROM_SCRATCH_TEMPLATE } from "../../utils/index";
29
29
  import { startJoyrideWhenReady } from "../../utils/joyride";
30
30
  import useDebounce from "../../hooks/useDebounce";
31
31
  import useRecognition from "../../hooks/useRecognition";
@@ -104,7 +104,6 @@ function Bot({
104
104
  handleBotInstructionsImageUpload,
105
105
  makeChatGptApiRequest,
106
106
  handleUpdateBot,
107
- generateRiveFile,
108
107
  siteLanguage,
109
108
  topicPhrases,
110
109
  phraseLists,
@@ -131,6 +130,18 @@ function Bot({
131
130
  recognizing
132
131
  } = recognitionState;
133
132
  const [bot, setBot] = useState(initialValues);
133
+ const isAuthorFromScratch = bot.selectedTemplate === AUTHOR_FROM_SCRATCH_TEMPLATE;
134
+ const botContentTemplateType = getBotContentTemplateType(bot.selectedTemplate);
135
+ // Only set by genuine user edits (via updateBotContent) - not by the mount-time
136
+ // backfill below - so opening an old bot never autosaves before anyone touches it.
137
+ const hasEditedBotContentRef = useRef(false);
138
+ const updateBotContent = newBotContent => {
139
+ hasEditedBotContentRef.current = true;
140
+ setBot(prevState => ({
141
+ ...prevState,
142
+ botContent: newBotContent
143
+ }));
144
+ };
134
145
  const [isDynamicResponsesEnabled, setIsDynamicResponsesEnabled] = useState("disable");
135
146
  const languageInformation = config.languages[learnLang];
136
147
  const languageTag = languageInformation.Code;
@@ -320,12 +331,21 @@ function Bot({
320
331
 
321
332
  // Effect for API call
322
333
  useEffect(() => {
323
- if (debouncedRiveFile !== initialValues.riveFile) {
334
+ if (isAuthorFromScratch && debouncedRiveFile !== initialValues.riveFile) {
324
335
  handleSaveBot();
325
336
  }
326
337
  }, [debouncedRiveFile] // Only call effect if debounced rive file changes
327
338
  );
328
339
 
340
+ // Templated bots: botContent is the source of truth, riveFile is compiled from it
341
+ // server-side on save - see handleSaveBotContent.
342
+ const debouncedBotContent = useDebounce(bot.botContent, 3000);
343
+ useEffect(() => {
344
+ if (!isAuthorFromScratch && hasEditedBotContentRef.current && debouncedBotContent) {
345
+ handleSaveBotContent();
346
+ }
347
+ }, [debouncedBotContent]);
348
+
329
349
  // Debounce botJSON so that it only gives us latest value ...
330
350
  // ... if botJSON has not been updated within last 3000ms.
331
351
  // The goal is to only have the bot flow chart fire when user stops typing ...
@@ -340,7 +360,17 @@ function Bot({
340
360
  if (bot.riveFile === true) {
341
361
  bot.riveFile = "";
342
362
  }
343
- updateRivescriptJSON(bot.riveFile);
363
+ if (isAuthorFromScratch) {
364
+ updateRivescriptJSON(bot.riveFile);
365
+ } else if (!bot.botContent) {
366
+ // Legacy templated bot with no structured record yet: recover its real questions from
367
+ // riveFile rather than seeding a blank one, so the next autosave can't wipe them out.
368
+ const decompiled = decompileBotContent(bot.riveFile, bot.selectedTemplate);
369
+ setBot(prevState => ({
370
+ ...prevState,
371
+ botContent: decompiled || buildInitialBotContent(prevState.selectedTemplate)
372
+ }));
373
+ }
344
374
  }, []); // Only run on mount
345
375
 
346
376
  useEffect(() => {
@@ -560,6 +590,15 @@ function Bot({
560
590
  console.error(error);
561
591
  }
562
592
  };
593
+ const handleSaveBotContent = async () => {
594
+ try {
595
+ await onSubmit({
596
+ botContent: sanitizeBotContent(bot.botContent)
597
+ });
598
+ } catch (error) {
599
+ console.error(error);
600
+ }
601
+ };
563
602
  const handleJoyrideCallback = data => {
564
603
  const {
565
604
  status,
@@ -763,16 +802,18 @@ function Bot({
763
802
  handleUploadBotExerciseImage: handleUploadBotExerciseImage,
764
803
  makeChatGptApiRequest: makeChatGptApiRequest,
765
804
  handleUpdateBot: handleUpdateBot,
766
- generateRiveFile: generateRiveFile,
767
805
  learnLang: learnLang,
768
806
  siteLanguage: siteLanguage,
769
807
  topicPhrases: topicPhrases,
770
808
  phraseLists: phraseLists,
771
- updateRivescriptJSON: updateRivescriptJSON,
772
809
  courseId: courseId,
773
810
  sectionId: sectionId,
774
811
  topicId: topicId,
775
812
  initialTopicGoal: initialTopicGoal,
813
+ verificationStatus: verificationStatus,
814
+ isAuthorFromScratch: isAuthorFromScratch,
815
+ botContentTemplateType: botContentTemplateType,
816
+ updateBotContent: updateBotContent,
776
817
  openSnackbar: openSnackbar
777
818
  }) : null, tabValue === 1 ? /*#__PURE__*/_jsx(Box, {
778
819
  className: hidden ? classes.paneFull : classes.pane,