feat(quiz): add login gate, one-attempt enforcement, and exit penalty
This commit is contained in:
parent
32ef89159a
commit
9637740ea9
|
|
@ -20,9 +20,10 @@
|
||||||
import { renderFlowchartEmbeds } from '$actions/renderFlowchartEmbeds';
|
import { renderFlowchartEmbeds } from '$actions/renderFlowchartEmbeds';
|
||||||
|
|
||||||
import { renderMath, autoRenderMath } from '$lib/actions/renderMath';
|
import { renderMath, autoRenderMath } from '$lib/actions/renderMath';
|
||||||
import { tick, mount, unmount } from 'svelte';
|
import { tick, mount, unmount, onMount } from 'svelte';
|
||||||
import { LessonManager } from './lesson.svelte';
|
import { LessonManager } from './lesson.svelte';
|
||||||
import { authLoggedIn } from '$stores/auth';
|
import { authLoggedIn, authToken } from '$stores/auth';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import SlideCarousel from '$components/SlideCarousel.svelte';
|
import SlideCarousel from '$components/SlideCarousel.svelte';
|
||||||
|
|
||||||
let { data: pageData } = $props();
|
let { data: pageData } = $props();
|
||||||
|
|
@ -104,6 +105,12 @@
|
||||||
|
|
||||||
beforeNavigate(() => {
|
beforeNavigate(() => {
|
||||||
lessonContext.set(null);
|
lessonContext.set(null);
|
||||||
|
|
||||||
|
// Quiz integrity: submit with penalty on SPA navigation
|
||||||
|
if (mgr.isQuizMode) {
|
||||||
|
mgr.submitQuiz(); // fire-and-forget, async
|
||||||
|
}
|
||||||
|
|
||||||
if (mgr.velxioCleanup) {
|
if (mgr.velxioCleanup) {
|
||||||
mgr.velxioCleanup();
|
mgr.velxioCleanup();
|
||||||
mgr.velxioCleanup = null;
|
mgr.velxioCleanup = null;
|
||||||
|
|
@ -114,6 +121,35 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (mgr.isQuizMode) {
|
||||||
|
// Calculate score with penalty
|
||||||
|
const mcqTotal = mgr.quizQuestionTypes.filter(t => t === 'mcq').length;
|
||||||
|
const correctTotal = mgr.quizIsCorrect.filter((v, i) => mgr.quizQuestionTypes[i] === 'mcq' && v).length;
|
||||||
|
const statusString = mcqTotal > 0 ? `${correctTotal}/${mcqTotal}` : 'completed';
|
||||||
|
const lessonName = mgr.slug.replace('.md', '');
|
||||||
|
|
||||||
|
// Send beacon to /track-progress (fire-and-forget on page unload)
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
token: get(authToken) || localStorage.getItem('student_token') || '',
|
||||||
|
lesson_name: lessonName,
|
||||||
|
status: statusString
|
||||||
|
});
|
||||||
|
navigator.sendBeacon(
|
||||||
|
'/api/track-progress',
|
||||||
|
new Blob([payload], { type: 'application/json' })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger browser confirm dialog
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
});
|
||||||
|
|
||||||
let contentEl = $state<HTMLElement | null>(null);
|
let contentEl = $state<HTMLElement | null>(null);
|
||||||
let tabsEl = $state<HTMLElement | null>(null);
|
let tabsEl = $state<HTMLElement | null>(null);
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -156,8 +192,8 @@
|
||||||
<div class="quiz-blur-content">
|
<div class="quiz-blur-content">
|
||||||
<div class="quiz-blur-icon">🫣</div>
|
<div class="quiz-blur-icon">🫣</div>
|
||||||
<h3>Materi Disembunyikan</h3>
|
<h3>Materi Disembunyikan</h3>
|
||||||
<p>Selesaikan atau batalkan kuis untuk melihat materi kembali.</p>
|
<p>Keluar dari kuis akan menyimpan skor akhir. Soal yang belum dijawab dianggap salah.</p>
|
||||||
<button class="btn btn-outline" onclick={() => mgr.isQuizMode = false}>Batal Kuis</button>
|
<button class="btn btn-outline" onclick={() => mgr.submitQuiz()}>Keluar Kuis</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -303,6 +339,7 @@
|
||||||
bind:isQuizMode={mgr.isQuizMode}
|
bind:isQuizMode={mgr.isQuizMode}
|
||||||
completedStatus={mgr.data.lesson_progress_status}
|
completedStatus={mgr.data.lesson_progress_status}
|
||||||
onComplete={(status) => mgr.completeLesson(status)}
|
onComplete={(status) => mgr.completeLesson(status)}
|
||||||
|
onAnswerChange={(status, isCorrect, total, types) => mgr.updateQuizSnapshot(status, isCorrect, total, types)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { authLoggedIn } from '$stores/auth';
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
text: string;
|
text: string;
|
||||||
is_correct: boolean;
|
is_correct: boolean;
|
||||||
|
|
@ -19,9 +21,10 @@
|
||||||
isQuizMode: boolean;
|
isQuizMode: boolean;
|
||||||
completedStatus?: string; // e.g. "5/6" or "completed"
|
completedStatus?: string; // e.g. "5/6" or "completed"
|
||||||
onComplete?: (status: string) => void;
|
onComplete?: (status: string) => void;
|
||||||
|
onAnswerChange?: (status: boolean[], isCorrect: boolean[], total: number, types: ('flashcard' | 'mcq')[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { quizData = [], isQuizMode = $bindable(), completedStatus = '', onComplete }: Props = $props();
|
let { quizData = [], isQuizMode = $bindable(), completedStatus = '', onComplete, onAnswerChange }: Props = $props();
|
||||||
|
|
||||||
let currentIndex = $state(0);
|
let currentIndex = $state(0);
|
||||||
let isFlipped = $state(false);
|
let isFlipped = $state(false);
|
||||||
|
|
@ -51,6 +54,17 @@
|
||||||
// Better way: Track correctness directly
|
// Better way: Track correctness directly
|
||||||
let isCorrectArray = $state<boolean[]>([]);
|
let isCorrectArray = $state<boolean[]>([]);
|
||||||
|
|
||||||
|
function notifyAnswerChange() {
|
||||||
|
if (onAnswerChange) {
|
||||||
|
onAnswerChange(
|
||||||
|
[...answersStatus],
|
||||||
|
[...isCorrectArray],
|
||||||
|
quizData.length,
|
||||||
|
quizData.map(c => c.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shuffleArray<T>(array: T[]): T[] {
|
function shuffleArray<T>(array: T[]): T[] {
|
||||||
const newArray = [...array];
|
const newArray = [...array];
|
||||||
for (let i = newArray.length - 1; i > 0; i--) {
|
for (let i = newArray.length - 1; i > 0; i--) {
|
||||||
|
|
@ -60,6 +74,37 @@
|
||||||
return newArray;
|
return newArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gate 1: Login required
|
||||||
|
const isLoggedIn = $derived($authLoggedIn);
|
||||||
|
// Gate 2: Quiz already completed (one-attempt enforcement)
|
||||||
|
const quizAlreadyCompleted = $derived(
|
||||||
|
!!completedStatus && completedStatus !== 'not_started' && completedStatus !== ''
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleExit() {
|
||||||
|
if (!isQuizMode) return;
|
||||||
|
|
||||||
|
const answeredCount = answersStatus.filter(s => s === true).length;
|
||||||
|
const unansweredCount = quizData.length - answeredCount;
|
||||||
|
|
||||||
|
let confirmMsg = 'Yakin keluar dari kuis?';
|
||||||
|
if (unansweredCount > 0) {
|
||||||
|
confirmMsg += ` ${unansweredCount} soal belum dijawab dan akan dianggap salah.`;
|
||||||
|
}
|
||||||
|
confirmMsg += ' Skor akan disimpan dan tidak bisa diulang.';
|
||||||
|
|
||||||
|
if (window.confirm(confirmMsg)) {
|
||||||
|
// Calculate score: only answered-correct MCQs count
|
||||||
|
const mcqTotal = quizData.filter(c => c.type === 'mcq').length;
|
||||||
|
const correctTotal = isCorrectArray.filter((v, i) => quizData[i].type === 'mcq' && v).length;
|
||||||
|
const statusString = mcqTotal > 0 ? `${correctTotal}/${mcqTotal}` : 'completed';
|
||||||
|
|
||||||
|
if (onComplete) onComplete(statusString);
|
||||||
|
showSummary = true;
|
||||||
|
isQuizMode = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleStart() {
|
function handleStart() {
|
||||||
isQuizMode = true;
|
isQuizMode = true;
|
||||||
currentIndex = 0;
|
currentIndex = 0;
|
||||||
|
|
@ -68,6 +113,7 @@
|
||||||
isCorrectArray = new Array(quizData.length).fill(false);
|
isCorrectArray = new Array(quizData.length).fill(false);
|
||||||
showSummary = false;
|
showSummary = false;
|
||||||
resetCardState();
|
resetCardState();
|
||||||
|
notifyAnswerChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCardState() {
|
function resetCardState() {
|
||||||
|
|
@ -106,6 +152,7 @@
|
||||||
if (!answersStatus[currentIndex]) {
|
if (!answersStatus[currentIndex]) {
|
||||||
answersStatus[currentIndex] = true;
|
answersStatus[currentIndex] = true;
|
||||||
isCorrectArray[currentIndex] = true; // Flashcards are always "correct" once seen
|
isCorrectArray[currentIndex] = true; // Flashcards are always "correct" once seen
|
||||||
|
notifyAnswerChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +163,7 @@
|
||||||
answersStatus[currentIndex] = true;
|
answersStatus[currentIndex] = true;
|
||||||
userSelections[currentIndex] = index;
|
userSelections[currentIndex] = index;
|
||||||
isCorrectArray[currentIndex] = randomizedOptions[index].is_correct;
|
isCorrectArray[currentIndex] = randomizedOptions[index].is_correct;
|
||||||
|
notifyAnswerChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextCard() {
|
function nextCard() {
|
||||||
|
|
@ -140,7 +188,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="quiz-container">
|
<div class="quiz-container">
|
||||||
{#if showSummary || (completedStatus && completedStatus !== 'not_started' && !isQuizMode)}
|
{#if showSummary || quizAlreadyCompleted}
|
||||||
<div class="summary-view">
|
<div class="summary-view">
|
||||||
<div class="summary-header">
|
<div class="summary-header">
|
||||||
<div class="summary-icon">🏁</div>
|
<div class="summary-icon">🏁</div>
|
||||||
|
|
@ -183,12 +231,22 @@
|
||||||
<div class="quiz-icon">📝</div>
|
<div class="quiz-icon">📝</div>
|
||||||
<h2>Kuis Interaktif</h2>
|
<h2>Kuis Interaktif</h2>
|
||||||
<p>Uji pemahamanmu dengan kuis ini.</p>
|
<p>Uji pemahamanmu dengan kuis ini.</p>
|
||||||
<div class="alert alert-warning">
|
|
||||||
⚠️ <strong>Penting:</strong> Materi pelajaran akan disembunyikan. Kamu harus menjawab semua soal untuk dapat menyelesaikannya.
|
{#if !isLoggedIn}
|
||||||
</div>
|
<div class="alert alert-warning">
|
||||||
<button class="btn btn-primary btn-lg" onclick={handleStart}>
|
⚠️ <strong>Login diperlukan.</strong> Klik tombol login di navbar atas untuk memulai kuis.
|
||||||
Mulai Kuis Sekarang
|
</div>
|
||||||
</button>
|
<button class="btn btn-primary btn-lg" disabled>
|
||||||
|
Mulai Kuis Sekarang
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
⚠️ <strong>Penting:</strong> Materi pelajaran akan disembunyikan. Kamu harus menjawab semua soal untuk dapat menyelesaikannya. Jika keluar di tengah kuis, soal yang belum dijawab akan dianggap <strong>salah</strong>.
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-lg" onclick={handleStart}>
|
||||||
|
Mulai Kuis Sekarang
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if quizData.length > 0}
|
{:else if quizData.length > 0}
|
||||||
<div class="quiz-active-view">
|
<div class="quiz-active-view">
|
||||||
|
|
@ -279,8 +337,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cancel-container">
|
<div class="cancel-container">
|
||||||
<button class="btn-exit-quiz" disabled>Batal Kuis</button>
|
<button class="btn-exit-quiz" onclick={handleExit}>Keluar Kuis</button>
|
||||||
<span class="cancel-lock-hint">🔒 Selesaikan kuis untuk melihat materi kembali</span>
|
<span class="cancel-lock-hint">⚠️ Keluar = selesai. Soal belum dijawab dianggap salah.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -390,9 +448,10 @@
|
||||||
.btn-outline { background: white; border: 1px solid var(--color-border); color: var(--color-text); }
|
.btn-outline { background: white; border: 1px solid var(--color-border); color: var(--color-text); }
|
||||||
.btn-lg { padding: 1rem 2rem; font-size: 1.1rem; }
|
.btn-lg { padding: 1rem 2rem; font-size: 1.1rem; }
|
||||||
|
|
||||||
.btn-exit-quiz { background: none; border: none; color: var(--color-text-muted); text-decoration: underline; font-size: 0.85rem; cursor: pointer; }
|
.btn-exit-quiz { background: none; border: none; color: var(--color-danger, #dc3545); text-decoration: underline; font-size: 0.85rem; cursor: pointer; }
|
||||||
|
.btn-exit-quiz:hover { color: #c82333; }
|
||||||
.cancel-container { display: flex; flex-direction: column; align-items: center; margin-top: 1rem; gap: 0.5rem; }
|
.cancel-container { display: flex; flex-direction: column; align-items: center; margin-top: 1rem; gap: 0.5rem; }
|
||||||
.cancel-lock-hint { font-size: 0.75rem; color: #fa5252; font-weight: 500; }
|
.cancel-lock-hint { font-size: 0.75rem; color: var(--color-danger, #dc3545); font-weight: 500; }
|
||||||
.quiz-empty { text-align: center; margin: auto; color: var(--color-text-muted); }
|
.quiz-empty { text-align: center; margin: auto; color: var(--color-text-muted); }
|
||||||
@media (max-width: 600px) { .card-content { font-size: 1.1rem; } .option-btn { padding: 0.75rem; font-size: 0.95rem; } }
|
@media (max-width: 600px) { .card-content { font-size: 1.1rem; } .option-btn { padding: 0.75rem; font-size: 0.95rem; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ export class LessonManager {
|
||||||
data = $state<LessonContent | null>(null);
|
data = $state<LessonContent | null>(null);
|
||||||
lessonCompleted = $state(false);
|
lessonCompleted = $state(false);
|
||||||
isQuizMode = $state(false);
|
isQuizMode = $state(false);
|
||||||
|
|
||||||
|
// Quiz answer snapshot (updated by QuizTab via onAnswerChange callback)
|
||||||
|
quizAnswersStatus = $state<boolean[]>([]);
|
||||||
|
quizIsCorrect = $state<boolean[]>([]);
|
||||||
|
quizQuestionTypes = $state<('flashcard' | 'mcq')[]>([]);
|
||||||
|
quizTotalQuestions = $state(0);
|
||||||
currentCode = $state('');
|
currentCode = $state('');
|
||||||
currentLanguage = $state<string>('c');
|
currentLanguage = $state<string>('c');
|
||||||
|
|
||||||
|
|
@ -162,9 +168,13 @@ export class LessonManager {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
this.data = lesson;
|
this.data = lesson;
|
||||||
this.lessonCompleted = lesson.lesson_completed;
|
this.lessonCompleted = lesson.lesson_completed;
|
||||||
this.isQuizMode = false;
|
this.isQuizMode = false;
|
||||||
|
this.quizAnswersStatus = [];
|
||||||
|
this.quizIsCorrect = [];
|
||||||
|
this.quizQuestionTypes = [];
|
||||||
|
this.quizTotalQuestions = 0;
|
||||||
|
|
||||||
this.cCode = lesson.initial_code_c || '';
|
this.cCode = lesson.initial_code_c || '';
|
||||||
this.pythonCode = lesson.initial_python || '';
|
this.pythonCode = lesson.initial_python || '';
|
||||||
|
|
||||||
const hasC = lesson.active_tabs?.includes('c');
|
const hasC = lesson.active_tabs?.includes('c');
|
||||||
|
|
@ -219,6 +229,25 @@ export class LessonManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateQuizSnapshot(status: boolean[], isCorrect: boolean[], total: number, types: ('flashcard' | 'mcq')[]) {
|
||||||
|
this.quizAnswersStatus = [...status];
|
||||||
|
this.quizIsCorrect = [...isCorrect];
|
||||||
|
this.quizQuestionTypes = [...types];
|
||||||
|
this.quizTotalQuestions = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitQuiz() {
|
||||||
|
if (!this.isQuizMode) return;
|
||||||
|
|
||||||
|
// Calculate score: unanswered MCQ = wrong (not in correct count)
|
||||||
|
const mcqTotal = this.quizQuestionTypes.filter(t => t === 'mcq').length;
|
||||||
|
const correctTotal = this.quizIsCorrect.filter((v, i) => this.quizQuestionTypes[i] === 'mcq' && v).length;
|
||||||
|
const statusString = mcqTotal > 0 ? `${correctTotal}/${mcqTotal}` : 'completed';
|
||||||
|
|
||||||
|
this.isQuizMode = false;
|
||||||
|
await this.completeLesson(statusString);
|
||||||
|
}
|
||||||
|
|
||||||
checkAllPassed(): boolean {
|
checkAllPassed(): boolean {
|
||||||
const needsC = this.data?.active_tabs?.includes('c');
|
const needsC = this.data?.active_tabs?.includes('c');
|
||||||
const needsPython = this.data?.active_tabs?.includes('python');
|
const needsPython = this.data?.active_tabs?.includes('python');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue