feat(localization): enhance course progress and review messaging across multiple languages
All checks were successful
Deploy to production / deploy (push) Successful in 2m52s
All checks were successful
Deploy to production / deploy (push) Successful in 2m52s
- Added new confirmation titles and messages for resetting and marking lessons as complete in admin and user interfaces. - Expanded course flow and review scheduling messages to improve clarity and user guidance in Cebuano, German, Spanish, and English. - Introduced a new section in the VocabCourseView to display today's recommended steps for users, enhancing the learning experience. - Updated localization files to ensure consistent messaging and improved user engagement across all supported languages.
This commit is contained in:
@@ -113,6 +113,7 @@
|
||||
|
||||
<script>
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { confirmAction } from '@/utils/feedback.js';
|
||||
import AdminUserSearch from '@/components/admin/AdminUserSearch.vue';
|
||||
|
||||
export default {
|
||||
@@ -243,20 +244,23 @@ export default {
|
||||
this.loadingVocabCourseDetail = false;
|
||||
}
|
||||
},
|
||||
adminResetVocabLesson() {
|
||||
async adminResetVocabLesson() {
|
||||
if (!this.selected || !this.selectedVocabLessonId || this.vocabResetSubmitting) {
|
||||
return;
|
||||
}
|
||||
const lesson = this.vocabCourseLessons.find((l) => String(l.id) === String(this.selectedVocabLessonId));
|
||||
const lessonLabel = lesson ? `${lesson.lessonNumber}. ${lesson.title}` : this.selectedVocabLessonId;
|
||||
const msg = this.$t('admin.vocabLessonReset.confirm', {
|
||||
lesson: lessonLabel,
|
||||
username: this.selected.username
|
||||
const confirmed = await confirmAction(this, {
|
||||
title: this.$t('admin.vocabLessonReset.confirmTitle'),
|
||||
message: this.$t('admin.vocabLessonReset.confirm', {
|
||||
lesson: lessonLabel,
|
||||
username: this.selected.username
|
||||
})
|
||||
});
|
||||
if (!window.confirm(msg)) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
this.runAdminVocabReset();
|
||||
await this.runAdminVocabReset();
|
||||
},
|
||||
async runAdminVocabReset() {
|
||||
this.vocabResetSubmitting = true;
|
||||
@@ -272,19 +276,22 @@ export default {
|
||||
this.vocabResetSubmitting = false;
|
||||
}
|
||||
},
|
||||
adminMarkVocabLessonsThrough() {
|
||||
async adminMarkVocabLessonsThrough() {
|
||||
if (!this.selected || !this.selectedVocabCourseId || !this.canMarkVocabThrough || this.vocabMarkSubmitting) {
|
||||
return;
|
||||
}
|
||||
const n = Number(this.vocabMarkThroughNumber);
|
||||
const msg = this.$t('admin.vocabLessonMarkComplete.confirm', {
|
||||
n,
|
||||
username: this.selected.username
|
||||
const confirmed = await confirmAction(this, {
|
||||
title: this.$t('admin.vocabLessonMarkComplete.confirmTitle'),
|
||||
message: this.$t('admin.vocabLessonMarkComplete.confirm', {
|
||||
n,
|
||||
username: this.selected.username
|
||||
})
|
||||
});
|
||||
if (!window.confirm(msg)) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
this.runAdminVocabMarkThrough();
|
||||
await this.runAdminVocabMarkThrough();
|
||||
},
|
||||
async runAdminVocabMarkThrough() {
|
||||
this.vocabMarkSubmitting = true;
|
||||
|
||||
@@ -51,6 +51,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="todayRecommendedSteps.length > 0" class="course-today-plan">
|
||||
<h4 class="course-today-plan__title">{{ $t('socialnetwork.vocab.courses.courseTodayPlanTitle') }}</h4>
|
||||
<p class="course-today-plan__intro">{{ $t('socialnetwork.vocab.courses.courseTodayPlanIntro') }}</p>
|
||||
<ol class="course-today-plan__list">
|
||||
<li v-for="(step, idx) in todayRecommendedSteps" :key="`${step.type}-${step.lesson.id}-${idx}`" class="course-today-plan__item">
|
||||
<div class="course-today-plan__item-main">
|
||||
<span class="course-today-plan__step-label">{{ todayPlanStepLabel(step.type) }}</span>
|
||||
<span class="course-today-plan__lesson-title">{{ step.lesson.title }}</span>
|
||||
<span class="course-today-plan__lesson-meta">#{{ step.lesson.lessonNumber }}</span>
|
||||
</div>
|
||||
<button type="button" class="course-today-plan__action" @click="openTodayPlanStep(step)">
|
||||
{{ step.type === 'practice' ? $t('socialnetwork.vocab.courses.courseTodayPlanTrainer') : $t('socialnetwork.vocab.courses.courseTodayPlanOpen') }}
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div v-else class="course-today-plan course-today-plan--empty">
|
||||
<h4 class="course-today-plan__title">{{ $t('socialnetwork.vocab.courses.courseTodayPlanTitle') }}</h4>
|
||||
<p class="course-today-plan__intro">{{ $t('socialnetwork.vocab.courses.courseTodayPlanEmpty') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="course-flow__grid">
|
||||
<article class="course-flow-card">
|
||||
<div class="course-flow-card__top">
|
||||
@@ -177,6 +198,7 @@
|
||||
v-if="getReviewBadgeLabel(getLessonProgress(lesson.id, lesson))"
|
||||
class="review-badge"
|
||||
:class="getReviewBadgeClass(getLessonProgress(lesson.id, lesson))"
|
||||
:title="getReviewBadgeTooltip(getLessonProgress(lesson.id, lesson))"
|
||||
>
|
||||
{{ getReviewBadgeLabel(getLessonProgress(lesson.id, lesson)) }}
|
||||
</span>
|
||||
@@ -372,6 +394,32 @@ export default {
|
||||
})
|
||||
.slice(0, 4);
|
||||
},
|
||||
todayRecommendedSteps() {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const push = (type, lesson) => {
|
||||
if (!lesson?.id || seen.has(lesson.id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(lesson.id);
|
||||
out.push({ type, lesson });
|
||||
};
|
||||
this.dueReviewLessons.forEach((l) => push('review_due', l));
|
||||
this.currentBlockLessons.slice(0, 3).forEach((l) => push('block', l));
|
||||
if (this.nextIntensiveReviewLesson) {
|
||||
push('intensive', this.nextIntensiveReviewLesson);
|
||||
}
|
||||
if (out.length === 0 && this.currentLesson) {
|
||||
const p = this.getLessonProgress(this.currentLesson.id, this.currentLesson);
|
||||
if (!p?.completed) {
|
||||
push('continue', this.currentLesson);
|
||||
}
|
||||
}
|
||||
if (out.length === 0 && this.freePracticeLessons.length > 0) {
|
||||
push('practice', this.freePracticeLessons[0]);
|
||||
}
|
||||
return out.slice(0, 8);
|
||||
},
|
||||
isLessonNumberValid() {
|
||||
return Number(this.newLesson.lessonNumber) > 0;
|
||||
},
|
||||
@@ -502,7 +550,7 @@ export default {
|
||||
}
|
||||
return this.$t('socialnetwork.vocab.courses.reviewDueSinceDays', { count: diffDays });
|
||||
},
|
||||
formatReviewBadgeSchedule(reviewNextDueAt) {
|
||||
formatReviewWhenFriendly(reviewNextDueAt) {
|
||||
if (!reviewNextDueAt) {
|
||||
return '';
|
||||
}
|
||||
@@ -514,15 +562,15 @@ export default {
|
||||
if (diffMs > 0) {
|
||||
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
|
||||
if (untilDays <= 1) {
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeScheduleTomorrow');
|
||||
return this.$t('socialnetwork.vocab.courses.reviewWhenFriendlyTomorrow');
|
||||
}
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeScheduleInDays', { count: untilDays });
|
||||
return this.$t('socialnetwork.vocab.courses.reviewWhenFriendlyInDays', { count: untilDays });
|
||||
}
|
||||
const diffDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
|
||||
if (diffDays <= 0) {
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeScheduleToday');
|
||||
return this.$t('socialnetwork.vocab.courses.reviewWhenFriendlyToday');
|
||||
}
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeScheduleOverdue', { count: diffDays });
|
||||
return this.$t('socialnetwork.vocab.courses.reviewWhenFriendlyOverdue', { count: diffDays });
|
||||
},
|
||||
canShowLessonTrainer(lesson) {
|
||||
const p = this.getLessonProgress(lesson.id, lesson);
|
||||
@@ -537,31 +585,35 @@ export default {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
getReviewStageLabel(progress) {
|
||||
const stage = Number(progress?.reviewStage || 0);
|
||||
if (stage === 0) return this.$t('socialnetwork.vocab.courses.reviewStageDay1');
|
||||
if (stage === 1) return this.$t('socialnetwork.vocab.courses.reviewStageDay3');
|
||||
if (stage === 2) return this.$t('socialnetwork.vocab.courses.reviewStageDay7');
|
||||
if (stage >= 3) return this.$t('socialnetwork.vocab.courses.reviewStageCompleted');
|
||||
return '';
|
||||
},
|
||||
getReviewBadgeLabel(progress) {
|
||||
if (!progress?.completed) {
|
||||
return '';
|
||||
}
|
||||
const stageLabel = this.getReviewStageLabel(progress);
|
||||
if (!stageLabel) {
|
||||
if (progress.reviewCompleted) {
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeLineAllDone');
|
||||
}
|
||||
const stage = Number(progress.reviewStage ?? 0);
|
||||
const step = Math.min(3, stage + 1);
|
||||
if (progress.reviewDue) {
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeLineDue', { step });
|
||||
}
|
||||
const when = this.formatReviewWhenFriendly(progress.reviewNextDueAt);
|
||||
if (when) {
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeLineScheduled', { step, when });
|
||||
}
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeLineScheduled', {
|
||||
step,
|
||||
when: this.$t('socialnetwork.vocab.courses.reviewWhenFriendlySoon')
|
||||
});
|
||||
},
|
||||
getReviewBadgeTooltip(progress) {
|
||||
if (!progress?.completed) {
|
||||
return '';
|
||||
}
|
||||
if (progress.reviewCompleted) {
|
||||
return stageLabel;
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeTooltipDone');
|
||||
}
|
||||
if (progress.reviewDue) {
|
||||
const dueLabel = this.formatReviewDue(progress.reviewNextDueAt);
|
||||
return `${stageLabel} · ${dueLabel}`;
|
||||
}
|
||||
const scheduleLabel = this.formatReviewBadgeSchedule(progress.reviewNextDueAt);
|
||||
return scheduleLabel ? `${stageLabel} · ${scheduleLabel}` : stageLabel;
|
||||
return this.$t('socialnetwork.vocab.courses.reviewBadgeTooltipActive');
|
||||
},
|
||||
getReviewBadgeClass(progress) {
|
||||
if (!progress?.completed) {
|
||||
@@ -639,6 +691,26 @@ export default {
|
||||
openLesson(lessonId) {
|
||||
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
|
||||
},
|
||||
todayPlanStepLabel(type) {
|
||||
const key = {
|
||||
review_due: 'courseTodayPlanStepReviewDue',
|
||||
block: 'courseTodayPlanStepBlock',
|
||||
intensive: 'courseTodayPlanStepIntensive',
|
||||
continue: 'courseTodayPlanStepContinue',
|
||||
practice: 'courseTodayPlanStepPractice'
|
||||
}[type];
|
||||
return key ? this.$t(`socialnetwork.vocab.courses.${key}`) : '';
|
||||
},
|
||||
openTodayPlanStep(step) {
|
||||
if (!step?.lesson) {
|
||||
return;
|
||||
}
|
||||
if (step.type === 'practice') {
|
||||
this.openLessonPractice(step.lesson);
|
||||
return;
|
||||
}
|
||||
this.openLesson(step.lesson.id);
|
||||
},
|
||||
openLessonPractice(lesson) {
|
||||
this.$refs.practiceDialog?.open?.({
|
||||
courseId: this.courseId,
|
||||
@@ -1259,6 +1331,96 @@ export default {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.course-today-plan {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 248, 235, 0.75);
|
||||
border: 1px solid rgba(212, 184, 150, 0.45);
|
||||
}
|
||||
|
||||
.course-today-plan--empty {
|
||||
background: rgba(250, 250, 250, 0.85);
|
||||
border-color: var(--color-border, #e0dcd6);
|
||||
}
|
||||
|
||||
.course-today-plan__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-primary, #3a322c);
|
||||
}
|
||||
|
||||
.course-today-plan__intro {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary, #5c534c);
|
||||
}
|
||||
|
||||
.course-today-plan__list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.course-today-plan__item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.course-today-plan__item-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.course-today-plan__step-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #8a6d3b;
|
||||
}
|
||||
|
||||
.course-today-plan__lesson-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.course-today-plan__lesson-meta {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.course-today-plan__action {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border-strong, #c9c2b8);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.course-today-plan__action:hover {
|
||||
border-color: #b8a896;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.review-badge[title] {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.course-flow__grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user