feat(localization): enhance course progress and review messaging across multiple languages
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:
Torsten Schulz (local)
2026-04-02 13:49:59 +02:00
parent edbf22ac5b
commit 3d2ccd620a
10 changed files with 311 additions and 54 deletions

View File

@@ -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;

View File

@@ -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;