@learnpack/learnpack 5.0.306 → 5.0.308

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.
@@ -472,6 +472,9 @@
472
472
  .mx-2 {
473
473
  margin-inline: calc(var(--spacing) * 2);
474
474
  }
475
+ .mx-4 {
476
+ margin-inline: calc(var(--spacing) * 4);
477
+ }
475
478
  .mx-auto {
476
479
  margin-inline: auto;
477
480
  }
@@ -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-CTQfJYti.js"></script>
14
- <link rel="stylesheet" crossorigin href="/creator/assets/index-DTjdV1LF.css">
13
+ <script type="module" crossorigin src="/creator/assets/index-B37w_ZhT.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/creator/assets/index-D4SYZg0r.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -59,7 +59,10 @@ declare const _default: {
59
59
  }>;
60
60
  updateAsset: (token: string, assetSlug: string, asset: Partial<TAssetMissing>) => Promise<any>;
61
61
  getCategories: (token: string) => Promise<any>;
62
- updateRigoAssetID: (token: string, slug: string, asset_id: number) => Promise<any>;
62
+ updateRigoPackage: (token: string, slug: string, updates: {
63
+ asset_id?: number;
64
+ new_slug?: string;
65
+ }) => Promise<any>;
63
66
  createRigoPackage: (token: string, slug: string, config: any) => Promise<any>;
64
67
  getCurrentTechnologies: () => TTechnology[];
65
68
  updateTechnologiesPeriodically: typeof updateTechnologiesPeriodically;
package/lib/utils/api.js CHANGED
@@ -420,11 +420,11 @@ const getCategories = async (token) => {
420
420
  throw error;
421
421
  }
422
422
  };
