feat: migrate course UI from supabase (#615)

* chore: fix storybook build

* chore: implement theming

* community list not showing properly

* refactor: migrate Chip component to @cio/ui/custom/chip

- Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package.
- Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality.
- Adjusted related components to ensure consistent usage of the updated Chip component.

* fix: align course sidebar design to match organization design (#611)

* fix: align course sidebar design to match organization design

* feat: add hideSearch prop to AppHeader and update sidebar layout

---------

Co-authored-by: Rotimi Best <[email protected]>

* refactor: replace PrimaryButton with Button component across multiple files

- Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton.
- Ensured consistent button usage throughout the application for improved styling and functionality.
- Removed the PrimaryButton component and its associated constants from the codebase.

* refactor: standardize button and input usage across components

- Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton).
- Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions.
- Removed unused components and streamlined imports to enhance code maintainability and readability.

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

- Refactored various components to centralize UI elements, improving discoverability and maintainability.
- Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application.
- Removed unused components and streamlined imports to enhance code clarity and reduce complexity.

* feat: add new documentation and refactor org and lms UI components

- Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference.
- Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality.
- Removed deprecated components and streamlined imports for better maintainability.
- Updated translations across multiple languages to ensure consistency in user experience.

* refactor: update UI components and improve styling consistency

- Refactored various components to enhance styling consistency, including updates to button and text color classes.
- Removed deprecated components and streamlined imports for better maintainability.
- Replaced instances of PageBody with a more flexible Page component structure across multiple routes.
- Updated translations and UI elements to align with the new design system.
- Deleted unused components to reduce code complexity and improve clarity.

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <[email protected]>
This commit is contained in:
Rotimi Best
2025-12-22 02:36:48 +01:00
committed by GitHub
co-authored by Abdulmuizz Hamzat
parent e6a3cf9332
commit 9fb6c7cbaa
401 changed files with 10489 additions and 11679 deletions
@@ -1,5 +1,5 @@
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
import { snackbar } from '$lib/components/Snackbar/store';
import { snackbar } from '$features/ui/snackbar/store';
export function formatYoutubeVideo(url: string, errors: Record<string, string>) {
console.log(`url`, url);
@@ -1,4 +1,4 @@
import { appInitApi } from '$lib/features/app/init.svelte';
import { appInitApi } from '$features/app/init.svelte';
import { authClient } from '$lib/utils/services/auth/client';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { goto } from '$app/navigation';
+39 -85
View File
@@ -1,51 +1,17 @@
import { tc } from '$lib/utils/functions/trycatch';
import { darken, lighten } from 'color2k';
function updateThemeClassInBody(newClass: string, customRegex?: string) {
const regex = /theme-[\w]+/gi;
document.body.className = document.body.className.replace(customRegex ?? regex, newClass);
}
import { tc } from '$lib/utils/functions/trycatch';
export function setTheme(_theme?: string) {
const theme = _theme || '';
// this condition checks if it's a hex code from the db or a specified theme
if (theme && !theme.includes('theme-')) {
// add the "custom-theme" styles to the head tag
injectCustomTheme(theme);
// add the "custom-theme" classname to the head tag
setCustomTheme('theme-custom');
} else if (!theme && document.body.className.includes('theme-')) {
// if no theme and a theme is already applied, remove it
updateThemeClassInBody(theme);
return;
}
// In case theme already exists in dom, don't add
if (document.body.className.includes(theme)) return;
// set the new theme
export function setTheme(theme: string) {
localStorage.setItem('theme', theme);
document.body.className = document.body.className.concat(' ', theme);
}
export function setCustomTheme(theme?: string) {
// In case the default theme is added but another theme exists
if (!theme && document.body.className.includes('theme-')) {
updateThemeClassInBody(theme || '');
return;
}
if (theme.includes('#')) {
const escapedHex = theme.replace(/"/g, '\\"');
document.body.setAttribute('data-theme', escapedHex);
// Remove any class starting with "theme-"
updateThemeClassInBody('');
const _theme = theme || '';
// Add the new theme if it doesn't already exist
if (!document.body.className.includes(_theme)) {
localStorage.setItem('theme', _theme);
document.body.className = document.body.className.concat(' ', _theme);
injectCustomTheme(escapedHex);
} else {
document.body.setAttribute('data-theme', theme);
}
}
@@ -54,10 +20,8 @@ const _lighten = (hex: string, no: number) => tc(() => lighten(hex, no), hex);
const _darken = (hex: string, no: number) => tc(() => darken(hex, no), hex);
export function injectCustomTheme(hex: string) {
const styleId = 'theme-custom';
let styleElement = document.getElementById(styleId);
// generate shades using color2k's lighten function
// Generate shades using color2k's lighten/darken functions
// Following the same pattern as predefined themes (e.g., purple)
const shades = {
50: _lighten(hex, 0.7),
100: _lighten(hex, 0.6),
@@ -72,46 +36,36 @@ export function injectCustomTheme(hex: string) {
};
const styleContent = `
.theme-custom .bg-primary-50 { background-color: ${shades[50]} !important; }
.theme-custom .bg-primary-100 { background-color: ${shades[100]} !important; }
.theme-custom .bg-primary-200 { background-color: ${shades[200]} !important; }
.theme-custom .bg-primary-300 { background-color: ${shades[300]} !important; }
.theme-custom .bg-primary-400 { background-color: ${shades[400]} !important; }
.theme-custom .bg-primary-500 { background-color: ${shades[500]} !important; }
.theme-custom .bg-primary-600 { background-color: ${shades[600]} !important; }
.theme-custom .bg-primary-700 { background-color: ${shades[700]} !important; }
.theme-custom .bg-primary-800 { background-color: ${shades[800]} !important; }
.theme-custom .bg-primary-900 { background-color: ${shades[900]} !important; }
body[data-theme="${hex}"] {
--primary: ${shades[600]};
--primary-foreground: ${shades[50]};
--ring: ${shades[400]};
--chart-1: ${shades[300]};
--chart-2: ${shades[500]};
--chart-3: ${shades[600]};
--chart-4: ${shades[700]};
--chart-5: ${shades[800]};
--sidebar-primary: ${shades[600]};
--sidebar-primary-foreground: ${shades[50]};
--sidebar-accent: ${shades[50]};
--sidebar-accent-foreground: ${shades[700]};
--sidebar-ring: ${shades[400]};
}
.theme-custom .text-primary-50 { color: ${shades[50]} !important; }
.theme-custom .text-primary-100 { color: ${shades[100]} !important; }
.theme-custom .text-primary-200 { color: ${shades[200]} !important; }
.theme-custom .text-primary-300 { color: ${shades[300]} !important; }
.theme-custom .text-primary-400 { color: ${shades[400]} !important; }
.theme-custom .text-primary-500 { color: ${shades[500]} !important; }
.theme-custom .text-primary-600 { color: ${shades[600]} !important; }
.theme-custom .text-primary-700 { color: ${shades[700]} !important; }
.theme-custom .text-primary-800 { color: ${shades[800]} !important; }
.theme-custom .text-primary-900 { color: ${shades[900]} !important; }
.theme-custom .border-primary-50 { border-color: ${shades[50]} !important; }
.theme-custom .border-primary-100 { border-color: ${shades[100]} !important; }
.theme-custom .border-primary-200 { border-color: ${shades[200]} !important; }
.theme-custom .border-primary-300 { border-color: ${shades[300]} !important; }
.theme-custom .border-primary-400 { border-color: ${shades[400]} !important; }
.theme-custom .border-primary-500 { border-color: ${shades[500]} !important; }
.theme-custom .border-primary-600 { border-color: ${shades[600]} !important; }
.theme-custom .border-primary-700 { border-color: ${shades[700]} !important; }
.theme-custom .border-primary-800 { border-color: ${shades[800]} !important; }
.theme-custom .border-primary-900 { border-color: ${shades[900]} !important; }
body.dark[data-theme="${hex}"],
html.dark body[data-theme="${hex}"] {
--primary: ${shades[500]};
--primary-foreground: ${shades[50]};
--ring: ${shades[900]};
--sidebar-primary: ${shades[500]};
--sidebar-primary-foreground: ${shades[50]};
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: ${shades[500]};
--sidebar-ring: ${shades[900]};
}
`;
if (styleElement) {
styleElement.innerHTML = styleContent;
} else {
styleElement = document.createElement('style');
styleElement.id = styleId;
styleElement.innerHTML = styleContent;
document.head.appendChild(styleElement);
}
const styleElement = document.createElement('style');
styleElement.innerHTML = styleContent;
document.head.appendChild(styleElement);
}
@@ -4,7 +4,7 @@ export function tc<T>(tryFunc: () => T, catchFunc: T | ((e?: Error) => T)) {
val = tryFunc();
} catch (e: unknown) {
if (typeof catchFunc === 'function') {
// @ts-ignore
// @ts-expect-error - catchFunc is a function
val = catchFunc(e);
} else {
val = catchFunc;
@@ -24,6 +24,10 @@ export class BaseApi {
isLoading = $state(false);
error = $state<string | null>(null);
constructor(loading = false) {
this.isLoading = loading;
this.error = null;
}
/**
* Execute an API request with automatic loading and error state management
* Type is automatically inferred from the RPC call (e.g., classroomio.organization.plan.$post)
@@ -1,15 +1,15 @@
import { type Writable, get } from 'svelte/store';
import { lessonDocUpload, lessonVideoUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import axios from 'axios';
import { classroomio } from '$lib/utils/services/api';
import { get } from 'svelte/store';
export type UploadType = 'document' | 'video' | 'generic';
export class GenericUploader {
public abortController: AbortController | null = null;
private uploadType: UploadType;
private uploadStore: Writable<any>;
private uploadStore: typeof lessonDocUpload | typeof lessonVideoUpload;
constructor(uploadType: UploadType) {
this.uploadType = uploadType;
@@ -27,7 +27,12 @@ export class GenericUploader {
}
});
return response.json();
const result = await response.json();
if (!result.success) {
throw new Error(result.message);
}
return result;
}
async getAllDownloadPresignedUrl(videoKeys: string[], docKeys: string[]) {
@@ -66,7 +71,12 @@ export class GenericUploader {
}
});
return response.json();
const result = await response.json();
if (!result.success) {
throw new Error(result.message);
}
return result;
}
async uploadFile(params: { url: string; file: File }) {
+2 -2
View File
@@ -1,7 +1,7 @@
import { browser, dev } from '$app/environment';
import { derived, writable } from 'svelte/store';
import type { AccountOrg } from '$lib/features/app/types';
import type { AccountOrg } from '$features/app/types';
import type { OrgTeamMember } from '../types/org';
import { PLAN } from '@cio/utils/plans';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
@@ -53,7 +53,7 @@ const getActivePlan = (org: AccountOrg) => {
export const currentOrgPlan = derived(currentOrg, ($currentOrg) => getActivePlan($currentOrg));
export const currentOrgPath = derived(currentOrg, ($currentOrg) =>
$currentOrg.siteName ? `/org/${$currentOrg.siteName}` : ''
$currentOrg.siteName ? `/org/${$currentOrg.siteName}` : '#'
);
export const currentOrgDomain = derived(currentOrg, ($currentOrg) => {
@@ -1085,7 +1085,6 @@
"students": "studerende",
"published": "Offentliggjort",
"unpublished": "Upubliceret",
"continue_course": "Fortsæt kursus",
"learn_more": "Lær mere",
"list_view": {
"title": "Titel",
@@ -1141,7 +1140,6 @@
"no_courses": "Ingen kurser i gang endnu",
"start_course": "Når du starter et kursus, afspejler dine fremskridt her",
"lms_dashboard_hero": "I dag er en anden dag for at bringe dig tættere på dine læringsmål. Giv ikke op, jo mere du lærer, jo bedre bliver du.",
"dont": "Giv ikke op",
"lessons_completed": "Lektioner afsluttet",
"no_courses_started": "Ingen kurser startede",
"revenue": "Indtægter",
@@ -1599,8 +1597,6 @@
},
"new_course_modal": {
"live_class_label": "Live klasse",
"live_class_subtitle": "Denne kursustype er ideel til bootcamps, hvor undervisningen er tidsbaseret, og du har brug for deltagelse og karaktergivning",
"self_paced_label": "Selv tempo",
"self_paced_subtitle": "Denne kursustype er ideel til kurser, hvor elever kan tage lektioner i deres eget tempo uden en lærer"
"self_paced_label": "Selv tempo"
}
}
}
@@ -916,7 +916,6 @@
"students": "Studenten",
"published": "Veröffentlicht",
"unpublished": "Unveröffentlicht",
"continue_course": "Kurs fortsetzen",
"list_view": {
"title": "Titel",
"description": "Beschreibung",
@@ -976,7 +975,6 @@
"no_courses": "Es sind noch keine Kurse im Gange",
"start_course": "Sobald Sie einen Kurs beginnen, werden Ihre Fortschritte hier angezeigt",
"lms_dashboard_hero": "Heute ist ein weiterer Tag, um Sie Ihren Lernzielen näher zu bringen. Geben Sie nicht auf, je mehr Sie lernen, desto besser werden Sie.",
"dont": "Gib nicht auf",
"lessons_completed": "Lektionen abgeschlossen",
"no_courses_started": "Es wurden keine Kurse gestartet",
"revenue": "Einnahmen",
@@ -1630,8 +1628,6 @@
},
"new_course_modal": {
"live_class_label": "Live-Klasse",
"live_class_subtitle": "Dieser Kurstyp ist ideal für Bootcamps, bei denen der Unterricht zeitbasiert ist und Anwesenheit und Benotung erforderlich sind",
"self_paced_label": "Eigenständig",
"self_paced_subtitle": "Dieser Kurstyp ist ideal für Kurse, bei denen die Studierenden den Unterricht in ihrem eigenen Tempo ohne Lehrer absolvieren können"
"self_paced_label": "Eigenständig"
}
}
}
@@ -19,9 +19,9 @@
"new_course_modal": {
"live_class_label": "Live Class",
"live_class_subtitle": "This course type is ideal for bootcamps where lessons are time based and you need attendance and grading",
"live_class_subtitle": "Ideal for bootcamps where lessons are time based",
"self_paced_label": "Self Paced",
"self_paced_subtitle": "This course type is ideal for courses where students can take lessons on their own pace without a teacher"
"self_paced_subtitle": "Best for courses where students can take lessons at their own pace"
},
"onboarding": {
@@ -479,10 +479,10 @@
"current_lesson": "Currently Learning",
"your_progress": "Your Progress",
"continue": "Continue",
"continue_learning": "Continue Learning",
"no_courses": "No courses in progress yet",
"start_course": "Once you start a course, your progress will reflect here",
"lms_dashboard_hero": "Today is another day to bring you closer to your learning goals. Don’t give up, the more you learn the better you get.",
"dont": "Don't give up",
"lessons_completed": "Lessons completed",
"no_courses_started": "No courses started",
"revenue": "Revenue",
@@ -536,7 +536,7 @@
"students": "students",
"published": "Published",
"unpublished": "Unpublished",
"continue_course": "Continue Course",
"continue_course": "Continue",
"learn_more": "Learn more",
"list_view": {
"title": "Title",
@@ -1294,6 +1294,7 @@
},
"analytics": {
"title": "Analytics",
"courses": "Courses",
"progress": "Progress",
"progress_description": "{value, number} of {total, number} complete",
@@ -916,7 +916,6 @@
"students": "estudiantes",
"published": "Publicado",
"unpublished": "Inédito",
"continue_course": "Continuar curso",
"list_view": {
"title": "Título",
"description": "Descripción",
@@ -976,7 +975,6 @@
"no_courses": "Aún no hay cursos en curso",
"start_course": "Una vez que comiences un curso, tu progreso se reflejará aquí.",
"lms_dashboard_hero": "Hoy es otro día para acercarte a tus objetivos de aprendizaje. No te rindas, cuanto más aprendas, mejor serás.",
"dont": "no te rindas",
"lessons_completed": "Lecciones completadas",
"no_courses_started": "No se iniciaron cursos",
"revenue": "Ganancia",
@@ -1630,8 +1628,6 @@
},
"new_course_modal": {
"live_class_label": "Clase en vivo",
"live_class_subtitle": "Este tipo de curso es ideal para bootcamps donde las lecciones se basan en el tiempo y necesitas asistencia y calificaciones.",
"self_paced_label": "a tu propio ritmo",
"self_paced_subtitle": "Este tipo de curso es ideal para cursos donde los estudiantes pueden tomar lecciones a su propio ritmo sin un profesor."
"self_paced_label": "a tu propio ritmo"
}
}
}
@@ -916,7 +916,6 @@
"students": "étudiants",
"published": "Publié",
"unpublished": "Inédit",
"continue_course": "Continuer le cours",
"list_view": {
"title": "Titre",
"description": "Description",
@@ -976,7 +975,6 @@
"no_courses": "Aucun cours en cours pour le moment",
"start_course": "Une fois que vous aurez commencé un cours, vos progrès seront reflétés ici",
"lms_dashboard_hero": "Aujourd'hui est un autre jour pour vous rapprocher de vos objectifs d'apprentissage. N'abandonnez pas, plus vous apprenez, mieux vous vous améliorez.",
"dont": "N'abandonne pas",
"lessons_completed": "Leçons terminées",
"no_courses_started": "Aucun cours n'a commencé",
"revenue": "Revenu",
@@ -1630,8 +1628,6 @@
},
"new_course_modal": {
"live_class_label": "Cours en direct",
"live_class_subtitle": "Ce type de cours est idéal pour les bootcamps où les cours sont basés sur le temps et où vous avez besoin d'assiduité et de notation.",
"self_paced_label": "À votre rythme",
"self_paced_subtitle": "Ce type de cours est idéal pour les cours où les étudiants peuvent suivre des cours à leur rythme sans professeur."
"self_paced_label": "À votre rythme"
}
}
}
@@ -916,7 +916,6 @@
"students": "छात्र",
"published": "प्रकाशित",
"unpublished": "अप्रकाशित",
"continue_course": "पाठ्यक्रम जारी रखें",
"list_view": {
"title": "शीर्षक",
"description": "विवरण",
@@ -976,7 +975,6 @@
"no_courses": "अभी तक कोई पाठ्यक्रम प्रगति पर नहीं है",
"start_course": "एक बार जब आप कोई कोर्स शुरू कर देंगे, तो आपकी प्रगति यहां दिखाई देगी",
"lms_dashboard_hero": "आज का दिन आपको आपके सीखने के लक्ष्यों के करीब लाने का एक और दिन है। हार मत मानो, जितना अधिक सीखोगे उतना बेहतर हो जाओगे।",
"dont": "हिम्मत मत हारो",
"lessons_completed": "पाठ पूरा हुआ",
"no_courses_started": "कोई पाठ्यक्रम शुरू नहीं हुआ",
"revenue": "आय",
@@ -1630,8 +1628,6 @@
},
"new_course_modal": {
"live_class_label": "लाइव क्लास",
"live_class_subtitle": "यह पाठ्यक्रम प्रकार बूटकैंप के लिए आदर्श है जहां पाठ समय आधारित होते हैं और आपको उपस्थिति और ग्रेडिंग की आवश्यकता होती है",
"self_paced_label": "स्वयं गति",
"self_paced_subtitle": "यह पाठ्यक्रम प्रकार उन पाठ्यक्रमों के लिए आदर्श है जहां छात्र शिक्षक के बिना अपनी गति से पाठ ले सकते हैं"
"self_paced_label": "स्वयं गति"
}
}
}
@@ -1081,7 +1081,6 @@
"students": "studenci",
"published": "Opublikowany",
"unpublished": "Niepublikowane",
"continue_course": "Kontynuuj kurs",
"learn_more": "Dowiedz się więcej",
"list_view": {
"title": "Tytuł",
@@ -1137,7 +1136,6 @@
"no_courses": "Nie ma jeszcze żadnych kursów",
"start_course": "Po rozpoczęciu kursu twoje postępy będą się tutaj odzwierciedlać",
"lms_dashboard_hero": "Dzisiaj jest kolejny dzień, aby zbliżyć cię do celów uczenia się. Nie poddawaj się, im więcej uczysz się, tym lepiej.",
"dont": "Nie poddawaj się",
"lessons_completed": "Ukończone lekcje",
"no_courses_started": "Żadne kursy nie rozpoczęły się",
"revenue": "Przychód",
@@ -1600,8 +1598,6 @@
},
"new_course_modal": {
"live_class_label": "Zajęcia na żywo",
"live_class_subtitle": "Ten typ kursu jest idealny w przypadku obozów typu bootcampy, gdzie lekcje zależą od czasu i wymagana jest obecność i ocena",
"self_paced_label": "Samodzielne tempo",
"self_paced_subtitle": "Ten typ kursu jest idealny do kursów, w których uczniowie mogą brać udział w lekcjach we własnym tempie, bez nauczyciela"
"self_paced_label": "Samodzielne tempo"
}
}
}
@@ -916,7 +916,6 @@
"students": "estudantes",
"published": "Publicados",
"unpublished": "Não publicado",
"continue_course": "Continuar curso",
"list_view": {
"title": "Título",
"description": "Descrição",
@@ -976,7 +975,6 @@
"no_courses": "Nenhum curso em andamento ainda",
"start_course": "Depois de iniciar um curso, seu progresso será refletido aqui",
"lms_dashboard_hero": "Hoje é mais um dia para aproximar você de seus objetivos de aprendizagem. Não desista, quanto mais você aprende melhor você fica.",
"dont": "Não desista",
"lessons_completed": "Lições concluídas",
"no_courses_started": "Nenhum curso iniciado",
"revenue": "Receita",
@@ -1630,8 +1628,6 @@
},
"new_course_modal": {
"live_class_label": "Aula ao vivo",
"live_class_subtitle": "Este tipo de curso é ideal para bootcamps onde as aulas são baseadas no tempo e você precisa de frequência e notas",
"self_paced_label": "Individualizado",
"self_paced_subtitle": "Este tipo de curso é ideal para cursos onde os alunos podem ter aulas em seu próprio ritmo, sem professor."
"self_paced_label": "Individualizado"
}
}
}
@@ -916,7 +916,6 @@
"students": "студенты",
"published": "Опубликовано",
"unpublished": "Неопубликовано",
"continue_course": "Продолжить курс",
"list_view": {
"title": "Заголовок",
"description": "Описание",
@@ -976,7 +975,6 @@
"no_courses": "Курсов пока нет",
"start_course": "Здесь будет отражаться ваш прогресс после начала курса.",
"lms_dashboard_hero": "Сегодня еще один день, который приблизит вас к вашим целям обучения. Не сдавайтесь, чем больше вы узнаете, тем лучше у вас получится.",
"dont": "Не сдавайся",
"lessons_completed": "Уроки завершены",
"no_courses_started": "Курсы не начались",
"revenue": "Доход",
@@ -1630,8 +1628,6 @@
},
"new_course_modal": {
"live_class_label": "Живой урок",
"live_class_subtitle": "Этот тип курса идеально подходит для учебных курсов, где уроки основаны на времени и вам необходимы посещаемость и оценки.",
"self_paced_label": "Самостоятельный темп",
"self_paced_subtitle": "Этот тип курса идеально подходит для курсов, на которых учащиеся могут брать уроки в своем темпе без учителя."
"self_paced_label": "Самостоятельный темп"
}
}
}
@@ -916,7 +916,6 @@
"students": "sinh viên",
"published": "Được phát hành",
"unpublished": "Chưa xuất bản",
"continue_course": "Tiếp tục khóa học",
"list_view": {
"title": "Tiêu đề",
"description": "Sự miêu tả",
@@ -976,7 +975,6 @@
"no_courses": "Chưa có khóa học nào đang diễn ra",
"start_course": "Khi bạn bắt đầu một khóa học, sự tiến bộ của bạn sẽ phản ánh ở đây",
"lms_dashboard_hero": "Hôm nay là một ngày khác để đưa bạn đến gần hơn với mục tiêu học tập của mình. Đừng bỏ cuộc, bạn càng học nhiều thì bạn càng tiến bộ hơn.",
"dont": "Đừng bỏ cuộc",
"lessons_completed": "Bài học đã hoàn thành",
"no_courses_started": "Không có khóa học nào bắt đầu",
"revenue": "Doanh thu",
@@ -1630,8 +1628,6 @@
},
"new_course_modal": {
"live_class_label": "Lớp học trực tiếp",
"live_class_subtitle": "Loại khóa học này lý tưởng cho các chương trình đào tạo trong đó các bài học dựa trên thời gian và bạn cần tham dự và chấm điểm",
"self_paced_label": "tự nhịp độ",
"self_paced_subtitle": "Loại khóa học này lý tưởng cho các khóa học mà học sinh có thể học theo tốc độ của riêng mình mà không cần giáo viên"
"self_paced_label": "tự nhịp độ"
}
}
}