@learnpack/learnpack 5.0.359 → 5.0.360

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.
@@ -10,8 +10,8 @@
10
10
  />
11
11
 
12
12
  <title>Learnpack Creator: Craft tutorials in seconds!</title>
13
- <script type="module" crossorigin src="/creator/assets/index-D6pmbMe9.js"></script>
14
- <link rel="stylesheet" crossorigin href="/creator/assets/index-zrPponAn.css">
13
+ <script type="module" crossorigin src="/creator/assets/index-GYxGB_X0.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/creator/assets/index-B7s5Nmq8.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@learnpack/learnpack",
3
3
  "description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
4
- "version": "5.0.359",
4
+ "version": "5.0.360",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {
@@ -0,0 +1,338 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import { MemoryRouter } from "react-router";
11
+ import i18n from "./i18n";
12
+ import App from "./App";
13
+ import useStore, { FormState } from "./utils/store";
14
+ import { publicInteractiveCreation } from "./utils/rigo";
15
+ import type { ParsedFile } from "./components/FileUploader";
16
+ import { TooltipProvider } from "@/components/ui/tooltip";
17
+
18
+ // Network edges only: everything else (store, i18n, wizard, detection) is real.
19
+ vi.mock("./utils/rigo", () => ({
20
+ publicInteractiveCreation: vi.fn(async () => ({ notificationId: "job-1" })),
21
+ isHuman: vi.fn(),
22
+ }));
23
+ vi.mock("./utils/lib", async (importOriginal) => ({
24
+ ...(await importOriginal<typeof import("./utils/lib")>()),
25
+ isValidPublicToken: vi.fn(async () => true),
26
+ isValidRigoToken: vi.fn(async () => false),
27
+ getTechnologies: vi.fn(async () => []),
28
+ loginWithToken: vi.fn(),
29
+ }));
30
+ let deliverNotification: ((payload: unknown) => void) | null = null;
31
+ vi.mock("./components/NotificationListener", () => ({
32
+ default: ({ onNotification }: { onNotification: (p: unknown) => void }) => {
33
+ deliverNotification = onNotification;
34
+ return null;
35
+ },
36
+ }));
37
+ vi.mock("./components/TurnstileChallenge", () => ({ default: () => null }));
38
+ vi.mock("./components/TurnstileModal", () => ({ default: () => null }));
39
+
40
+ const ENGLISH_SEED =
41
+ "Relational databases store information in tables made of rows and columns. " +
42
+ "Each table describes one kind of thing, such as customers or orders, and the " +
43
+ "relationships between tables are expressed through keys. In this chapter you " +
44
+ "will learn how to write queries that filter, sort and join data. ";
45
+ const SPANISH_SEED =
46
+ "Las bases de datos relacionales guardan la información en tablas formadas " +
47
+ "por filas y columnas. Cada tabla describe un tipo de cosa, como clientes o " +
48
+ "pedidos, y las relaciones entre tablas se expresan mediante claves. En este " +
49
+ "capítulo aprenderás a escribir consultas que filtran y combinan datos. ";
50
+
51
+ const file = (text: string, status: ParsedFile["status"] = "SUCCESS"): ParsedFile => ({
52
+ name: "seed.pdf",
53
+ text,
54
+ status,
55
+ notificationId: "",
56
+ });
57
+
58
+ const sentCourseInfo = () => {
59
+ const calls = vi.mocked(publicInteractiveCreation).mock.calls;
60
+ expect(calls).toHaveLength(1);
61
+ return calls[0][0].courseInfo;
62
+ };
63
+
64
+ const ASK_MESSAGE = "Which language should the course be in?";
65
+
66
+ const setUp = async ({
67
+ form = {},
68
+ files = [],
69
+ url = "/creator",
70
+ interfaceLanguage = "en",
71
+ }: {
72
+ form?: Partial<FormState>;
73
+ files?: ParsedFile[];
74
+ url?: string;
75
+ interfaceLanguage?: string;
76
+ }) => {
77
+ localStorage.clear();
78
+ window.history.replaceState({}, "", url);
79
+ useStore.setState(useStore.getInitialState(), true);
80
+ const store = useStore.getState();
81
+ store.setAuth({ ...store.auth, publicToken: "public-token" });
82
+ store.setFormState({ description: "React hooks", purpose: "learnpack-lesson-writer", duration: 30, ...form });
83
+ store.setUploadedFiles(files);
84
+ await act(() => i18n.changeLanguage(interfaceLanguage));
85
+ // Mounted like main.tsx does.
86
+ render(
87
+ <TooltipProvider>
88
+ <MemoryRouter>
89
+ <App />
90
+ </MemoryRouter>
91
+ </TooltipProvider>
92
+ );
93
+ };
94
+
95
+ /** What the wizard does when its last step is answered. */
96
+ const finishWizard = () =>
97
+ act(() => useStore.getState().setFormState({ isCompleted: true }));
98
+
99
+ beforeEach(() => {
100
+ vi.mocked(publicInteractiveCreation).mockClear();
101
+ deliverNotification = null;
102
+ });
103
+ afterEach(cleanup);
104
+
105
+ describe("course language at generation", () => {
106
+ it("follows an English seed under a Spanish interface (the reported bug)", async () => {
107
+ await setUp({
108
+ form: { description: "Learn how to document REST APIs", hasContentIndex: true },
109
+ files: [file(ENGLISH_SEED)],
110
+ interfaceLanguage: "es",
111
+ });
112
+ finishWizard();
113
+
114
+ await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
115
+ expect(screen.queryByText("¿En qué idioma quieres el curso?")).toBeNull();
116
+ expect(sentCourseInfo()).toContain('"language":"en"');
117
+ expect(sentCourseInfo()).toContain("The user chose English as the course language.");
118
+
119
+ // The model still answers Spanish: the settled language wins.
120
+ act(() =>
121
+ deliverNotification!({
122
+ status: "SUCCESS",
123
+ parsed: {
124
+ title: "Documentar APIs REST",
125
+ description: "Un curso.",
126
+ languageCode: "es",
127
+ technologies: [],
128
+ aiMessage: "Listo.",
129
+ listOfSteps: ["00.0 - Welcome [READ: ...]"],
130
+ },
131
+ })
132
+ );
133
+ const { courseInfo } = useStore.getState().history[0];
134
+ expect(courseInfo.language).toBe("en");
135
+ expect("languageChosen" in courseInfo).toBe(false);
136
+ expect(i18n.language).toBe("en");
137
+ });
138
+
139
+ it("asks when the seed and the description disagree, and sends the answer", async () => {
140
+ await setUp({
141
+ form: { description: "Quiero aprender a documentar APIs REST", hasContentIndex: true },
142
+ files: [file(ENGLISH_SEED)],
143
+ });
144
+ finishWizard();
145
+
146
+ expect(await screen.findByText(ASK_MESSAGE)).toBeTruthy();
147
+ expect(publicInteractiveCreation).not.toHaveBeenCalled();
148
+
149
+ fireEvent.click(screen.getByText("Spanish"));
150
+
151
+ await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
152
+ expect(sentCourseInfo()).toContain('"language":"es"');
153
+ expect(sentCourseInfo()).toContain("The user chose Spanish as the course language.");
154
+ expect(useStore.getState().formState).toMatchObject({
155
+ language: "es",
156
+ languageChosen: true,
157
+ });
158
+ });
159
+
160
+ it("asks when uploaded files disagree with each other", async () => {
161
+ await setUp({
162
+ form: { description: "React hooks", hasContentIndex: true },
163
+ files: [file(ENGLISH_SEED), file(SPANISH_SEED)],
164
+ });
165
+ finishWizard();
166
+
167
+ expect(await screen.findByText(ASK_MESSAGE)).toBeTruthy();
168
+ expect(publicInteractiveCreation).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it("reads pasted text as the seed", async () => {
172
+ await setUp({
173
+ form: {
174
+ description: "Learn how to design databases",
175
+ hasContentIndex: true,
176
+ currentStep: "contentIndex",
177
+ variables: ["description", "contentIndex"],
178
+ },
179
+ });
180
+
181
+ fireEvent.click(screen.getByText("Write or paste a content table"));
182
+ fireEvent.change(screen.getByPlaceholderText("Add your text here..."), {
183
+ target: { value: SPANISH_SEED },
184
+ });
185
+ fireEvent.click(screen.getByText("Finish"));
186
+
187
+ expect(await screen.findByText(ASK_MESSAGE)).toBeTruthy();
188
+ });
189
+
190
+ it("ignores files that are still being read", async () => {
191
+ await setUp({
192
+ form: { description: "React hooks", hasContentIndex: true },
193
+ files: [file(ENGLISH_SEED), file(SPANISH_SEED, "PROCESSING")],
194
+ });
195
+ finishWizard();
196
+
197
+ await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
198
+ expect(sentCourseInfo()).toContain("The user chose English as the course language.");
199
+ });
200
+
201
+ it("leaves the request untouched when the user said there is no content", async () => {
202
+ // Files left over from an earlier attempt are not the user's seed.
203
+ await setUp({
204
+ form: { description: "Quiero aprender SQL", hasContentIndex: false },
205
+ files: [file(ENGLISH_SEED)],
206
+ });
207
+ finishWizard();
208
+
209
+ await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
210
+ expect(screen.queryByText(ASK_MESSAGE)).toBeNull();
211
+ expect(sentCourseInfo()).not.toContain("as the course language");
212
+ expect(sentCourseInfo()).not.toContain("languageChosen");
213
+ });
214
+ });
215
+
216
+ describe("course language picker", () => {
217
+ it("starts on Auto", async () => {
218
+ await setUp({});
219
+ expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("auto");
220
+ });
221
+
222
+ it("wins over the seed without asking", async () => {
223
+ await setUp({
224
+ form: { description: "Learn how to document REST APIs", hasContentIndex: true },
225
+ files: [file(ENGLISH_SEED)],
226
+ });
227
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "es" } });
228
+ expect(i18n.language).toBe("es");
229
+ finishWizard();
230
+
231
+ await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
232
+ expect(screen.queryByText("¿En qué idioma quieres el curso?")).toBeNull();
233
+ expect(sentCourseInfo()).toContain("The user chose Spanish as the course language.");
234
+ });
235
+
236
+ it("explains Auto in a hint", async () => {
237
+ await setUp({});
238
+ fireEvent.focus(screen.getByRole("combobox"));
239
+
240
+ expect(
241
+ (await screen.findAllByText(i18n.t("courseLanguage.autoHint"))).length
242
+ ).toBeGreaterThan(0);
243
+ });
244
+
245
+ it("hides the hint once a language is picked", async () => {
246
+ await setUp({});
247
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "en" } });
248
+ fireEvent.focus(screen.getByRole("combobox"));
249
+
250
+ expect(screen.queryAllByText(i18n.t("courseLanguage.autoHint"))).toHaveLength(0);
251
+ });
252
+
253
+ it("goes back to the defaults on Auto", async () => {
254
+ await setUp({});
255
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "es" } });
256
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "auto" } });
257
+
258
+ expect(useStore.getState().formState).toMatchObject({
259
+ language: "en",
260
+ languageChosen: false,
261
+ });
262
+ });
263
+ });
264
+
265
+ describe("language URL param", () => {
266
+ it("counts as the user's choice", async () => {
267
+ await setUp({
268
+ url: "/creator?language=es",
269
+ form: { description: "Learn how to document REST APIs", hasContentIndex: true },
270
+ files: [file(ENGLISH_SEED)],
271
+ });
272
+ expect(useStore.getState().formState).toMatchObject({
273
+ language: "es",
274
+ languageChosen: true,
275
+ });
276
+ finishWizard();
277
+
278
+ await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
279
+ expect(sentCourseInfo()).toContain("The user chose Spanish as the course language.");
280
+ });
281
+
282
+ it.each(["fr", "es-MX"])("ignores the unsupported value %j", async (value) => {
283
+ await setUp({ url: `/creator?language=${value}` });
284
+ expect(useStore.getState().formState).toMatchObject({
285
+ language: "en",
286
+ languageChosen: false,
287
+ });
288
+ });
289
+
290
+ it("accepts an uppercase value", async () => {
291
+ await setUp({ url: "/creator?language=ES" });
292
+ expect(useStore.getState().formState).toMatchObject({
293
+ language: "es",
294
+ languageChosen: true,
295
+ });
296
+ });
297
+ });
298
+
299
+ describe("description language modal", () => {
300
+ const typeDescriptionAndGoNext = (description: string) => {
301
+ fireEvent.change(screen.getByPlaceholderText(i18n.t("stepWizard.descriptionPlaceholder")), {
302
+ target: { value: description },
303
+ });
304
+ fireEvent.click(screen.getByText(i18n.t("stepWizard.next")));
305
+ };
306
+
307
+ it("still suggests Spanish for a Spanish description", async () => {
308
+ await setUp({ form: { description: "" } });
309
+ typeDescriptionAndGoNext("Quiero aprender a crear APIs con Flask");
310
+ expect(screen.getByText("Language detected")).toBeTruthy();
311
+ });
312
+
313
+ it("records staying as a choice", async () => {
314
+ await setUp({ form: { description: "" } });
315
+ typeDescriptionAndGoNext("Quiero aprender a crear APIs con Flask");
316
+ fireEvent.click(screen.getByText("Stay in English"));
317
+
318
+ expect(useStore.getState().formState).toMatchObject({
319
+ language: "en",
320
+ languageChosen: true,
321
+ });
322
+ });
323
+
324
+ it("does not ask again once a language is chosen", async () => {
325
+ await setUp({ form: { description: "" } });
326
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "en" } });
327
+ typeDescriptionAndGoNext("Quiero aprender a crear APIs con Flask");
328
+ expect(screen.queryByText("Language detected")).toBeNull();
329
+ });
330
+
331
+ it("does not offer English to an en-US interface", async () => {
332
+ await setUp({
333
+ url: "/creator?description=Learn%20how%20to%20build%20REST%20APIs%20with%20Flask",
334
+ interfaceLanguage: "en-US",
335
+ });
336
+ expect(screen.queryByText("Language detected")).toBeNull();
337
+ });
338
+ });
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react"
1
+ import { useEffect, useRef, useState } from "react"
2
2
  import StepWizard from "./components/StepWizard"
3
3
  import SelectableCard from "./components/SelectableCard"
4
4
  import Loader from "./components/Loader"
@@ -39,6 +39,17 @@ import TurnstileModal from "./components/TurnstileModal"
39
39
  import { TMessage } from "./components/Message"
40
40
  import { Lesson } from "./components/LessonItem"
41
41
  import LanguageDetectionModal from "./components/LanguageDetectionModal"
42
+ import CourseLanguagePicker, {
43
+ TCourseLanguageChoice,
44
+ } from "./components/CourseLanguagePicker"
45
+ import {
46
+ decideCourseLanguage,
47
+ detectDescriptionLanguage,
48
+ detectSeedLanguage,
49
+ TCourseLanguage,
50
+ toCourseLanguage,
51
+ } from "./utils/courseLanguage"
52
+ import { buildCourseInfo, toCourseFormState } from "./utils/creationRequest"
42
53
 
43
54
  function App() {
44
55
  const navigate = useNavigate()
@@ -82,6 +93,19 @@ function App() {
82
93
  const [showTurnstileModal, setShowTurnstileModal] = useState(false)
83
94
  const [showLanguageDetectionModal, setShowLanguageDetectionModal] = useState(false)
84
95
  const [detectedLanguage, setDetectedLanguage] = useState<string>("")
96
+ const [showCourseLanguageModal, setShowCourseLanguageModal] = useState(false)
97
+ // Readable content behind the last content-index answer (pasted text or a
98
+ // video transcript), for detecting the course language. Uploaded files are
99
+ // read from the store instead.
100
+ const seedTextRef = useRef<string | null>(null)
101
+ // Course language settled when generation is fired, reused when the result
102
+ // arrives so the stored language matches the one that was sent.
103
+ const courseLanguageRef = useRef<TCourseLanguage | null>(null)
104
+
105
+ // i18next's resolved language is always one with translations, so this is
106
+ // "en" for an "en-US" or a French browser alike.
107
+ const getInterfaceLanguage = (): TCourseLanguage =>
108
+ toCourseLanguage(i18n.resolvedLanguage) ?? "en"
85
109
 
86
110
  useEffect(() => {
87
111
  if (formState.isCompleted) {
@@ -162,24 +186,31 @@ function App() {
162
186
  }
163
187
 
164
188
  // Language detection logic
165
- let finalLanguage = language
189
+ // Only a language the creator supports counts, because it is now the
190
+ // user's choice and wins over detection and the model.
191
+ const urlLanguage = toCourseLanguage(language)
166
192
 
167
193
  // Change if query param is provided
168
- if (finalLanguage && finalLanguage.length === 2) {
169
- console.log("LANGUAGE", finalLanguage)
194
+ if (urlLanguage) {
195
+ console.log("LANGUAGE", urlLanguage)
170
196
  setFormState({
171
- language: finalLanguage,
197
+ language: urlLanguage,
198
+ languageChosen: true,
172
199
  })
173
- if (i18n.language !== finalLanguage) {
174
- i18n.changeLanguage(finalLanguage)
200
+ if (i18n.language !== urlLanguage) {
201
+ i18n.changeLanguage(urlLanguage)
175
202
  }
176
203
  }
177
204
 
178
- // If no language is provided, detect the language of the description
179
- if (!finalLanguage && description) {
205
+ // If no language is provided or already chosen, detect the language of
206
+ // the description
207
+ if (
208
+ !urlLanguage &&
209
+ description &&
210
+ !useStore.getState().formState.languageChosen
211
+ ) {
180
212
  const detectedLang = detectLanguage(description)
181
- if (detectedLang && detectedLang !== i18n.language) {
182
- finalLanguage = detectedLang
213
+ if (detectedLang && detectedLang !== getInterfaceLanguage()) {
183
214
  setDetectedLanguage(detectedLang)
184
215
  setShowLanguageDetectionModal(true)
185
216
  }
@@ -213,8 +244,37 @@ function App() {
213
244
  }
214
245
  }
215
246
 
247
+ /**
248
+ * Settles the course language from the user's choice or the content they
249
+ * gave. Reads the store directly because it also runs right after a language
250
+ * answer, before this render's formState has caught up.
251
+ */
252
+ const decideLanguageForGeneration = () => {
253
+ const { formState: current, uploadedFiles: files } = useStore.getState()
254
+ const seeds = current.hasContentIndex
255
+ ? [
256
+ ...files.filter((f) => f.status === "SUCCESS").map((f) => f.text),
257
+ ...(seedTextRef.current ? [seedTextRef.current] : []),
258
+ ]
259
+ : []
260
+
261
+ return decideCourseLanguage({
262
+ chosen: current.languageChosen ? toCourseLanguage(current.language) : null,
263
+ seed: detectSeedLanguage(seeds),
264
+ description: detectDescriptionLanguage(current.description),
265
+ })
266
+ }
267
+
216
268
  const handleCreateTutorial = async () => {
217
269
  try {
270
+ const decision = decideLanguageForGeneration()
271
+ if (decision.kind === "ask") {
272
+ setShowCourseLanguageModal(true)
273
+ return
274
+ }
275
+ const courseLanguage = decision.kind === "use" ? decision.language : null
276
+ courseLanguageRef.current = courseLanguage
277
+
218
278
  let isAuthenticated = false
219
279
  let tokenToUse = ""
220
280
  let tokenType = ""
@@ -248,7 +308,9 @@ function App() {
248
308
  return
249
309
  }
250
310
 
251
- let techs = technologies.filter((t) => t.lang === formState.language)
311
+ let techs = technologies.filter(
312
+ (t) => t.lang === (courseLanguage ?? formState.language)
313
+ )
252
314
 
253
315
  if (techs.length === 0) {
254
316
  techs = technologies.filter((t) => t.lang === "en")
@@ -256,14 +318,12 @@ function App() {
256
318
 
257
319
  const res = await publicInteractiveCreation(
258
320
  {
259
- courseInfo: `${JSON.stringify(
260
- formState
261
- )}. The following technologies are available, choose up to 3 from the following list: <techs>${techs
262
- .map((t) => t.slug)
263
- .join(", ")}</techs>
264
-
265
- The following files have been uploaded by the user: ${uploadedFiles.map((f) => `<FILE name="${f.name}">${f.text}</FILE>`).join("\n")}
266
- `,
321
+ courseInfo: buildCourseInfo({
322
+ formState,
323
+ techs,
324
+ uploadedFiles,
325
+ language: courseLanguage,
326
+ }),
267
327
  prevInteractions: "USER: " + formState.description,
268
328
  },
269
329
  tokenToUse,
@@ -329,14 +389,28 @@ function App() {
329
389
  )
330
390
  }
331
391
 
392
+ // A language settled before generation (the user's choice or the
393
+ // seed's) wins over the model's, which follows the description.
394
+ const courseLanguage = courseLanguageRef.current
395
+ if (
396
+ courseLanguage &&
397
+ parsed.languageCode &&
398
+ parsed.languageCode !== courseLanguage
399
+ ) {
400
+ console.warn(
401
+ `Asked for a course in "${courseLanguage}", the model answered "${parsed.languageCode}"`
402
+ )
403
+ }
404
+
332
405
  push({
333
406
  lessons,
334
407
  courseInfo: {
335
- ...formState,
408
+ ...toCourseFormState(formState),
336
409
  title: parsed.title,
337
410
  slug: slugify(fixTitleLength(parsed.title)),
338
411
  description: parsed.description,
339
- language: parsed.languageCode || formState.language || "en",
412
+ language:
413
+ courseLanguage || parsed.languageCode || formState.language || "en",
340
414
  technologies:
341
415
  parsed.technologies.length > 0
342
416
  ? parsed.technologies
@@ -344,8 +418,9 @@ function App() {
344
418
  },
345
419
  })
346
420
 
347
- if (parsed.languageCode) {
348
- i18n.changeLanguage(parsed.languageCode)
421
+ const interfaceLanguage = courseLanguage || parsed.languageCode
422
+ if (interfaceLanguage) {
423
+ i18n.changeLanguage(interfaceLanguage)
349
424
  }
350
425
 
351
426
  const initialMessages: TMessage[] = [
@@ -397,15 +472,42 @@ function App() {
397
472
  const handleLanguageSwitch = () => {
398
473
  setFormState({
399
474
  language: detectedLanguage,
475
+ languageChosen: true,
400
476
  })
401
477
  i18n.changeLanguage(detectedLanguage)
402
478
  setShowLanguageDetectionModal(false)
403
479
  }
404
480
 
405
481
  const handleLanguageStay = () => {
482
+ // Staying is a choice too: the course keeps the interface language.
483
+ setFormState({
484
+ language: getInterfaceLanguage(),
485
+ languageChosen: true,
486
+ })
406
487
  setShowLanguageDetectionModal(false)
407
488
  }
408
489
 
490
+ const handleCourseLanguageChoice = (language: TCourseLanguage) => {
491
+ setShowCourseLanguageModal(false)
492
+ setFormState({ language, languageChosen: true })
493
+ i18n.changeLanguage(language)
494
+ handleCreateTutorial()
495
+ }
496
+
497
+ const handleCourseLanguagePick = (choice: TCourseLanguageChoice) => {
498
+ if (choice === "auto") {
499
+ // Back to the store's defaults: nothing chosen, the model decides.
500
+ setFormState({ language: "en", languageChosen: false })
501
+ return
502
+ }
503
+ setFormState({ language: choice, languageChosen: true })
504
+ i18n.changeLanguage(choice)
505
+ }
506
+
507
+ const courseLanguageChoice: TCourseLanguageChoice =
508
+ (formState.languageChosen && toCourseLanguage(formState.language)) ||
509
+ "auto"
510
+
409
511
  const buildSteps = () => {
410
512
  const steps = [
411
513
  {
@@ -417,7 +519,12 @@ function App() {
417
519
  const lang = detectLanguage(value)
418
520
  console.log("Description validator language detection:", { lang, currentLang: i18n.language, value })
419
521
 
420
- if (lang && lang !== "en" && lang !== i18n.language) {
522
+ if (
523
+ !formState.languageChosen &&
524
+ lang &&
525
+ lang !== "en" &&
526
+ lang !== getInterfaceLanguage()
527
+ ) {
421
528
  setDetectedLanguage(lang)
422
529
  setShowLanguageDetectionModal(true)
423
530
  }
@@ -584,7 +691,8 @@ function App() {
584
691
  required: true,
585
692
  content: (
586
693
  <Uploader
587
- onFinish={(text) => {
694
+ onFinish={(text, seedText) => {
695
+ seedTextRef.current = seedText ?? null
588
696
  setFormState({
589
697
  contentIndex: text,
590
698
  isCompleted: true,
@@ -612,6 +720,18 @@ function App() {
612
720
  onStay={handleLanguageStay}
613
721
  />
614
722
  )}
723
+ {showCourseLanguageModal && (
724
+ <LanguageDetectionModal
725
+ detectedLanguage="en"
726
+ title={t("languageDetection.chooseTitle")}
727
+ message={t("languageDetection.chooseMessage")}
728
+ switchLabel={t("languageDetection.languages.en")}
729
+ stayLabel={t("languageDetection.languages.es")}
730
+ equalChoices
731
+ onSwitch={() => handleCourseLanguageChoice("en")}
732
+ onStay={() => handleCourseLanguageChoice("es")}
733
+ />
734
+ )}
615
735
  {showTurnstileModal && (
616
736
  <TurnstileModal
617
737
  onClose={() => {
@@ -658,6 +778,12 @@ function App() {
658
778
  )}
659
779
  <StepWizard
660
780
  hideLastButton={true}
781
+ footer={
782
+ <CourseLanguagePicker
783
+ value={courseLanguageChoice}
784
+ onChange={handleCourseLanguagePick}
785
+ />
786
+ }
661
787
  formState={formState}
662
788
  steps={buildSteps()}
663
789
  setFormState={setFormState}