423
- const updateRigoAssetID = async (token, slug, asset_id) => {
423
+ const updateRigoPackage = async (token, slug, updates) => {
424
424
  const cleanToken = token.replace(/[\n\r]/g, "");
425
425
  const url = `${exports.RIGOBOT_HOST}/v1/learnpack/package/${slug}/`;
426
426
  try {
427
- const response = await axios_1.default.put(url, { asset_id }, {
427
+ const response = await axios_1.default.put(url, updates, {
428
428
  headers: {
429
429
  Authorization: "Token " + cleanToken,
430
430
  },
@@ -516,7 +516,7 @@ exports.default = {
516
516
  doesAssetExists: exports.doesAssetExists,
517
517
  updateAsset,
518
518
  getCategories,
519
- updateRigoAssetID,
519
+ updateRigoPackage,
520
520
  createRigoPackage,
521
521
  getCurrentTechnologies: exports.getCurrentTechnologies,
522
522
  updateTechnologiesPeriodically,
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.306",
4
+ "version": "5.0.308",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {
@@ -8,7 +8,7 @@ import * as fs from "fs"
8
8
  import * as path from "path"
9
9
  import * as archiver from "archiver"
10
10
  import axios from "axios"
11
- import FormData = require("form-data");
11
+ import FormData = require("form-data")
12
12
  import Console from "../utils/console"
13
13
  import {
14
14
  decompress,
@@ -60,11 +60,9 @@ export const handleAssetCreation = async (
60
60
  readme_raw: b64IndexReadme,
61
61
  all_translations,
62
62
  })
63
- await api.updateRigoAssetID(
64
- sessionPayload.token,
65
- learnJson.slug,
66
- asset.id
67
- )
63
+ await api.updateRigoPackage(sessionPayload.token, learnJson.slug, {
64
+ asset_id: asset.id,
65
+ })
68
66
  Console.info("Asset created with id", asset.id)
69
67
  return asset
70
68
  }
@@ -77,10 +75,12 @@ export const handleAssetCreation = async (
77
75
  description: learnJson.description[selectedLang],
78
76
  all_translations,
79
77
  })
80
- await api.updateRigoAssetID(
78
+ await api.updateRigoPackage(
81
79
  sessionPayload.rigobotToken.trim(),
82
80
  learnJson.slug,
83
- asset.id
81
+ {
82
+ asset_id: asset.id,
83
+ }
84
84
  )
85
85
  Console.info("Asset updated with id", asset.id)
86
86
  return asset
@@ -117,19 +117,19 @@ const runAudit = (strict: boolean) => {
117
117
  }
118
118
 
119
119
  type Academy = {
120
- id: number;
121
- name: string;
122
- slug?: string;
123
- timezone?: string;
124
- };
120
+ id: number
121
+ name: string
122
+ slug?: string
123
+ timezone?: string
124
+ }
125
125
 
126
126
  type Category = {
127
- id: number;
128
- slug: string;
129
- title: string;
130
- lang: string;
131
- academy: Academy;
132
- };
127
+ id: number
128
+ slug: string
129
+ title: string
130
+ lang: string
131
+ academy: Academy
132
+ }
133
133
 
134
134
  function getCategoriesByAcademy(
135
135
  categories: Category[],
@@ -292,9 +292,7 @@ class BuildCommand extends SessionCommand {
292
292
 
293
293
  if (configObject) {
294
294
  const { config } = configObject
295
- const appAlreadyExists = checkIfDirectoryExists(
296
- `${config?.dirPath}/_app`
297
- )
295
+ const appAlreadyExists = checkIfDirectoryExists(`${config?.dirPath}/_app`)
298
296
 
299
297
  if (!appAlreadyExists) {
300
298
  // download app and decompress
@@ -20,7 +20,6 @@ import * as archiver from "archiver"
20
20
  import * as mkdirp from "mkdirp"
21
21
  import { convert } from "html-to-text"
22
22
  import * as rimraf from "rimraf"
23
- import { v4 as uuidv4 } from "uuid"
24
23
  import SessionCommand from "../utils/SessionCommand"
25
24
  import { Bucket, Storage } from "@google-cloud/storage"
26
25
  import { downloadEditor, decompress } from "../managers/file"
@@ -44,7 +43,6 @@ import * as dotenv from "dotenv"
44
43
 
45
44
  // import { v4 as uuidv4 } from "uuid"
46
45
  import {
47
- extractImagesFromMarkdown,
48
46
  getFilenameFromUrl,
49
47
  getReadmeExtension,
50
48
  } from "../utils/creatorUtilities"
@@ -52,11 +50,8 @@ import {
52
50
  import axios from "axios"
53
51
  import * as FormData from "form-data"
54
52
  import api, { RIGOBOT_HOST, RIGOBOT_REALTIME_HOST } from "../utils/api"
55
- import {
56
- createUploadMiddleware,
57
- minutesToISO8601Duration,
58
- } from "../utils/misc"
59
- import { buildConfig, ConfigResponse } from "../utils/configBuilder"
53
+ import { createUploadMiddleware, minutesToISO8601Duration } from "../utils/misc"
54
+ import { buildConfig } from "../utils/configBuilder"
60
55
  import { checkReadability, slugify } from "../utils/creatorUtilities"
61
56
  import { checkAndFixSidebarPure } from "../utils/sidebarGenerator"
62
57
  import { handleAssetCreation } from "./publish"
@@ -67,20 +62,6 @@ const frontMatter = require("front-matter")
67
62
 
68
63
  dotenv.config()
69
64
 
70
- // Asegúrate de tener uuid instalado
71
- // npm install uuid
72
-
73
- function findLast<T>(
74
- array: T[],
75
- predicate: (item: T) => boolean
76
- ): T | undefined {
77
- for (let i = array.length - 1; i >= 0; i--) {
78
- if (predicate(array[i])) return array[i]
79
- }
80
-
81
- return undefined
82
- }
83
-
84
65
  export const createLearnJson = (courseInfo: FormState) => {
85
66
  // console.log("courseInfo to create learn json", courseInfo)
86
67
 
@@ -687,7 +668,6 @@ const fixPreviewUrl = (slug: string, previewUrl: string) => {
687
668
  }
688
669
 
689
670
  const expectedUrl = `https://${slug}.learn-pack.com/preview.png`
690
- console.log("Preview url fixed!", expectedUrl)
691
671
  return expectedUrl
692
672
  }
693
673
 
@@ -713,13 +693,11 @@ export default class ServeCommand extends SessionCommand {
713
693
 
714
694
  async init() {
715
695
  const { flags } = this.parse(ServeCommand)
716
- console.log("Initializing serve command")
717
696
  }
718
697
 
719
698
  async run() {
720
699
  const crendsEnv = process.env.GCP_CREDENTIALS_JSON
721
700
  if (!crendsEnv) {
722
- console.log("GCP_CREDENTIALS_JSON is not set")
723
701
  process.exit(1)
724
702
  }
725
703
 
@@ -737,10 +715,12 @@ export default class ServeCommand extends SessionCommand {
737
715
  const host = process.env.HOST
738
716
 
739
717
  if (!host) {
740
- console.log("HOST is not set")
718
+ console.log(
719
+ "ERROR: HOST is not set, please set the HOST environment variable"
720
+ )
741
721
  process.exit(1)
742
722
  } else {
743
- console.log("HOST is set to", host)
723
+ console.log("INFO: HOST is set to", host)
744
724
  }
745
725
 
746
726
  // async function listFilesWithPrefix(prefix: string) {
@@ -807,7 +787,7 @@ export default class ServeCommand extends SessionCommand {
807
787
  })
808
788
 
809
789
  stream.on("finish", () => {
810
- console.log(`✅ Uploaded to: ${file.name}`)
790
+ console.log(`INFO: Uploaded to: ${file.name}`)
811
791
  res.send("File uploaded successfully")
812
792
  })
813
793
 
@@ -817,7 +797,7 @@ export default class ServeCommand extends SessionCommand {
817
797
  const upload = createUploadMiddleware()
818
798
 
819
799
  app.post("/upload-image-file", upload.single("file"), async (req, res) => {
820
- console.log("UPLOADING IMAGE FILE")
800
+ console.log("INFO: Uploading image file")
821
801
 
822
802
  const destination = (req.body as { destination?: string }).destination
823
803
 
@@ -842,7 +822,7 @@ export default class ServeCommand extends SessionCommand {
842
822
  contentType: req.file.mimetype,
843
823
  })
844
824
 
845
- console.log(`✅ Image uploaded to ${file.name}`)
825
+ console.log(`INFO: Image uploaded to ${file.name}`)
846
826
  res.json({ message: "Image uploaded successfully", path: file.name })
847
827
  } catch (error) {
848
828
  console.error("❌ upload-image-file error:", error)
@@ -1075,9 +1055,7 @@ export default class ServeCommand extends SessionCommand {
1075
1055
  fileObj.solution,
1076
1056
  solutionFilePath
1077
1057
  )
1078
- console.log(
1079
- `✅ Saved solution file: ${solutionFilePath}`
1080
- )
1058
+ console.log(`✅ Saved solution file: ${solutionFilePath}`)
1081
1059
  } else {
1082
1060
  // If no extension, just add .solution.hide
1083
1061
  const solutionFileName = `${fileObj.name}.solution.hide`
@@ -1088,9 +1066,7 @@ export default class ServeCommand extends SessionCommand {
1088
1066
  fileObj.solution,
1089
1067
  solutionFilePath
1090
1068
  )
1091
- console.log(
1092
- `✅ Saved solution file: ${solutionFilePath}`
1093
- )
1069
+ console.log(`✅ Saved solution file: ${solutionFilePath}`)
1094
1070
  }
1095
1071
  }
1096
1072
  } catch (parseError) {
@@ -1976,10 +1952,6 @@ export default class ServeCommand extends SessionCommand {
1976
1952
 
1977
1953
  try {
1978
1954
  const { config, exercises } = await buildConfig(bucket, courseSlug)
1979
-
1980
- console.log("CONFIG", config)
1981
- console.log("EXERCISES", exercises)
1982
-
1983
1955
  res.set("X-Creator-Web", "true")
1984
1956
  res.set("Access-Control-Expose-Headers", "X-Creator-Web")
1985
1957
 
@@ -2221,11 +2193,11 @@ export default class ServeCommand extends SessionCommand {
2221
2193
  new_languages: neededLanguagesList.join(","),
2222
2194
  })
2223
2195
 
2224
- const translateTitle = JSON.parse(result.parsed.title)
2225
- const translateDescription = JSON.parse(result.parsed.description)
2196
+ const translatedTitle = JSON.parse(result.parsed.title)
2197
+ const translatedDescription = JSON.parse(result.parsed.description)
2226
2198
 
2227
- courseJson.title = translateTitle
2228
- courseJson.description = translateDescription
2199
+ courseJson.title = translatedTitle
2200
+ courseJson.description = translatedDescription
2229
2201
 
2230
2202
  await uploadFileToBucket(
2231
2203
  bucket,
@@ -2593,10 +2565,12 @@ export default class ServeCommand extends SessionCommand {
2593
2565
  .json({ error: "Authentication failed, missing tokens" })
2594
2566
  }
2595
2567
 
2596
- const { config, exercises }: ConfigResponse = await buildConfig(
2597
- bucket,
2598
- slug
2568
+ const configFile = await bucket.file(
2569
+ `courses/${slug}/.learn/config.json`
2599
2570
  )
2571
+ const [configContent] = await configFile.download()
2572
+ const configJson = JSON.parse(configContent.toString())
2573
+ const { config, exercises } = configJson
2600
2574
 
2601
2575
  const prefix = `courses/${slug}/`
2602
2576
  const tmpRoot = path.join(os.tmpdir(), `learnpack-${slug}`)
@@ -2838,9 +2812,7 @@ export default class ServeCommand extends SessionCommand {
2838
2812
  })
2839
2813
  } catch (error: any) {
2840
2814
  console.error("❌ /actions/fetch error:", error.message || error)
2841
- res
2842
- .status(500)
2843
- .json({ error: error.message || "Failed to fetch link" })
2815
+ res.status(500).json({ error: error.message || "Failed to fetch link" })
2844
2816
  }
2845
2817
  })
2846
2818
  app.delete("/packages/:slug", async (req, res) => {
@@ -2881,6 +2853,135 @@ export default class ServeCommand extends SessionCommand {
2881
2853
  }
2882
2854
  })
2883
2855
 
2856
+ app.post("/actions/change-slug", async (req, res) => {
2857
+ const { currentSlug, newSlug } = req.body
2858
+ const rigoToken = req.header("x-rigo-token")
2859
+
2860
+ if (!rigoToken) {
2861
+ return res.status(400).json({
2862
+ error: "Rigo token is required. x-rigo-token header is missing",
2863
+ })
2864
+ }
2865
+
2866
+ if (!currentSlug || !newSlug) {
2867
+ return res.status(400).json({
2868
+ error: "Both currentSlug and newSlug are required",
2869
+ })
2870
+ }
2871
+
2872
+ if (currentSlug === newSlug) {
2873
+ return res.status(200).json({
2874
+ message: "Slug unchanged",
2875
+ newSlug: currentSlug,
2876
+ })
2877
+ }
2878
+
2879
+ try {
2880
+ // Check if new slug is available via RigoBot
2881
+ const isAvailable = await isValidRigoToken(rigoToken)
2882
+ if (!isAvailable) {
2883
+ return res.status(401).json({ error: "Invalid Rigo token" })
2884
+ }
2885
+
2886
+ // Check slug availability
2887
+ const slugCheckUrl = `${RIGOBOT_HOST}/v1/learnpack/check-slug-availability?slug=${encodeURIComponent(
2888
+ newSlug
2889
+ )}`
2890
+ const slugResponse = await axios.get(slugCheckUrl)
2891
+
2892
+ if (!slugResponse.data.available) {
2893
+ return res.status(409).json({
2894
+ error: "Slug is not available",
2895
+ available: false,
2896
+ })
2897
+ }
2898
+
2899
+ // Get all files with current slug prefix
2900
+ const currentPrefix = `courses/${currentSlug}/`
2901
+ const [files] = await bucket.getFiles({ prefix: currentPrefix })
2902
+
2903
+ if (files.length === 0) {
2904
+ return res.status(404).json({
2905
+ error: "No files found for current slug",
2906
+ })
2907
+ }
2908
+
2909
+ // Copy all files to new slug location
2910
+ const newPrefix = `courses/${newSlug}/`
2911
+
2912
+ for (const file of files) {
2913
+ const newFileName = file.name.replace(currentPrefix, newPrefix)
2914
+ // eslint-disable-next-line no-await-in-loop
2915
+ await file.copy(newFileName)
2916
+ }
2917
+
2918
+ // Update learn.json with new slug
2919
+ const learnJsonFile = bucket.file(`${newPrefix}learn.json`)
2920
+ const [learnJsonContent] = await learnJsonFile.download()
2921
+ const learnJson = JSON.parse(learnJsonContent.toString())
2922
+ learnJson.slug = newSlug
2923
+ await uploadFileToBucket(
2924
+ bucket,
2925
+ JSON.stringify(learnJson),
2926
+ `${newPrefix}learn.json`
2927
+ )
2928
+
2929
+ // Update initialSyllabus.json with new slug
2930
+ const syllabusFile = bucket.file(
2931
+ `${newPrefix}.learn/initialSyllabus.json`
2932
+ )
2933
+ const [syllabusContent] = await syllabusFile.download()
2934
+ const syllabus = JSON.parse(syllabusContent.toString())
2935
+ syllabus.courseInfo.slug = newSlug
2936
+ await uploadFileToBucket(
2937
+ bucket,
2938
+ JSON.stringify(syllabus),
2939
+ `${newPrefix}.learn/initialSyllabus.json`
2940
+ )
2941
+
2942
+ // Update config.json with new slug
2943
+ const configFile = bucket.file(`${newPrefix}.learn/config.json`)
2944
+ const [configContent] = await configFile.download()
2945
+ const config = JSON.parse(configContent.toString())
2946
+ config.config.slug = newSlug
2947
+ await uploadFileToBucket(
2948
+ bucket,
2949
+ JSON.stringify(config),
2950
+ `${newPrefix}.learn/config.json`
2951
+ )
2952
+
2953
+ // Update Rigobot package slug
2954
+ const updateUrl = `${RIGOBOT_HOST}/v1/learnpack/package/${currentSlug}/`
2955
+ await axios.put(
2956
+ updateUrl,
2957
+ { new_slug: newSlug },
2958
+ {
2959
+ headers: {
2960
+ Authorization: "Token " + rigoToken.trim(),
2961
+ },
2962
+ }
2963
+ )
2964
+
2965
+ // Delete old files
2966
+ await Promise.all(files.map(file => file.delete()))
2967
+
2968
+ console.log(
2969
+ `✅ Successfully changed slug from ${currentSlug} to ${newSlug}`
2970
+ )
2971
+
2972
+ return res.json({
2973
+ message: "Slug changed successfully",
2974
+ newSlug: newSlug,
2975
+ })
2976
+ } catch (error) {
2977
+ console.error("❌ Error changing slug:", error)
2978
+ return res.status(500).json({
2979
+ error: "Failed to change slug",
2980
+ details: (error as Error).message,
2981
+ })
2982
+ }
2983
+ })
2984
+
2884
2985
  app.get("/proxy", async (req, res) => {
2885
2986
  const { url } = req.query
2886
2987
 
@@ -3020,10 +3121,7 @@ export default class ServeCommand extends SessionCommand {
3020
3121
  !metadata.rights ||
3021
3122
  !metadata.lang
3022
3123
  ) {
3023
- console.log(
3024
- "Missing required metadata for EPUB export",
3025
- metadata
3026
- )
3124
+ console.log("Missing required metadata for EPUB export", metadata)
3027
3125
  return res.status(400).json({
3028
3126
  error: "Missing required metadata for EPUB export",
3029
3127
  required: ["creator", "publisher", "title", "rights", "lang"],
@@ -3068,9 +3166,7 @@ export default class ServeCommand extends SessionCommand {
3068
3166
  })
3069
3167
  } catch (error: any) {
3070
3168
  console.error("Export error:", error)
3071
- res
3072
- .status(500)
3073
- .json({ error: "Export failed", details: error.message })
3169
+ res.status(500).json({ error: "Export failed", details: error.message })
3074
3170
  }
3075
3171
  })
3076
3172
 
@@ -31,6 +31,7 @@ import NotificationListener from "./components/NotificationListener"
31
31
  import { slugify } from "./utils/creatorUtils"
32
32
  import TurnstileModal from "./components/TurnstileModal"
33
33
  import { TMessage } from "./components/Message"
34
+ import LanguageDetectionModal from "./components/LanguageDetectionModal"
34
35
 
35
36
  function App() {
36
37
  const navigate = useNavigate()
@@ -72,6 +73,8 @@ function App() {
72
73
 
73
74
  const [notificationId, setNotificationId] = useState<string>("")
74
75
  const [showTurnstileModal, setShowTurnstileModal] = useState(false)
76
+ const [showLanguageDetectionModal, setShowLanguageDetectionModal] = useState(false)
77
+ const [detectedLanguage, setDetectedLanguage] = useState<string>("")
75
78
 
76
79
  useEffect(() => {
77
80
  if (formState.isCompleted) {
@@ -130,27 +133,45 @@ function App() {
130
133
  if (duration && !isNaN(parseInt(duration))) {
131
134
  if (["15", "30", "60"].includes(duration)) {
132
135
  const durationInt = parseInt(duration)
133
- console.log("duration", durationInt)
134
136
  setFormState({
135
137
  duration: durationInt,
136
138
  })
137
139
  } else {
138
- console.log("Invalid duration received in params", duration)
140
+ console.error("Invalid duration received in params", duration)
139
141
  }
140
142
  }
141
-
143
+
142
144
  if (plan) {
143
145
  setPlanToRedirect(plan)
144
146
  } else {
145
147
  console.debug("No plan received in params")
146
148
  }
149
+
150
+ // Language detection logic
151
+ let finalLanguage = language
147
152
 
148
- if (language && language.length === 2) {
153
+ // Change if query param is provided
154
+ if (finalLanguage && finalLanguage.length === 2) {
155
+ console.log("LANGUAGE", finalLanguage)
149
156
  setFormState({
150
- language: language,
157
+ language: finalLanguage,
151
158
  })
159
+ if (i18n.language !== finalLanguage) {
160
+ i18n.changeLanguage(finalLanguage)
161
+ }
152
162
  }
153
163
 
164
+ // If no language is provided, detect the language of the description
165
+ if (!finalLanguage && description) {
166
+ const detectedLang = detectLanguage(description)
167
+ if (detectedLang && detectedLang !== i18n.language) {
168
+ finalLanguage = detectedLang
169
+ setDetectedLanguage(detectedLang)
170
+ setShowLanguageDetectionModal(true)
171
+ }
172
+ }
173
+
174
+
154
175
  if (purpose && purpose.length > 0 && possiblePurposes.includes(purpose)) {
155
176
  setFormState({
156
177
  purpose: purpose,
@@ -233,8 +254,6 @@ function App() {
233
254
  formState.purpose || "learnpack-lesson-writer",
234
255
  tokenType === "rigo" ? false : true
235
256
  )
236
- console.log("RES", res)
237
-
238
257
  setNotificationId(res.notificationId)
239
258
  } catch (error) {
240
259
  console.error(error, "ERROR CREATING TUTORIAL")
@@ -254,6 +273,18 @@ function App() {
254
273
  }
255
274
  }
256
275
 
276
+ const handleLanguageSwitch = () => {
277
+ setFormState({
278
+ language: detectedLanguage,
279
+ })
280
+ i18n.changeLanguage(detectedLanguage)
281
+ setShowLanguageDetectionModal(false)
282
+ }
283
+
284
+ const handleLanguageStay = () => {
285
+ setShowLanguageDetectionModal(false)
286
+ }
287
+
257
288
  const buildSteps = () => {
258
289
  const steps = [
259
290
  {
@@ -263,9 +294,11 @@ function App() {
263
294
  required: true,
264
295
  validator: (value: string) => {
265
296
  const lang = detectLanguage(value)
297
+ console.log("Description validator language detection:", { lang, currentLang: i18n.language, value })
266
298
 
267
- if (lang) {
268
- i18n.changeLanguage(lang)
299
+ if (lang && lang !== "en" && lang !== i18n.language) {
300
+ setDetectedLanguage(lang)
301
+ setShowLanguageDetectionModal(true)
269
302
  }
270
303
 
271
304
  return value.length > 0
@@ -451,6 +484,13 @@ function App() {
451
484
  return (
452
485
  <>
453
486
  <ParamsChecker />
487
+ {showLanguageDetectionModal && (
488
+ <LanguageDetectionModal
489
+ detectedLanguage={detectedLanguage}
490
+ onSwitch={handleLanguageSwitch}
491
+ onStay={handleLanguageStay}
492
+ />
493
+ )}
454
494
  {showTurnstileModal && (
455
495
  <TurnstileModal
456
496
  onClose={() => {
@@ -477,7 +517,6 @@ function App() {
477
517
  {notificationId && (
478
518
  <NotificationListener
479
519
  onNotification={(res) => {
480
- console.log("Async response", res)
481
520
  const lessons = res.parsed.listOfSteps.map((lesson: any) => {
482
521
  return parseLesson(lesson, [])
483
522
  })
@@ -0,0 +1,46 @@
1
+ import { useTranslation } from "react-i18next"
2
+
3
+ interface LanguageDetectionModalProps {
4
+ detectedLanguage: string
5
+ onSwitch: () => void
6
+ onStay: () => void
7
+ }
8
+
9
+ export default function LanguageDetectionModal({
10
+ detectedLanguage,
11
+ onSwitch,
12
+ onStay,
13
+ }: LanguageDetectionModalProps) {
14
+ const { t, i18n } = useTranslation()
15
+
16
+ return (
17
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-1000">
18
+ <div className="bg-white p-6 rounded-xl shadow-md max-w-md w-full mx-4">
19
+ <h2 className="text-xl font-semibold text-center mb-4">
20
+ {t("languageDetection.title")}
21
+ </h2>
22
+ <p className="text-gray-600 text-center mb-6">
23
+ {t("languageDetection.message", {
24
+ language: t(`languageDetection.languages.${detectedLanguage.toLowerCase()}`)
25
+ })}
26
+ </p>
27
+ <div className="flex flex-col sm:flex-row gap-3">
28
+ <button
29
+ onClick={onSwitch}
30
+ className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md font-semibold hover:bg-blue-700 transition-colors"
31
+ >
32
+ {t("languageDetection.switchTo", {
33
+ language: t(`languageDetection.languages.${detectedLanguage.toLowerCase()}`)
34
+ })}
35
+ </button>
36
+ <button
37
+ onClick={onStay}
38
+ className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md font-semibold hover:bg-gray-300 transition-colors"
39
+ >
40
+ {t(`languageDetection.stayIn${i18n.language.toLowerCase()}`)}
41
+ </button>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ )
46
+ }