improvement Quiz Interactive

This commit is contained in:
unknown 2025-11-04 11:05:39 +07:00
parent d0e5332563
commit bcb9aabc35
25 changed files with 3117 additions and 428 deletions

View File

@ -0,0 +1,355 @@
# 🧪 Design Review: UAT Testing Strategy untuk LMS
**Epic**: LMS System
**Story**: UAT Design Testing
**Topic**: User Acceptance Testing
**Date**: 2025-01-27
**Test Architect**: Quinn - BMad Method
---
## 📋 **EXECUTIVE SUMMARY**
Dokumen ini menyajikan design review khusus untuk User Acceptance Testing (UAT) pada sistem Learning Management System (LMS). Fokus utama adalah memastikan sistem siap untuk pengujian oleh end-user dengan skenario yang komprehensif dan realistis.
---
## 🎯 **UAT SCOPE & OBJECTIVES**
### **Primary Objectives:**
- ✅ Validasi user experience untuk pengguna Indonesia
- ✅ Verifikasi business requirements telah terpenuhi
- ✅ Konfirmasi sistem dapat digunakan dalam kondisi real-world
- ✅ Identifikasi gap antara ekspektasi user dan implementasi
### **UAT Coverage Areas:**
1. **Authentication & Authorization** (Login/Register/Role Management)
2. **Course Management** (Browse, Enroll, Progress Tracking)
3. **Assessment System** (Quiz, Exam, Interactive Elements)
4. **Certificate Management** (Generation, Verification, Download)
5. **Dashboard & Analytics** (Student/Admin/Instructor Views)
6. **Payroll Reward System** (Learning Hours, Performance Bonus)
7. **AI Assistant** (Learning Support, Recommendations)
8. **Mobile Responsiveness** (Cross-device compatibility)
---
## 👥 **USER PERSONAS FOR UAT**
### **1. Peserta/Student (Primary User)**
- **Profile**: Karyawan perusahaan, usia 25-45 tahun
- **Tech Literacy**: Menengah (familiar dengan smartphone, basic computer)
- **Goals**: Menyelesaikan pelatihan, mendapat sertifikat, earning rewards
- **Pain Points**: Waktu terbatas, perlu interface yang intuitif
### **2. Instruktur/Trainer**
- **Profile**: Professional trainer, usia 30-50 tahun
- **Tech Literacy**: Menengah-Tinggi
- **Goals**: Mengelola kursus, monitor progress siswa, evaluasi hasil
- **Pain Points**: Butuh tools yang efisien untuk manajemen konten
### **3. Admin/HR**
- **Profile**: HR Manager atau Training Coordinator
- **Tech Literacy**: Menengah-Tinggi
- **Goals**: Oversight training program, generate reports, manage users
- **Pain Points**: Butuh dashboard yang comprehensive dan mudah dipahami
### **4. Super Admin/System Administrator**
- **Profile**: IT Professional
- **Tech Literacy**: Tinggi
- **Goals**: System maintenance, user management, security oversight
- **Pain Points**: Butuh control panel yang powerful namun user-friendly
---
## 🧪 **UAT TEST SCENARIOS**
### **Scenario 1: Onboarding Journey (Critical Path)**
```
User Story: "Sebagai karyawan baru, saya ingin dapat mendaftar dan mulai belajar dengan mudah"
Test Steps:
1. Akses halaman registrasi
2. Isi form registrasi dengan data valid
3. Verifikasi email (jika ada)
4. Login pertama kali
5. Complete profile setup
6. Browse available courses
7. Enroll ke course pertama
8. Start learning module pertama
Expected Results:
- Proses registrasi < 3 menit
- Interface dalam Bahasa Indonesia yang jelas
- Guidance yang membantu untuk first-time user
- Smooth transition antar step
```
### **Scenario 2: Learning Experience (Core Functionality)**
```
User Story: "Sebagai peserta, saya ingin pengalaman belajar yang engaging dan mudah diikuti"
Test Steps:
1. Login ke dashboard
2. Continue course yang sedang berjalan
3. Watch video pembelajaran
4. Complete interactive quiz
5. Submit assignment (jika ada)
6. Check progress tracking
7. Earn learning hours untuk payroll system
8. Receive notifications/feedback
Expected Results:
- Video player berfungsi smooth di berbagai device
- Quiz interface intuitif dan responsive
- Progress tracking akurat dan real-time
- Payroll hours tercatat dengan benar
```
### **Scenario 3: Assessment & Certification (High Stakes)**
```
User Story: "Sebagai peserta, saya ingin dapat mengikuti ujian dan mendapat sertifikat dengan confidence"
Test Steps:
1. Access exam session
2. Review exam instructions
3. Complete exam dengan timer
4. Submit exam answers
5. Receive immediate feedback (jika applicable)
6. Check exam results
7. Download digital certificate
8. Verify certificate authenticity
Expected Results:
- Exam interface stable dan tidak crash
- Timer berfungsi akurat
- Auto-save answers berfungsi
- Certificate generation berhasil
- QR code verification works
```
### **Scenario 4: Admin Management (Power User)**
```
User Story: "Sebagai admin, saya ingin dapat mengelola sistem dengan efisien"
Test Steps:
1. Login ke admin panel
2. Create new course
3. Upload learning materials
4. Set up quiz/exam
5. Manage user enrollments
6. Generate analytics reports
7. Configure payroll reward settings
8. Monitor system performance
Expected Results:
- Admin interface responsive dan intuitive
- Bulk operations berfungsi dengan baik
- Reports generated accurately
- System performance metrics visible
```
### **Scenario 5: Mobile Experience (Cross-Platform)**
```
User Story: "Sebagai mobile user, saya ingin dapat belajar dengan nyaman di smartphone"
Test Steps:
1. Access LMS via mobile browser
2. Login dan navigate dashboard
3. Watch video di mobile
4. Complete quiz di mobile
5. Check progress dan notifications
6. Download certificate di mobile
7. Test offline capabilities (jika ada)
Expected Results:
- Responsive design works seamlessly
- Touch interactions smooth
- Video playback optimized for mobile
- Text readable tanpa zoom
- Fast loading times
```
---
## 🔍 **UAT TEST DESIGN FRAMEWORK**
### **Testing Approach:**
- **Exploratory Testing**: User bebas explore sistem secara natural
- **Scenario-Based Testing**: Guided scenarios berdasarkan real use cases
- **Usability Testing**: Focus pada ease of use dan user satisfaction
- **Acceptance Criteria Validation**: Verify business requirements terpenuhi
### **Test Environment:**
- **Staging Environment**: Mirror production dengan test data
- **Multiple Devices**: Desktop, tablet, smartphone (Android/iOS)
- **Multiple Browsers**: Chrome, Firefox, Safari, Edge
- **Network Conditions**: Fast WiFi, slow 3G, intermittent connection
### **Success Metrics:**
- **Task Completion Rate**: > 90% untuk critical paths
- **Time to Complete**: Sesuai dengan target yang ditetapkan
- **Error Rate**: < 5% untuk user-induced errors
- **User Satisfaction**: Rating > 4/5 pada post-test survey
- **Accessibility**: WCAG 2.1 AA compliance
---
## 📊 **UAT EXECUTION PLAN**
### **Phase 1: Internal UAT (Week 1)**
- **Participants**: Internal team members (5-8 orang)
- **Focus**: Basic functionality dan critical bugs
- **Duration**: 3 hari
- **Deliverable**: Bug report dan initial feedback
### **Phase 2: Stakeholder UAT (Week 2)**
- **Participants**: Key stakeholders dan power users (8-12 orang)
- **Focus**: Business requirements validation
- **Duration**: 5 hari
- **Deliverable**: Acceptance criteria validation report
### **Phase 3: End-User UAT (Week 3)**
- **Participants**: Representative end users (15-20 orang)
- **Focus**: Real-world usage scenarios
- **Duration**: 1 minggu
- **Deliverable**: User experience report dan recommendations
### **Phase 4: Performance UAT (Week 4)**
- **Participants**: Mixed user groups dengan concurrent access
- **Focus**: System performance under load
- **Duration**: 2 hari
- **Deliverable**: Performance validation report
---
## 🎯 **UAT SUCCESS CRITERIA**
### **Functional Criteria:**
- ✅ Semua critical user journeys dapat diselesaikan tanpa blocker
- ✅ Authentication dan authorization berfungsi sesuai role
- ✅ Course management dan learning experience smooth
- ✅ Assessment system reliable dan secure
- ✅ Certificate generation dan verification works
- ✅ Payroll reward system calculate accurately
- ✅ Admin functions accessible dan efficient
### **Non-Functional Criteria:**
- ✅ Page load time < 3 detik untuk 95% requests
- ✅ System available 99.5% selama UAT period
- ✅ Mobile responsiveness works pada semua target devices
- ✅ Accessibility standards met (WCAG 2.1 AA)
- ✅ Security vulnerabilities addressed
- ✅ Data integrity maintained throughout testing
### **User Experience Criteria:**
- ✅ Interface dalam Bahasa Indonesia yang natural
- ✅ Navigation intuitive untuk Indonesian users
- ✅ Error messages helpful dan actionable
- ✅ Feedback mechanisms responsive
- ✅ Help documentation accessible dan comprehensive
---
## 🚨 **RISK ASSESSMENT**
### **High Risk Areas:**
1. **Exam System Stability**: Critical untuk certification process
2. **Mobile Performance**: Majority users akan akses via mobile
3. **Payroll Calculation**: Financial implications jika salah
4. **Certificate Verification**: Legal compliance requirements
5. **User Data Security**: Privacy dan GDPR compliance
### **Mitigation Strategies:**
- **Comprehensive Test Data**: Cover edge cases dan boundary conditions
- **Rollback Plan**: Ready jika critical issues ditemukan
- **Performance Monitoring**: Real-time monitoring selama UAT
- **Security Review**: Penetration testing sebelum UAT
- **User Training**: Provide clear documentation dan support
---
## 📋 **UAT DELIVERABLES**
### **Test Documentation:**
- [ ] UAT Test Plan (This document)
- [ ] Test Scenarios dan Test Cases
- [ ] User Personas dan Journey Maps
- [ ] Test Data Requirements
- [ ] Environment Setup Guide
### **Execution Artifacts:**
- [ ] Daily Test Execution Reports
- [ ] Bug Reports dengan severity classification
- [ ] User Feedback Compilation
- [ ] Performance Test Results
- [ ] Accessibility Audit Report
### **Final Reports:**
- [ ] UAT Summary Report
- [ ] Business Requirements Traceability Matrix
- [ ] User Acceptance Sign-off Document
- [ ] Go-Live Readiness Assessment
- [ ] Post-UAT Recommendations
---
## 🎯 **RECOMMENDATIONS**
### **Pre-UAT Preparation:**
1. **Test Data Setup**: Prepare realistic test data yang represent production scenarios
2. **User Training**: Brief UAT participants tentang objectives dan expectations
3. **Environment Validation**: Ensure staging environment stable dan representative
4. **Communication Plan**: Clear channels untuk reporting issues dan feedback
### **During UAT Execution:**
1. **Daily Standups**: Track progress dan address blockers immediately
2. **Real-time Monitoring**: Monitor system performance dan user behavior
3. **Feedback Collection**: Multiple channels untuk user input (forms, interviews, observations)
4. **Issue Triage**: Rapid classification dan resolution untuk critical issues
### **Post-UAT Actions:**
1. **Lessons Learned**: Document insights untuk future UAT cycles
2. **User Training Materials**: Update based pada UAT feedback
3. **Performance Optimization**: Address performance issues identified
4. **Go-Live Planning**: Finalize deployment strategy based pada UAT results
---
## 🏁 **GATE ASSESSMENT**
### **Current Status**: READY FOR UAT EXECUTION
### **Gate Decision**: **PASS**
**Justification:**
- ✅ Comprehensive UAT strategy telah didefinisikan
- ✅ Test scenarios cover semua critical user journeys
- ✅ Success criteria jelas dan measurable
- ✅ Risk mitigation strategies in place
- ✅ Execution plan realistic dan achievable
- ✅ Deliverables clearly defined
### **Conditions for Success:**
1. Staging environment harus stable sebelum UAT dimulai
2. Test data harus representative dan comprehensive
3. UAT participants harus properly briefed
4. Issue resolution process harus efficient
5. Performance monitoring harus real-time
### **Next Steps:**
1. Finalize UAT participant selection
2. Setup staging environment dengan production-like data
3. Conduct UAT kickoff meeting
4. Begin Phase 1: Internal UAT execution
5. Monitor progress dan adjust plan as needed
---
**Document Prepared By**: Quinn - Test Architect, BMad Method
**Review Date**: 2025-01-27
**Next Review**: Post-UAT Completion
**Approval Status**: Ready for Stakeholder Review
---
*"Quality is not an act, it is a habit. UAT adalah kesempatan terakhir untuk memastikan sistem benar-benar siap melayani user dengan excellence."* - Quinn, BMad Method QA Team

View File

@ -6,7 +6,7 @@ import { cn } from '@/utils/cn';
import Link from 'next/link'; import Link from 'next/link';
import { ExamProvider, useExam } from '@/contexts/ExamContext'; import { ExamProvider, useExam } from '@/contexts/ExamContext';
import { useExamPersistence } from '@/hooks/useExamPersistence'; import { useExamPersistence } from '@/hooks/useExamPersistence';
import { useSearchParams } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { InteractiveQuestion, EnhancedExamData, Question, PuzzleData } from '@/types'; import { InteractiveQuestion, EnhancedExamData, Question, PuzzleData } from '@/types';
// Feature Flags // Feature Flags
@ -23,6 +23,9 @@ import AnswerFeedback from '@/components/quiz/AnswerFeedback';
// Interactive Quiz Components // Interactive Quiz Components
import InteractiveQuizRenderer from '@/components/quiz/InteractiveQuizRenderer'; import InteractiveQuizRenderer from '@/components/quiz/InteractiveQuizRenderer';
import VideoScenario from '@/components/quiz/VideoScenario';
import InteractiveImageHotspot from '@/components/quiz/InteractiveImageHotspot';
import MediaGallery from '@/components/quiz/MediaGallery';
// Legacy Components (fallback) // Legacy Components (fallback)
import QuestionNavigator from '@/components/exam/QuestionNavigator'; import QuestionNavigator from '@/components/exam/QuestionNavigator';
@ -141,6 +144,235 @@ const sampleInteractiveExamData: EnhancedExamData = {
} }
] ]
}, },
// Video Scenario Question - NEW REALISTIC SCENARIO
{
id: 'video-scenario-1',
question: 'Analisis Situasi Dapur: Identifikasi Masalah Keamanan Pangan',
type: 'video_scenario' as const,
options: [],
correctAnswer: 0,
content: {
question: 'Tonton video situasi dapur berikut dan identifikasi masalah keamanan pangan yang terjadi',
instructions: 'Video akan berhenti pada titik-titik kritis. Jawab pertanyaan yang muncul untuk melanjutkan.',
media: {
type: 'video' as const,
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
alt: 'Simulasi Dapur Komersial - Analisis Keamanan Pangan',
caption: 'Amati aktivitas dapur selama shift pagi dan identifikasi potensi masalah keamanan pangan'
}
},
difficulty: 'medium' as const,
timeLimit: 600,
scenarios: [
{
id: 'step1',
timestamp: 15,
title: 'Persiapan Bahan Makanan',
question: 'Berdasarkan simulasi video, apa masalah utama yang dapat terjadi dalam persiapan bahan makanan di dapur komersial?',
options: [
{ id: 'opt1', text: 'Daging mentah dan sayuran disiapkan di area yang sama tanpa pemisahan', consequence: 'Risiko kontaminasi silang sangat tinggi - dapat menyebabkan foodborne illness' },
{ id: 'opt2', text: 'Suhu ruangan tidak terkontrol dengan baik', consequence: 'Dapat mempercepat pertumbuhan bakteri patogen pada makanan' },
{ id: 'opt3', text: 'Peralatan masak tidak dibersihkan secara proper antara penggunaan', consequence: 'Menjadi sumber kontaminasi utama dan breeding ground bakteri' },
{ id: 'opt4', text: 'Semua masalah di atas dapat terjadi bersamaan', consequence: 'Analisis komprehensif - multiple hazards memerlukan sistem HACCP yang ketat' }
],
correctAnswer: 3,
explanation: 'Dalam operasional dapur komersial, multiple food safety hazards sering terjadi bersamaan: kontaminasi silang, temperature abuse, dan poor sanitation. Sistem HACCP (Hazard Analysis Critical Control Points) diperlukan untuk mengidentifikasi dan mengontrol semua critical control points.',
pauseVideo: true
},
{
id: 'step2',
timestamp: 45,
title: 'Kontrol Suhu dan Penyimpanan',
question: 'Dalam konteks keamanan pangan, apa yang harus diperhatikan dalam kontrol suhu?',
options: [
{ id: 'opt1', text: 'Danger Zone (4°C - 60°C) harus dihindari', consequence: 'Benar - bakteri berkembang pesat di suhu ini' },
{ id: 'opt2', text: 'Cold chain harus dijaga dari supplier hingga serving', consequence: 'Kritis untuk mencegah temperature abuse' },
{ id: 'opt3', text: 'Monitoring suhu harus dilakukan secara berkala', consequence: 'Dokumentasi suhu adalah requirement legal' },
{ id: 'opt4', text: 'Semua aspek kontrol suhu di atas penting', consequence: 'Temperature control adalah fundamental dalam food safety' }
],
correctAnswer: 3,
explanation: 'Temperature control adalah salah satu pilar utama food safety. Danger zone, cold chain management, dan monitoring berkelanjutan harus diimplementasikan secara sistematis.',
pauseVideo: true
}
],
interactiveElements: [
{
type: 'hint',
content: 'Fokus pada prinsip-prinsip HACCP: identifikasi hazards, critical control points, monitoring procedures, dan corrective actions'
},
{
type: 'learning_objective',
content: 'Setelah menyelesaikan scenario ini, Anda akan mampu mengidentifikasi critical control points dalam operasional dapur dan menerapkan prinsip food safety management'
}
]
},
// Image Hotspot Question - NEW REALISTIC SCENARIO
{
id: 'hotspot-scenario-1',
question: 'Identifikasi Area Bermasalah dalam Tata Letak Dapur',
type: 'image_hotspot' as const,
options: [],
correctAnswer: 0,
content: {
question: 'Klik pada area-area dalam gambar dapur yang menunjukkan pelanggaran keamanan pangan',
instructions: 'Temukan dan klik pada 5 area bermasalah dalam tata letak dapur. Setiap area yang benar akan memberikan poin.',
media: {
type: 'image' as const,
src: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&h=600',
alt: 'Tata letak dapur dengan berbagai area bermasalah',
caption: 'Analisis Tata Letak Dapur MBG - Identifikasi masalah keamanan pangan'
}
},
difficulty: 'hard' as const,
timeLimit: 300,
hotspots: [
{
id: 'hotspot1',
x: 15, y: 25, width: 12, height: 15,
type: 'incorrect',
title: 'Area Penyimpanan Daging',
description: 'Daging mentah disimpan di rak atas',
feedback: 'SALAH! Daging mentah harus disimpan di rak paling bawah untuk mencegah tetesan ke makanan lain.',
points: 10,
isRequired: true
},
{
id: 'hotspot2',
x: 45, y: 35, width: 15, height: 12,
type: 'incorrect',
title: 'Area Pencucian Sayuran',
description: 'Sayuran dicuci bersamaan dengan peralatan kotor',
feedback: 'SALAH! Sayuran harus dicuci di area terpisah dari peralatan kotor untuk mencegah kontaminasi silang.',
points: 10,
isRequired: true
},
{
id: 'hotspot3',
x: 70, y: 20, width: 18, height: 10,
type: 'incorrect',
title: 'Area Penyimpanan Suhu Ruang',
description: 'Makanan mudah rusak disimpan pada suhu ruang',
feedback: 'SALAH! Makanan mudah rusak harus disimpan dalam refrigerator pada suhu 4°C atau lebih rendah.',
points: 10,
isRequired: true
},
{
id: 'hotspot4',
x: 25, y: 60, width: 20, height: 8,
type: 'incorrect',
title: 'Area Persiapan Makanan',
description: 'Talenan untuk daging dan sayuran tidak dipisah',
feedback: 'SALAH! Harus menggunakan talenan terpisah untuk daging mentah dan sayuran untuk mencegah kontaminasi silang.',
points: 10,
isRequired: true
},
{
id: 'hotspot5',
x: 60, y: 70, width: 15, height: 12,
type: 'incorrect',
title: 'Area Pembuangan Sampah',
description: 'Tempat sampah terbuka dekat area persiapan makanan',
feedback: 'SALAH! Tempat sampah harus tertutup rapat dan dijauhkan dari area persiapan makanan.',
points: 10,
isRequired: true
},
{
id: 'hotspot6',
x: 80, y: 45, width: 12, height: 18,
type: 'correct',
title: 'Area Hand Washing Station',
description: 'Stasiun cuci tangan yang tepat',
feedback: 'BENAR! Stasiun cuci tangan sudah ditempatkan dengan benar dan mudah diakses oleh staff.',
points: 15,
isRequired: false
}
],
interactiveElements: [
{
type: 'hint',
content: 'Fokus pada prinsip pemisahan: daging mentah, area pencucian, kontrol suhu, dan kebersihan. Cari area yang melanggar prinsip HACCP.'
}
]
},
// Media Gallery Question - NEW REALISTIC SCENARIO
{
id: 'media-gallery-1',
question: 'Pelajari Prosedur HACCP Melalui Media Pembelajaran',
type: 'media_gallery' as const,
options: [],
correctAnswer: 0,
content: {
question: 'Tinjau semua media pembelajaran tentang implementasi HACCP di Dapur MBG',
instructions: 'Pelajari setiap media untuk memahami prosedur HACCP secara komprehensif.',
description: 'Koleksi media pembelajaran untuk implementasi sistem HACCP'
},
difficulty: 'medium' as const,
timeLimit: 480,
mediaItems: [
{
id: 'video1',
type: 'video',
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
title: 'Pengenalan Sistem HACCP',
description: 'Video pengenalan konsep dasar HACCP dan implementasinya di industri makanan - 7 prinsip fundamental untuk keamanan pangan',
thumbnail: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=300&fit=crop',
duration: 180
},
{
id: 'image1',
type: 'image',
src: 'https://images.unsplash.com/photo-1577308856961-8e4e0d5b63b8?w=800&h=600&fit=crop',
title: 'Diagram Alur HACCP',
description: 'Flowchart lengkap implementasi 7 prinsip HACCP: Analisis Bahaya, Identifikasi CCP, Penetapan Batas Kritis, Monitoring, Tindakan Koreksi, Verifikasi, dan Dokumentasi'
},
{
id: 'video2',
type: 'video',
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
title: 'Critical Control Points (CCP)',
description: 'Panduan identifikasi dan monitoring Critical Control Points dalam operasional dapur komersial',
thumbnail: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=300&fit=crop',
duration: 240
},
{
id: 'image2',
type: 'image',
src: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=800&h=600&fit=crop',
title: 'Temperature Control Chart',
description: 'Grafik kontrol suhu untuk berbagai jenis makanan - Danger Zone, Safe Storage, dan Cooking Temperatures'
},
{
id: 'document1',
type: 'document',
src: 'https://www.fda.gov/media/99016/download',
title: 'HACCP Implementation Guide',
description: 'Panduan resmi implementasi HACCP dari FDA - dokumen referensi lengkap untuk industri makanan',
fileType: 'PDF',
fileSize: '2.5 MB'
},
{
id: 'image3',
type: 'image',
src: 'https://images.unsplash.com/photo-1583394838336-acd977736f90?w=800&h=600&fit=crop',
title: 'Food Safety Monitoring Log',
description: 'Contoh form monitoring harian untuk dokumentasi HACCP - temperature logs, cleaning schedules, dan corrective actions'
}
],
interactiveElements: [
{
type: 'hint',
content: 'Pelajari setiap media secara berurutan: Video → Diagram → Dokumentasi. Fokus pada 7 prinsip HACCP dan implementasi praktisnya'
},
{
type: 'learning_objective',
content: 'Setelah mempelajari semua media, Anda akan memahami: (1) 7 Prinsip HACCP, (2) Identifikasi CCP, (3) Sistem Monitoring, (4) Dokumentasi yang diperlukan'
},
{
type: 'assessment_criteria',
content: 'Pemahaman dinilai berdasarkan: kemampuan mengidentifikasi hazards, menetapkan CCP, merancang monitoring system, dan membuat dokumentasi HACCP'
}
]
},
{ {
id: '12', id: '12',
question: 'Identifikasi Suara Peralatan Dapur yang Bermasalah', question: 'Identifikasi Suara Peralatan Dapur yang Bermasalah',
@ -278,7 +510,8 @@ const sampleInteractiveExamData: EnhancedExamData = {
correctAnswer: 0, correctAnswer: 0,
content: { content: {
question: 'Seret setiap istilah untuk mencocokkan dengan definisi yang benar', question: 'Seret setiap istilah untuk mencocokkan dengan definisi yang benar',
instructions: 'Seret setiap istilah untuk mencocokkan dengan definisi yang benar.' instructions: 'Seret setiap istilah untuk mencocokkan dengan definisi yang benar.',
puzzleType: 'matching' as const
}, },
difficulty: 'medium' as const, difficulty: 'medium' as const,
puzzleData: { puzzleData: {
@ -288,27 +521,33 @@ const sampleInteractiveExamData: EnhancedExamData = {
{ {
id: 'ccp', id: 'ccp',
content: 'Titik Kendali Kritis (CCP)', content: 'Titik Kendali Kritis (CCP)',
correctPosition: 0, correctPosition: 'def1',
isLocked: false isLocked: false
}, },
{ {
id: 'haccp', id: 'haccp',
content: 'Analisis Bahaya dan Titik Kendali Kritis', content: 'Analisis Bahaya dan Titik Kendali Kritis',
correctPosition: 1, correctPosition: 'def2',
isLocked: false isLocked: false
}, },
{ {
id: 'sanitasi', id: 'sanitasi',
content: 'Proses pembersihan dan desinfeksi', content: 'Proses pembersihan dan desinfeksi',
correctPosition: 2, correctPosition: 'def3',
isLocked: false isLocked: false
}, },
{ {
id: 'kontaminasi', id: 'kontaminasi',
content: 'Pencemaran makanan oleh zat berbahaya', content: 'Pencemaran makanan oleh zat berbahaya',
correctPosition: 3, correctPosition: 'def4',
isLocked: false isLocked: false
} }
],
targetAreas: [
{ id: 'def1', label: 'Titik dalam proses produksi makanan di mana bahaya dapat dicegah, dieliminasi, atau dikurangi' },
{ id: 'def2', label: 'Sistem manajemen keamanan pangan yang mengidentifikasi, mengevaluasi, dan mengendalikan bahaya' },
{ id: 'def3', label: 'Tindakan untuk menghilangkan kotoran dan mikroorganisme dari permukaan' },
{ id: 'def4', label: 'Masuknya bahan kimia, fisik, atau biologis yang dapat membahayakan kesehatan' }
] ]
} }
}, },
@ -383,18 +622,51 @@ const sampleInteractiveExamData: EnhancedExamData = {
correctAnswer: 0, correctAnswer: 0,
content: { content: {
question: 'Susun langkah mencuci tangan dalam urutan yang benar', question: 'Susun langkah mencuci tangan dalam urutan yang benar',
instructions: 'Susun langkah mencuci tangan dalam urutan yang benar.' instructions: 'Seret setiap langkah ke urutan yang benar (1-5).',
puzzleType: 'matching' as const
}, },
difficulty: 'easy' as const, difficulty: 'easy' as const,
puzzleData: { puzzleData: {
type: 'sequence' as const, type: 'jigsaw' as const,
instructions: 'Urutkan langkah-langkah mencuci tangan sesuai standar Dapur MBG.', instructions: 'Urutkan langkah-langkah mencuci tangan sesuai standar Dapur MBG.',
sequence: [ pieces: [
'Basahi tangan dengan air bersih', {
'Gunakan sabun dan gosok hingga berbusa', id: 'step1',
'Gosok selama minimal 20 detik', content: 'Basahi tangan dengan air bersih',
'Bilas hingga bersih', correctPosition: 'pos1',
'Keringkan dengan handuk bersih' isLocked: false
},
{
id: 'step2',
content: 'Gunakan sabun dan gosok hingga berbusa',
correctPosition: 'pos2',
isLocked: false
},
{
id: 'step3',
content: 'Gosok selama minimal 20 detik',
correctPosition: 'pos3',
isLocked: false
},
{
id: 'step4',
content: 'Bilas hingga bersih',
correctPosition: 'pos4',
isLocked: false
},
{
id: 'step5',
content: 'Keringkan dengan handuk bersih',
correctPosition: 'pos5',
isLocked: false
}
],
targetAreas: [
{ id: 'pos1', label: 'Langkah 1' },
{ id: 'pos2', label: 'Langkah 2' },
{ id: 'pos3', label: 'Langkah 3' },
{ id: 'pos4', label: 'Langkah 4' },
{ id: 'pos5', label: 'Langkah 5' }
] ]
} }
}, },
@ -406,18 +678,51 @@ const sampleInteractiveExamData: EnhancedExamData = {
correctAnswer: 0, correctAnswer: 0,
content: { content: {
question: 'Urutkan tahapan persiapan makanan dari awal hingga penyajian', question: 'Urutkan tahapan persiapan makanan dari awal hingga penyajian',
instructions: 'Seret dan lepas untuk mengurutkan tahapan persiapan makanan.' instructions: 'Seret setiap tahapan ke urutan yang benar (1-5).',
puzzleType: 'matching' as const
}, },
difficulty: 'hard' as const, difficulty: 'hard' as const,
puzzleData: { puzzleData: {
type: 'sequence' as const, type: 'jigsaw' as const,
instructions: 'Urutkan tahapan persiapan makanan bergizi gratis sesuai standar MBG.', instructions: 'Urutkan tahapan persiapan makanan bergizi gratis sesuai standar MBG.',
sequence: [ pieces: [
'Perencanaan menu bergizi seimbang', {
'Pemilihan dan pemeriksaan bahan baku', id: 'stage1',
'Persiapan dan pengolahan makanan', content: 'Perencanaan menu bergizi seimbang',
'Kontrol kualitas dan suhu', correctPosition: 'order1',
'Penyajian dan distribusi' isLocked: false
},
{
id: 'stage2',
content: 'Pemilihan dan pemeriksaan bahan baku',
correctPosition: 'order2',
isLocked: false
},
{
id: 'stage3',
content: 'Persiapan dan pengolahan makanan',
correctPosition: 'order3',
isLocked: false
},
{
id: 'stage4',
content: 'Kontrol kualitas dan suhu',
correctPosition: 'order4',
isLocked: false
},
{
id: 'stage5',
content: 'Penyajian dan distribusi',
correctPosition: 'order5',
isLocked: false
}
],
targetAreas: [
{ id: 'order1', label: 'Tahap 1' },
{ id: 'order2', label: 'Tahap 2' },
{ id: 'order3', label: 'Tahap 3' },
{ id: 'order4', label: 'Tahap 4' },
{ id: 'order5', label: 'Tahap 5' }
] ]
} }
}, },
@ -659,33 +964,6 @@ const sampleInteractiveExamData: EnhancedExamData = {
} }
] ]
}, },
{
id: '11',
question: 'Puzzle Slider: Pengaturan Suhu Optimal',
type: 'puzzle' as const,
content: {
question: 'Atur suhu yang tepat untuk berbagai jenis penyimpanan makanan',
instructions: 'Geser slider untuk mengatur suhu optimal setiap area penyimpanan',
puzzleType: 'slider' as const
},
difficulty: 'hard' as const,
timeLimit: 120,
puzzleData: {
type: 'jigsaw' as const,
sliders: [
{ id: 's1', label: 'Freezer', minValue: -25, maxValue: 0, correctValue: -18, unit: '°C' },
{ id: 's2', label: 'Kulkas Sayuran', minValue: 0, maxValue: 10, correctValue: 4, unit: '°C' },
{ id: 's3', label: 'Kulkas Daging', minValue: -5, maxValue: 5, correctValue: 2, unit: '°C' },
{ id: 's4', label: 'Display Makanan Panas', minValue: 50, maxValue: 80, correctValue: 65, unit: '°C' }
]
},
interactiveElements: [
{
type: 'explanation',
content: 'Suhu optimal: Freezer -18°C, Sayuran 4°C, Daging 2°C, Display Panas 65°C'
}
]
},
// New Quiz Type 1: Puzzle Gambar/Icon Match // New Quiz Type 1: Puzzle Gambar/Icon Match
{ {
id: '15', id: '15',
@ -706,7 +984,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
leftItem: { leftItem: {
id: 'img1', id: 'img1',
content: 'Termometer Digital', content: 'Termometer Digital',
image: '/images/thermometer-digital.png' image: 'https://cdn-icons-png.flaticon.com/512/2913/2913465.png'
}, },
rightItem: { rightItem: {
id: 'area1', id: 'area1',
@ -718,7 +996,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
leftItem: { leftItem: {
id: 'img2', id: 'img2',
content: 'Cutting Board Berwarna', content: 'Cutting Board Berwarna',
image: '/images/cutting-board-colored.png' image: 'https://cdn-icons-png.flaticon.com/512/2515/2515183.png'
}, },
rightItem: { rightItem: {
id: 'area2', id: 'area2',
@ -730,7 +1008,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
leftItem: { leftItem: {
id: 'img3', id: 'img3',
content: 'Timer Dapur', content: 'Timer Dapur',
image: '/images/kitchen-timer.png' image: 'https://cdn-icons-png.flaticon.com/512/2921/2921222.png'
}, },
rightItem: { rightItem: {
id: 'area3', id: 'area3',
@ -742,12 +1020,36 @@ const sampleInteractiveExamData: EnhancedExamData = {
leftItem: { leftItem: {
id: 'img4', id: 'img4',
content: 'Sarung Tangan Sekali Pakai', content: 'Sarung Tangan Sekali Pakai',
image: '/images/disposable-gloves.png' image: 'https://cdn-icons-png.flaticon.com/512/2913/2913423.png'
}, },
rightItem: { rightItem: {
id: 'area4', id: 'area4',
content: 'Melindungi dari kontaminasi' content: 'Melindungi dari kontaminasi'
} }
},
{
id: 'pair5',
leftItem: {
id: 'img5',
content: 'Topi Chef',
image: 'https://cdn-icons-png.flaticon.com/512/2515/2515191.png'
},
rightItem: {
id: 'area5',
content: 'Menjaga kebersihan rambut'
}
},
{
id: 'pair6',
leftItem: {
id: 'img6',
content: 'Apron/Celemek',
image: 'https://cdn-icons-png.flaticon.com/512/2515/2515189.png'
},
rightItem: {
id: 'area6',
content: 'Melindungi pakaian dari kontaminasi'
}
} }
], ],
allowDragDrop: true, allowDragDrop: true,
@ -775,18 +1077,18 @@ const sampleInteractiveExamData: EnhancedExamData = {
correctAnswer: 0, correctAnswer: 0,
content: { content: {
question: 'Daging mentah boleh disimpan pada suhu 8°C selama 2 hari', question: 'Daging mentah boleh disimpan pada suhu 8°C selama 2 hari',
instructions: 'Jawab dengan cepat! Anda memiliki waktu terbatas untuk menjawab.' instructions: 'Jawab sesuai kecepatan Anda untuk testing.'
}, },
difficulty: 'easy' as const, difficulty: 'easy' as const,
timeLimit: 10, timeLimit: 300, // 5 minutes - very long time limit
reactionTimeConfig: { reactionTimeConfig: {
timeLimit: 10, timeLimit: 300000, // 5 minutes in milliseconds
showCountdown: true, showCountdown: false, // Hide countdown timer
trackReactionTime: true, trackReactionTime: true, // Still track reaction time for testing
instantFeedback: true, instantFeedback: true,
penaltyForWrongAnswer: 2, penaltyForWrongAnswer: 0, // No penalty since this is for testing
bonusForQuickAnswer: 5, bonusForQuickAnswer: 5,
quickAnswerThreshold: 5 quickAnswerThreshold: 3000 // 3 seconds threshold
}, },
interactiveElements: [ interactiveElements: [
{ {
@ -818,29 +1120,33 @@ const sampleInteractiveExamData: EnhancedExamData = {
problemSolving: 60, problemSolving: 60,
communication: 75 communication: 75
}, },
avatar: '/images/kitchen-manager-avatar.png' avatar: 'https://cdn-icons-png.flaticon.com/512/3135/3135715.png'
}, },
scenario: 'Anda adalah manajer dapur MBG yang menghadapi situasi krisis keracunan makanan. Setiap keputusan yang Anda buat akan mempengaruhi reputasi dapur dan keselamatan pelanggan.', scenario: 'Anda adalah manajer dapur MBG yang menghadapi situasi krisis keracunan makanan. Setiap keputusan yang Anda buat akan mempengaruhi reputasi dapur dan keselamatan pelanggan.',
maxSteps: 3,
decisions: [ decisions: [
{ {
id: 'decision1', id: 'decision1',
step: 1, step: 0,
situation: 'Laporan keracunan makanan masuk. Apa langkah pertama Anda?', situation: 'Laporan keracunan makanan masuk. Apa langkah pertama Anda?',
options: [ options: [
{ {
id: 'opt1', id: 'opt1',
text: 'Segera hentikan semua operasi dapur', text: 'Segera hentikan semua operasi dapur',
difficulty: 'medium' difficulty: 'medium',
icon: '🛑'
}, },
{ {
id: 'opt2', id: 'opt2',
text: 'Investigasi sumber makanan yang dicurigai', text: 'Investigasi sumber makanan yang dicurigai',
difficulty: 'easy' difficulty: 'easy',
icon: '🔍'
}, },
{ {
id: 'opt3', id: 'opt3',
text: 'Hubungi manajemen dan otoritas kesehatan', text: 'Hubungi manajemen dan otoritas kesehatan',
difficulty: 'medium' difficulty: 'medium',
icon: '📞'
} }
], ],
consequences: { consequences: {
@ -875,9 +1181,125 @@ const sampleInteractiveExamData: EnhancedExamData = {
nextStep: 'decision2c' nextStep: 'decision2c'
} }
} }
},
{
id: 'decision2',
step: 1,
situation: 'Tim dapur mulai panik. Bagaimana Anda mengelola situasi ini?',
options: [
{
id: 'opt1',
text: 'Berikan instruksi tegas dan jelas kepada semua staff',
difficulty: 'medium',
icon: '👨‍💼'
},
{
id: 'opt2',
text: 'Tenangkan tim dan jelaskan situasi dengan empati',
difficulty: 'easy',
icon: '🤝'
},
{
id: 'opt3',
text: 'Fokus pada dokumentasi dan bukti',
difficulty: 'hard',
icon: '📋'
}
],
consequences: {
'opt1': {
message: 'Tim mengikuti instruksi dengan baik',
statChanges: {
leadership: 10,
empathy: -5,
problemSolving: 5,
communication: 5
},
isEndingPath: false
},
'opt2': {
message: 'Tim merasa didukung dan bekerja lebih tenang',
statChanges: {
leadership: 5,
empathy: 15,
problemSolving: 0,
communication: 10
},
isEndingPath: false
},
'opt3': {
message: 'Dokumentasi lengkap membantu investigasi',
statChanges: {
leadership: 0,
empathy: -5,
problemSolving: 20,
communication: -5
},
isEndingPath: false
}
}
},
{
id: 'decision3',
step: 2,
situation: 'Media mulai meliput kasus ini. Bagaimana respons Anda?',
options: [
{
id: 'opt1',
text: 'Berikan pernyataan transparan kepada media',
difficulty: 'hard',
icon: '📺'
},
{
id: 'opt2',
text: 'Hindari media dan fokus pada penyelesaian internal',
difficulty: 'medium',
icon: '🚫'
},
{
id: 'opt3',
text: 'Koordinasi dengan tim PR perusahaan',
difficulty: 'easy',
icon: '💼'
}
],
consequences: {
'opt1': {
message: 'Transparansi meningkatkan kepercayaan publik',
statChanges: {
leadership: 15,
empathy: 10,
problemSolving: 5,
communication: 20
},
isEndingPath: true,
endingType: 'success'
},
'opt2': {
message: 'Spekulasi media meningkat, reputasi terpengaruh',
statChanges: {
leadership: -10,
empathy: -5,
problemSolving: 10,
communication: -15
},
isEndingPath: true,
endingType: 'failure'
},
'opt3': {
message: 'Respons terkoordinasi dengan baik',
statChanges: {
leadership: 5,
empathy: 5,
problemSolving: 10,
communication: 15
},
isEndingPath: true,
endingType: 'neutral'
}
}
} }
], ],
maxSteps: 5,
scoringSystem: { scoringSystem: {
empathy: 10, empathy: 10,
leadership: 15, leadership: 15,
@ -1041,14 +1463,21 @@ const sampleExamData = {
}; };
function InteractiveQuizWrapper() { function InteractiveQuizWrapper() {
const router = useRouter();
const { const {
state, state,
dispatch, dispatch,
setExamData, setExamData,
setAdminConfig, setAdminConfig,
setInteractiveState setInteractiveState,
submitExam
} = useExam(); } = useExam();
const handleSubmitExam = () => {
submitExam();
router.push('/exam-summary');
};
useEffect(() => { useEffect(() => {
// Set up interactive exam data // Set up interactive exam data
setExamData(sampleInteractiveExamData); setExamData(sampleInteractiveExamData);
@ -1108,13 +1537,33 @@ function InteractiveQuizWrapper() {
> >
Previous Previous
</button> </button>
<button {state.currentQuestion >= (state.examData.interactiveData?.questions.length || state.examData.questions.length) - 1 ? (
onClick={() => dispatch({ type: 'SET_CURRENT_QUESTION', payload: state.currentQuestion + 1 })} <button
disabled={state.currentQuestion >= (state.examData.interactiveData?.questions.length || state.examData.questions.length) - 1} onClick={handleSubmitExam}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors" disabled={state.answers[state.currentQuestion] === undefined}
> className={cn(
Next "px-4 py-2 rounded-lg transition-colors",
</button> state.answers[state.currentQuestion] !== undefined
? "bg-green-600 text-white hover:bg-green-700"
: "bg-gray-300 text-gray-500 cursor-not-allowed disabled:opacity-50"
)}
>
Finish
</button>
) : (
<button
onClick={() => dispatch({ type: 'SET_CURRENT_QUESTION', payload: state.currentQuestion + 1 })}
disabled={state.answers[state.currentQuestion] === undefined}
className={cn(
"px-4 py-2 rounded-lg transition-colors",
state.answers[state.currentQuestion] !== undefined
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-300 text-gray-500 cursor-not-allowed disabled:opacity-50"
)}
>
Next
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -1124,6 +1573,7 @@ function InteractiveQuizWrapper() {
function ExamSessionContent() { function ExamSessionContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter();
const isInteractive = searchParams.get('type') === 'interactive'; const isInteractive = searchParams.get('type') === 'interactive';
// If this is an interactive quiz, render the InteractiveQuizWrapper // If this is an interactive quiz, render the InteractiveQuizWrapper
@ -1151,6 +1601,11 @@ function ExamSessionContent() {
canSubmit, canSubmit,
hasUnsavedChanges hasUnsavedChanges
} = useExam(); } = useExam();
const handleSubmitExam = () => {
submitExam();
router.push('/exam-summary');
};
const [showIntroModal, setShowIntroModal] = useState(true); const [showIntroModal, setShowIntroModal] = useState(true);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
@ -1201,10 +1656,6 @@ function ExamSessionContent() {
// Remove the invalid START_EXAM dispatch - not needed as the exam starts when intro modal closes // Remove the invalid START_EXAM dispatch - not needed as the exam starts when intro modal closes
}; };
const handleSubmitExam = () => {
submitExam();
};
// Don't render main content if intro modal is showing and feature is enabled // Don't render main content if intro modal is showing and feature is enabled
if (showIntroModal && isFeatureEnabled('quizIntroductionModal')) { if (showIntroModal && isFeatureEnabled('quizIntroductionModal')) {
return ( return (
@ -1465,10 +1916,10 @@ function ExamSessionContent() {
{isLastQuestion ? ( {isLastQuestion ? (
<button <button
onClick={() => dispatch({ type: 'SHOW_SUBMIT_MODAL', payload: true })} onClick={() => dispatch({ type: 'SHOW_SUBMIT_MODAL', payload: true })}
disabled={!canSubmit} disabled={state.answers[state.currentQuestion] === undefined}
className={cn( className={cn(
"flex items-center gap-2 px-6 py-3 rounded-lg transition-colors font-medium", "flex items-center gap-2 px-6 py-3 rounded-lg transition-colors font-medium",
canSubmit state.answers[state.currentQuestion] !== undefined
? "bg-green-600 text-white hover:bg-green-700" ? "bg-green-600 text-white hover:bg-green-700"
: "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-gray-300 text-gray-500 cursor-not-allowed"
)} )}
@ -1479,7 +1930,13 @@ function ExamSessionContent() {
) : ( ) : (
<button <button
onClick={handleNextQuestion} onClick={handleNextQuestion}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium" disabled={state.answers[state.currentQuestion] === undefined}
className={cn(
"flex items-center gap-2 px-6 py-3 rounded-lg transition-colors font-medium",
state.answers[state.currentQuestion] !== undefined
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
)}
> >
Selanjutnya Selanjutnya
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />

View File

@ -0,0 +1,242 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle, XCircle, Clock, Award, BarChart3, Home, FileText } from 'lucide-react';
import { cn } from '@/utils/cn';
interface ExamResult {
examId: string;
examTitle: string;
totalQuestions: number;
correctAnswers: number;
score: number;
timeSpent: string;
completedAt: string;
answers: Array<{
questionId: string;
question: string;
userAnswer: any;
correctAnswer: any;
isCorrect: boolean;
type: string;
}>;
}
export default function ExamSummaryPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [examResult, setExamResult] = useState<ExamResult | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Simulate loading exam results
// In real app, this would fetch from API or localStorage
const mockResult: ExamResult = {
examId: 'exam-1',
examTitle: 'Ujian Keamanan Pangan MBG',
totalQuestions: 10,
correctAnswers: 8,
score: 80,
timeSpent: '25 menit 30 detik',
completedAt: new Date().toLocaleString('id-ID'),
answers: [
{
questionId: '1',
question: 'Suhu ideal untuk menyimpan daging segar adalah...',
userAnswer: 'Di bawah 4°C',
correctAnswer: 'Di bawah 4°C',
isCorrect: true,
type: 'multiple_choice'
},
{
questionId: '2',
question: 'Langkah pertama dalam sistem HACCP adalah...',
userAnswer: 'Analisis bahaya',
correctAnswer: 'Analisis bahaya',
isCorrect: true,
type: 'multiple_choice'
},
// Add more mock answers as needed
]
};
setTimeout(() => {
setExamResult(mockResult);
setLoading(false);
}, 1000);
}, []);
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-green-600';
if (score >= 60) return 'text-yellow-600';
return 'text-red-600';
};
const getScoreBgColor = (score: number) => {
if (score >= 80) return 'bg-green-100 border-green-200';
if (score >= 60) return 'bg-yellow-100 border-yellow-200';
return 'bg-red-100 border-red-200';
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Memproses hasil ujian...</p>
</div>
</div>
);
}
if (!examResult) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Hasil ujian tidak ditemukan</h2>
<button
onClick={() => router.push('/exams')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Kembali ke Daftar Ujian
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
{examResult.score >= 80 ? (
<CheckCircle className="w-16 h-16 text-green-500" />
) : examResult.score >= 60 ? (
<Award className="w-16 h-16 text-yellow-500" />
) : (
<XCircle className="w-16 h-16 text-red-500" />
)}
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{examResult.score >= 80 ? 'Selamat!' : examResult.score >= 60 ? 'Cukup Baik!' : 'Perlu Perbaikan'}
</h1>
<p className="text-gray-600">Anda telah menyelesaikan ujian</p>
</div>
{/* Score Summary */}
<div className={cn(
"bg-white rounded-xl shadow-lg p-6 mb-8 border-2",
getScoreBgColor(examResult.score)
)}>
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">{examResult.examTitle}</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="text-center">
<div className={cn("text-4xl font-bold mb-2", getScoreColor(examResult.score))}>
{examResult.score}%
</div>
<p className="text-gray-600">Skor Akhir</p>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900 mb-2">
{examResult.correctAnswers}/{examResult.totalQuestions}
</div>
<p className="text-gray-600">Jawaban Benar</p>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900 mb-2 flex items-center justify-center gap-2">
<Clock className="w-6 h-6" />
{examResult.timeSpent}
</div>
<p className="text-gray-600">Waktu Pengerjaan</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-gray-900 mb-2">
{examResult.completedAt}
</div>
<p className="text-gray-600">Selesai Pada</p>
</div>
</div>
</div>
</div>
{/* Detailed Results */}
<div className="bg-white rounded-xl shadow-lg p-6 mb-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<BarChart3 className="w-6 h-6" />
Detail Jawaban
</h3>
<div className="space-y-4">
{examResult.answers.map((answer, index) => (
<div
key={answer.questionId}
className={cn(
"p-4 rounded-lg border-2",
answer.isCorrect
? "bg-green-50 border-green-200"
: "bg-red-50 border-red-200"
)}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
{answer.isCorrect ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : (
<XCircle className="w-6 h-6 text-red-600" />
)}
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-2">
Soal {index + 1}: {answer.question}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600 mb-1">Jawaban Anda:</p>
<p className={cn(
"font-medium",
answer.isCorrect ? "text-green-700" : "text-red-700"
)}>
{typeof answer.userAnswer === 'string' ? answer.userAnswer : 'Tidak dijawab'}
</p>
</div>
{!answer.isCorrect && (
<div>
<p className="text-sm text-gray-600 mb-1">Jawaban Benar:</p>
<p className="font-medium text-green-700">
{typeof answer.correctAnswer === 'string' ? answer.correctAnswer : 'N/A'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={() => router.push('/exams')}
className="flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
<FileText className="w-5 h-5" />
Lihat Ujian Lain
</button>
<button
onClick={() => router.push('/')}
className="flex items-center justify-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium"
>
<Home className="w-5 h-5" />
Kembali ke Dashboard
</button>
</div>
</div>
</div>
);
}

View File

@ -10,7 +10,7 @@ import {
} from '../../features/payroll-reward-system/components'; } from '../../features/payroll-reward-system/components';
import { PayrollRewardSystemModule } from '../../features/payroll-reward-system'; import { PayrollRewardSystemModule } from '../../features/payroll-reward-system';
import { setupGlobalErrorHandling } from '../../core/errors'; import { setupGlobalErrorHandling } from '../../core/errors';
import { container } from '../../core/di/DIContainer'; import { container as globalContainer } from '../../core/di/DIContainer';
export default function PayrollDemoPage() { export default function PayrollDemoPage() {
const [moduleLoaded, setModuleLoaded] = useState(false); const [moduleLoaded, setModuleLoaded] = useState(false);
@ -33,23 +33,43 @@ export default function PayrollDemoPage() {
// Create module instance // Create module instance
const moduleInstance = new PayrollRewardSystemModule(); const moduleInstance = new PayrollRewardSystemModule();
// Register services in DI container // Activate the module first (this initializes the services)
const services = moduleInstance.getServices();
// Activate the module
await moduleInstance.activate(); await moduleInstance.activate();
// Register services with container // Get services after activation
const services = moduleInstance.getServices();
// Register services with container using lazy initialization to avoid circular dependency
for (const serviceConfig of services) { for (const serviceConfig of services) {
const serviceInstance = serviceConfig.factory(); try {
container.registerInstance(serviceConfig.token, serviceInstance); console.log(`Registering service: ${serviceConfig.token}`);
// Check if service is already registered to prevent duplicates
if (globalContainer.has(serviceConfig.token)) {
console.log(`Service ${serviceConfig.token} already registered, skipping`);
continue;
}
// Register as singleton with lazy factory to avoid circular dependency
globalContainer.singleton(serviceConfig.token, () => {
const serviceInstance = serviceConfig.factory();
console.log(`Created service instance: ${serviceConfig.token}`);
return serviceInstance;
});
console.log(`✅ Registered service: ${serviceConfig.token}`);
} catch (serviceError) {
console.error(`❌ Failed to register service ${serviceConfig.token}:`, serviceError);
throw new Error(`Failed to register ${serviceConfig.token}: ${serviceError instanceof Error ? serviceError.message : 'Unknown error'}`);
}
} }
setModuleLoaded(true); setModuleLoaded(true);
console.log('Payroll Reward System module loaded successfully'); console.log('🎉 Payroll Reward System module loaded successfully');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to initialize module'); const errorMessage = err instanceof Error ? err.message : 'Failed to initialize module';
console.error('Failed to initialize module:', err); setError(errorMessage);
console.error('❌ Failed to initialize module:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -235,9 +235,13 @@ export default function AdminTopBar({ onMenuClick, sidebarCollapsed }: AdminTopB
<div className="border-t border-gray-100"> <div className="border-t border-gray-100">
<button <button
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => { onClick={async () => {
setShowUserMenu(false); try {
logout(); setShowUserMenu(false);
logout();
} catch (error) {
console.error('Logout button error:', error);
}
}} }}
> >
Keluar Keluar

View File

@ -59,7 +59,6 @@ export interface InteractiveQuestionData {
interactiveConfig?: { interactiveConfig?: {
// Enhanced MCQ // Enhanced MCQ
allowMultipleAnswers?: boolean; allowMultipleAnswers?: boolean;
showConfidenceScale?: boolean;
enableHints?: boolean; enableHints?: boolean;
hints?: string[]; hints?: string[];
@ -156,7 +155,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
multimedia: [], multimedia: [],
interactiveConfig: { interactiveConfig: {
allowMultipleAnswers: false, allowMultipleAnswers: false,
showConfidenceScale: false,
enableHints: false, enableHints: false,
hints: [], hints: [],
timeLimit: 0, timeLimit: 0,
@ -575,16 +573,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
<span className="ml-2">Izinkan Multiple Answers</span> <span className="ml-2">Izinkan Multiple Answers</span>
</label> </label>
<label className="flex items-center">
<input
type="checkbox"
checked={questionData.interactiveConfig?.showConfidenceScale || false}
onChange={(e) => handleInteractiveConfigChange('showConfidenceScale', e.target.checked)}
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2">Tampilkan Skala Confidence</span>
</label>
<label className="flex items-center"> <label className="flex items-center">
<input <input
type="checkbox" type="checkbox"
@ -852,19 +840,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
)} )}
</label> </label>
))} ))}
{questionData.interactiveConfig?.showConfidenceScale && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm font-medium text-blue-900 mb-2">Seberapa yakin Anda dengan jawaban ini?</p>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map(level => (
<button key={level} className="px-3 py-1 bg-white border rounded text-sm">
{level}
</button>
))}
</div>
</div>
)}
</div> </div>
)} )}

View File

@ -42,7 +42,6 @@ interface QuizPreviewProps {
interface PreviewState { interface PreviewState {
currentIndex: number; currentIndex: number;
selectedAnswers: { [questionId: string]: any }; selectedAnswers: { [questionId: string]: any };
confidenceScores: { [questionId: string]: number };
timeSpent: { [questionId: string]: number }; timeSpent: { [questionId: string]: number };
startTime: number; startTime: number;
isPlaying: boolean; isPlaying: boolean;
@ -67,7 +66,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
const [previewState, setPreviewState] = useState<PreviewState>({ const [previewState, setPreviewState] = useState<PreviewState>({
currentIndex: currentQuestionIndex, currentIndex: currentQuestionIndex,
selectedAnswers: {}, selectedAnswers: {},
confidenceScores: {},
timeSpent: {}, timeSpent: {},
startTime: Date.now(), startTime: Date.now(),
isPlaying: false, isPlaying: false,
@ -162,16 +160,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
})); }));
}; };
const handleConfidenceChange = (questionId: string, confidence: number) => {
setPreviewState(prev => ({
...prev,
confidenceScores: {
...prev.confidenceScores,
[questionId]: confidence
}
}));
};
const toggleHints = (questionId: string) => { const toggleHints = (questionId: string) => {
setPreviewState(prev => ({ setPreviewState(prev => ({
...prev, ...prev,
@ -204,7 +192,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
setPreviewState({ setPreviewState({
currentIndex: 0, currentIndex: 0,
selectedAnswers: {}, selectedAnswers: {},
confidenceScores: {},
timeSpent: {}, timeSpent: {},
startTime: Date.now(), startTime: Date.now(),
isPlaying: false, isPlaying: false,
@ -359,34 +346,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
</label> </label>
); );
})} })}
{/* Confidence Scale */}
{currentQuestion.interactiveConfig?.showConfidenceScale && (
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm font-medium text-blue-900 mb-3">
Seberapa yakin Anda dengan jawaban ini?
</p>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map(level => (
<button
key={level}
onClick={() => handleConfidenceChange(questionId, level)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
previewState.confidenceScores[questionId] === level
? 'bg-blue-600 text-white'
: 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-100'
}`}
>
{level}
</button>
))}
</div>
<div className="flex justify-between text-xs text-blue-700 mt-1">
<span>Tidak yakin</span>
<span>Sangat yakin</span>
</div>
</div>
)}
</div> </div>
)} )}

View File

@ -0,0 +1,358 @@
import React, { useState, useRef, useEffect } from 'react';
import { Eye, Target, CheckCircle, XCircle, AlertTriangle, Lightbulb, Zap } from 'lucide-react';
import { cn } from '../../utils/cn';
interface InteractiveImageHotspotProps {
imageSrc: string;
title: string;
description: string;
hotspots: ImageHotspot[];
onComplete: (results: HotspotResult[]) => void;
className?: string;
showHints?: boolean;
allowMultipleAttempts?: boolean;
}
interface ImageHotspot {
id: string;
x: number; // percentage (0-100)
y: number; // percentage (0-100)
width: number; // percentage
height: number; // percentage
type: 'correct' | 'incorrect' | 'neutral' | 'warning';
title: string;
description: string;
feedback: string;
points: number;
hint?: string;
consequence?: string;
isRequired?: boolean;
}
interface HotspotResult {
hotspotId: string;
clicked: boolean;
timestamp: number;
attempts: number;
isCorrect: boolean;
points: number;
}
const InteractiveImageHotspot: React.FC<InteractiveImageHotspotProps> = ({
imageSrc,
title,
description,
hotspots,
onComplete,
className,
showHints = true,
allowMultipleAttempts = true
}) => {
const imageRef = useRef<HTMLImageElement>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [clickedHotspots, setClickedHotspots] = useState<Set<string>>(new Set());
const [hotspotResults, setHotspotResults] = useState<Map<string, HotspotResult>>(new Map());
const [showFeedback, setShowFeedback] = useState<string | null>(null);
const [currentFeedback, setCurrentFeedback] = useState<ImageHotspot | null>(null);
const [hintsVisible, setHintsVisible] = useState(false);
const [attempts, setAttempts] = useState<Map<string, number>>(new Map());
useEffect(() => {
// Check if all required hotspots are clicked
const requiredHotspots = hotspots.filter(h => h.isRequired);
const clickedRequired = requiredHotspots.filter(h => clickedHotspots.has(h.id));
if (requiredHotspots.length > 0 && clickedRequired.length === requiredHotspots.length) {
const results = Array.from(hotspotResults.values());
onComplete(results);
}
}, [clickedHotspots, hotspots, hotspotResults, onComplete]);
const handleHotspotClick = (hotspot: ImageHotspot, event: React.MouseEvent) => {
event.preventDefault();
const currentAttempts = attempts.get(hotspot.id) || 0;
const newAttempts = currentAttempts + 1;
// Update attempts
setAttempts(prev => new Map(prev.set(hotspot.id, newAttempts)));
// Check if already clicked and multiple attempts not allowed
if (clickedHotspots.has(hotspot.id) && !allowMultipleAttempts) {
return;
}
// Add to clicked hotspots
setClickedHotspots(prev => new Set([...Array.from(prev), hotspot.id]));
// Calculate points (reduce points for multiple attempts)
let points = hotspot.points;
if (newAttempts > 1) {
points = Math.max(0, hotspot.points - (newAttempts - 1) * 2);
}
// Create result
const result: HotspotResult = {
hotspotId: hotspot.id,
clicked: true,
timestamp: Date.now(),
attempts: newAttempts,
isCorrect: hotspot.type === 'correct',
points: hotspot.type === 'correct' ? points : 0
};
// Update results
setHotspotResults(prev => new Map(prev.set(hotspot.id, result)));
// Show feedback
setCurrentFeedback(hotspot);
setShowFeedback(hotspot.id);
// Auto-hide feedback after 3 seconds
setTimeout(() => {
setShowFeedback(null);
setCurrentFeedback(null);
}, 3000);
};
const getHotspotIcon = (type: string) => {
switch (type) {
case 'correct':
return <CheckCircle className="text-green-600" size={16} />;
case 'incorrect':
return <XCircle className="text-red-600" size={16} />;
case 'warning':
return <AlertTriangle className="text-yellow-600" size={16} />;
default:
return <Target className="text-blue-600" size={16} />;
}
};
const getHotspotColor = (hotspot: ImageHotspot) => {
const isClicked = clickedHotspots.has(hotspot.id);
if (isClicked) {
switch (hotspot.type) {
case 'correct':
return 'border-green-500 bg-green-500/20';
case 'incorrect':
return 'border-red-500 bg-red-500/20';
case 'warning':
return 'border-yellow-500 bg-yellow-500/20';
default:
return 'border-blue-500 bg-blue-500/20';
}
}
return showHints
? 'border-blue-400 bg-blue-400/10 animate-pulse'
: 'border-transparent bg-transparent hover:border-blue-400 hover:bg-blue-400/10';
};
const totalPoints = Array.from(hotspotResults.values()).reduce((sum, result) => sum + result.points, 0);
const maxPoints = hotspots.reduce((sum, hotspot) => sum + hotspot.points, 0);
const requiredHotspots = hotspots.filter(h => h.isRequired);
const clickedRequired = requiredHotspots.filter(h => clickedHotspots.has(h.id));
const progress = requiredHotspots.length > 0 ? (clickedRequired.length / requiredHotspots.length) * 100 : 0;
return (
<div className={cn("bg-white rounded-lg shadow-lg overflow-hidden", className)}>
{/* Header */}
<div className="p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-blue-100 text-sm mt-1">{description}</p>
{/* Instructions */}
<div className="mt-3 p-3 bg-white/10 rounded-lg">
<p className="text-sm text-blue-50">
📍 <strong>Instruksi:</strong> Klik pada area-area bermasalah dalam gambar.
Area yang wajib ditemukan: <span className="font-bold">{requiredHotspots.length}</span>
</p>
</div>
<div className="flex items-center justify-between mt-3">
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Target size={16} />
{clickedRequired.length}/{requiredHotspots.length} Wajib
</span>
<span className="flex items-center gap-1">
<Eye size={16} />
{clickedHotspots.size}/{hotspots.length} Total
</span>
<span className="flex items-center gap-1">
<Zap size={16} />
{totalPoints}/{maxPoints} Poin
</span>
</div>
<button
onClick={() => setHintsVisible(!hintsVisible)}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium transition-colors",
hintsVisible
? "bg-yellow-500 text-white"
: "bg-white/20 text-white hover:bg-white/30"
)}
>
<Lightbulb size={14} className="inline mr-1" />
{hintsVisible ? 'Sembunyikan Hint' : 'Tampilkan Hint'}
</button>
</div>
</div>
{/* Progress Bar */}
<div className="px-4 py-2 bg-gray-50">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-700">Progress Area Wajib</span>
<span className="text-xs text-gray-600">{Math.round(progress)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all duration-300",
progress === 100 ? "bg-green-500" : "bg-blue-500"
)}
style={{ width: `${progress}%` }}
/>
</div>
{progress === 100 && (
<p className="text-xs text-green-600 mt-1 font-medium">
Semua area wajib telah ditemukan!
</p>
)}
</div>
{/* Interactive Image */}
<div className="relative">
<img
ref={imageRef}
src={imageSrc}
alt={title}
className="w-full h-auto"
onLoad={() => setImageLoaded(true)}
/>
{/* Hotspots Overlay */}
{imageLoaded && (
<div className="absolute inset-0">
{hotspots.map((hotspot) => (
<button
key={hotspot.id}
className={cn(
"absolute border-2 rounded-lg transition-all duration-200 cursor-pointer",
"flex items-center justify-center group hover:scale-105",
getHotspotColor(hotspot),
clickedHotspots.has(hotspot.id) && "animate-pulse"
)}
style={{
left: `${hotspot.x}%`,
top: `${hotspot.y}%`,
width: `${hotspot.width}%`,
height: `${hotspot.height}%`,
}}
onClick={(e) => handleHotspotClick(hotspot, e)}
title={hintsVisible ? hotspot.hint || hotspot.title : 'Klik untuk memeriksa area ini'}
>
{clickedHotspots.has(hotspot.id) && getHotspotIcon(hotspot.type)}
{/* Hotspot Number */}
<span className={cn(
"absolute -top-2 -right-2 w-6 h-6 rounded-full text-xs font-bold",
"flex items-center justify-center shadow-lg",
clickedHotspots.has(hotspot.id)
? hotspot.type === 'correct'
? "bg-green-500 text-white"
: "bg-red-500 text-white"
: hotspot.isRequired
? "bg-orange-500 text-white animate-bounce"
: "bg-blue-500 text-white"
)}>
{hotspots.indexOf(hotspot) + 1}
</span>
{/* Required indicator */}
{hotspot.isRequired && !clickedHotspots.has(hotspot.id) && (
<span className="absolute -top-1 -left-1 w-3 h-3 bg-red-500 rounded-full animate-ping" />
)}
{/* Hover tooltip */}
{!clickedHotspots.has(hotspot.id) && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{hotspot.isRequired ? '⚠️ Area Wajib' : '💡 Area Opsional'}
</div>
)}
</button>
))}
</div>
)}
{/* Feedback Overlay */}
{showFeedback && currentFeedback && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
{getHotspotIcon(currentFeedback.type)}
<h4 className="text-lg font-semibold text-gray-800">
{currentFeedback.title}
</h4>
</div>
<p className="text-gray-700 mb-4">{currentFeedback.feedback}</p>
{currentFeedback.consequence && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-4">
<p className="text-sm text-yellow-800">
<strong>Konsekuensi:</strong> {currentFeedback.consequence}
</p>
</div>
)}
<div className="flex items-center justify-between">
<span className={cn(
"px-3 py-1 rounded-full text-sm font-medium",
currentFeedback.type === 'correct'
? "bg-green-100 text-green-800"
: currentFeedback.type === 'incorrect'
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
)}>
{currentFeedback.type === 'correct' ? '+' : ''}{currentFeedback.points} poin
</span>
<button
onClick={() => {
setShowFeedback(null);
setCurrentFeedback(null);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Lanjutkan
</button>
</div>
</div>
</div>
)}
</div>
{/* Summary */}
<div className="p-4 bg-gray-50">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Area Ditemukan:</span>
<span className="ml-2 font-semibold text-gray-800">
{clickedHotspots.size}/{hotspots.length}
</span>
</div>
<div>
<span className="text-gray-600">Total Poin:</span>
<span className="ml-2 font-semibold text-green-600">
{totalPoints}/{maxPoints}
</span>
</div>
</div>
</div>
</div>
);
};
export default InteractiveImageHotspot;

View File

@ -26,6 +26,9 @@ import PuzzleGambarIconMatch from './PuzzleGambarIconMatch';
import TrueFalseCepatTepat from './TrueFalseCepatTepat'; import TrueFalseCepatTepat from './TrueFalseCepatTepat';
import MiniSimulationRPG from './MiniSimulationRPG'; import MiniSimulationRPG from './MiniSimulationRPG';
import MiniSurveyReflection from './MiniSurveyReflection'; import MiniSurveyReflection from './MiniSurveyReflection';
import VideoScenario from './VideoScenario';
import InteractiveImageHotspot from './InteractiveImageHotspot';
import MediaGallery from './MediaGallery';
interface InteractiveQuizRendererProps { interface InteractiveQuizRendererProps {
question: InteractiveQuestion; question: InteractiveQuestion;
@ -56,8 +59,7 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
const [startTime] = useState(Date.now()); const [startTime] = useState(Date.now());
const [lastInteraction, setLastInteraction] = useState(Date.now()); const [lastInteraction, setLastInteraction] = useState(Date.now());
const [confidenceLevel, setConfidenceLevel] = useState<number>(3);
const [showConfidenceRating, setShowConfidenceRating] = useState(false);
// Guard clause untuk question yang undefined // Guard clause untuk question yang undefined
if (!question) { if (!question) {
@ -98,19 +100,9 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
onAnswerChange?.(answer); onAnswerChange?.(answer);
}; };
// Effect untuk menampilkan confidence rating
useEffect(() => {
if (question?.content?.instructions && state.answers[questionIndex]) {
setShowConfidenceRating(true);
}
}, [question?.content?.instructions, state.answers, questionIndex]);
// Handle confidence rating
const handleConfidenceRating = (rating: number) => {
setConfidenceLevel(rating);
setConfidenceRating(questionIndex, rating);
setShowConfidenceRating(false);
};
// Render ikon berdasarkan tipe pertanyaan // Render ikon berdasarkan tipe pertanyaan
const renderQuestionTypeIcon = () => { const renderQuestionTypeIcon = () => {
@ -131,6 +123,12 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
return <Gamepad2 {...iconProps} />; return <Gamepad2 {...iconProps} />;
case 'mini_survey_reflection': case 'mini_survey_reflection':
return <Heart {...iconProps} />; return <Heart {...iconProps} />;
case 'video_scenario':
return <ChefHat {...iconProps} />;
case 'image_hotspot':
return <MousePointer {...iconProps} />;
case 'media_gallery':
return <Image {...iconProps} />;
default: default:
return <HelpCircle {...iconProps} />; return <HelpCircle {...iconProps} />;
} }
@ -167,6 +165,41 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
case 'mini_survey_reflection': case 'mini_survey_reflection':
return <MiniSurveyReflection {...commonProps} />; return <MiniSurveyReflection {...commonProps} />;
case 'video_scenario':
return (
<VideoScenario
videoSrc={question.content?.media?.src || ''}
title={question.question || ''}
description={question.content?.instructions || ''}
scenarios={question.scenarios || []}
onScenarioComplete={(results) => {
if (onAnswerChange) {
onAnswerChange(results);
}
}}
/>
);
case 'image_hotspot':
return (
<InteractiveImageHotspot
imageSrc={question.content?.media?.src || ''}
title={question.question || ''}
description={question.content?.instructions || ''}
hotspots={question.hotspots || []}
onComplete={(results) => {
if (onAnswerChange) {
onAnswerChange(results);
}
}}
showHints={true}
allowMultipleAttempts={true}
/>
);
case 'media_gallery':
return <MediaGallery {...commonProps} />;
default: default:
return ( return (
<div className="p-6 text-center text-gray-500"> <div className="p-6 text-center text-gray-500">
@ -275,48 +308,6 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
{/* Render komponen pertanyaan */} {/* Render komponen pertanyaan */}
{renderQuestionComponent()} {renderQuestionComponent()}
</div> </div>
{/* Confidence Rating Modal */}
{showConfidenceRating && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">
{adminLanguage === 'indonesia'
? 'Seberapa yakin Anda dengan jawaban ini?'
: 'How confident are you with this answer?'}
</h3>
<div className="flex justify-between mb-4">
{[1, 2, 3, 4, 5].map((rating) => (
<button
key={rating}
onClick={() => handleConfidenceRating(rating)}
className={cn(
'w-12 h-12 rounded-full border-2 flex items-center justify-center font-semibold transition-colors',
confidenceLevel === rating
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
)}
>
{rating}
</button>
))}
</div>
<div className="flex justify-between text-sm text-gray-600 mb-4">
<span>{adminLanguage === 'indonesia' ? 'Tidak yakin' : 'Not sure'}</span>
<span>{adminLanguage === 'indonesia' ? 'Sangat yakin' : 'Very sure'}</span>
</div>
<button
onClick={() => setShowConfidenceRating(false)}
className="w-full py-2 px-4 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
>
{adminLanguage === 'indonesia' ? 'Lewati' : 'Skip'}
</button>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -0,0 +1,409 @@
'use client';
import React, { useState, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { InteractiveQuestion, MediaItem } from '@/types';
import {
Play,
Pause,
Volume2,
VolumeX,
Download,
FileText,
Image as ImageIcon,
Video,
Music,
Maximize,
X,
ChevronLeft,
ChevronRight
} from 'lucide-react';
interface MediaGalleryProps {
question: InteractiveQuestion;
questionIndex: number;
onAnswerChange: (answer: any) => void;
adminLanguage: string;
}
export default function MediaGallery({
question,
questionIndex,
onAnswerChange,
adminLanguage
}: MediaGalleryProps) {
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [completedMedia, setCompletedMedia] = useState<Set<string>>(new Set());
const [progress, setProgress] = useState<{ [key: string]: number }>({});
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const mediaItems = question.mediaItems || [];
const currentMedia = mediaItems[currentMediaIndex];
const handleMediaComplete = (mediaId: string) => {
setCompletedMedia(prev => new Set([...Array.from(prev), mediaId]));
// Update progress
const newProgress = { ...progress };
newProgress[mediaId] = 100;
setProgress(newProgress);
// Report completion to parent
onAnswerChange({
completedMedia: Array.from(new Set([...Array.from(completedMedia), mediaId])),
currentMediaIndex,
totalMedia: mediaItems.length,
progress: newProgress
});
};
const handlePlayPause = () => {
if (currentMedia?.type === 'video' && videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
} else if (currentMedia?.type === 'audio' && audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleMuteToggle = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
}
if (audioRef.current) {
audioRef.current.muted = !isMuted;
}
setIsMuted(!isMuted);
};
const handleDownload = (media: MediaItem) => {
// Create download link
const link = document.createElement('a');
link.href = media.src;
link.download = media.title || 'media-file';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const navigateMedia = (direction: 'prev' | 'next') => {
if (direction === 'prev' && currentMediaIndex > 0) {
setCurrentMediaIndex(currentMediaIndex - 1);
} else if (direction === 'next' && currentMediaIndex < mediaItems.length - 1) {
setCurrentMediaIndex(currentMediaIndex + 1);
}
setIsPlaying(false);
};
const getMediaIcon = (type: string) => {
switch (type) {
case 'video': return <Video className="w-5 h-5" />;
case 'audio': return <Music className="w-5 h-5" />;
case 'image': return <ImageIcon className="w-5 h-5" />;
case 'document': return <FileText className="w-5 h-5" />;
default: return <FileText className="w-5 h-5" />;
}
};
const renderMediaContent = () => {
if (!currentMedia) return null;
switch (currentMedia.type) {
case 'video':
return (
<div className="relative bg-black rounded-lg overflow-hidden">
<video
ref={videoRef}
src={currentMedia.src}
className="w-full h-64 object-contain"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => handleMediaComplete(currentMedia.id)}
controls={false}
/>
{/* Video Controls */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handlePlayPause}
className="text-white hover:bg-white/20"
>
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleMuteToggle}
className="text-white hover:bg-white/20"
>
{isMuted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setIsFullscreen(true)}
className="text-white hover:bg-white/20"
>
<Maximize className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
case 'audio':
return (
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-6">
<audio
ref={audioRef}
src={currentMedia.src}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => handleMediaComplete(currentMedia.id)}
className="hidden"
/>
<div className="flex items-center justify-center gap-4">
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center">
<Music className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg">{currentMedia.title}</h3>
<p className="text-gray-600 text-sm">{currentMedia.description}</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={handlePlayPause} variant="outline">
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</Button>
<Button onClick={handleMuteToggle} variant="outline" size="sm">
{isMuted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</Button>
</div>
</div>
</div>
);
case 'image':
return (
<div className="relative">
<img
src={currentMedia.src}
alt={currentMedia.title}
className="w-full h-64 object-cover rounded-lg"
onLoad={() => handleMediaComplete(currentMedia.id)}
/>
<Button
className="absolute top-2 right-2"
size="sm"
variant="secondary"
onClick={() => setIsFullscreen(true)}
>
<Maximize className="w-4 h-4" />
</Button>
</div>
);
case 'document':
return (
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<FileText className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="font-semibold text-lg mb-2">{currentMedia.title}</h3>
<p className="text-gray-600 mb-4">{currentMedia.description}</p>
<div className="flex justify-center gap-2">
<Button
onClick={() => window.open(currentMedia.src, '_blank')}
variant="outline"
>
Buka Dokumen
</Button>
<Button
onClick={() => handleDownload(currentMedia)}
variant="outline"
>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
</div>
);
default:
return (
<div className="text-center text-gray-500 p-8">
<p>Tipe media tidak didukung</p>
</div>
);
}
};
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
📚 {question.question || 'Media Gallery'}
</span>
<Badge variant="outline">
{currentMediaIndex + 1} / {mediaItems.length}
</Badge>
</CardTitle>
{question.content?.description && (
<p className="text-gray-600">{question.content.description}</p>
)}
</CardHeader>
<CardContent className="space-y-6">
{/* Media Navigation */}
<div className="flex items-center justify-between">
<Button
onClick={() => navigateMedia('prev')}
disabled={currentMediaIndex === 0}
variant="outline"
size="sm"
>
<ChevronLeft className="w-4 h-4 mr-1" />
Sebelumnya
</Button>
<div className="flex items-center gap-2">
{getMediaIcon(currentMedia?.type || 'document')}
<span className="font-medium">{currentMedia?.title}</span>
{completedMedia.has(currentMedia?.id || '') && (
<Badge className="bg-green-500"> Selesai</Badge>
)}
</div>
<Button
onClick={() => navigateMedia('next')}
disabled={currentMediaIndex === mediaItems.length - 1}
variant="outline"
size="sm"
>
Selanjutnya
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
{/* Media Content */}
<div className="space-y-4">
{renderMediaContent()}
{/* Media Description */}
{currentMedia?.description && (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-gray-700">{currentMedia.description}</p>
{currentMedia.duration && (
<p className="text-sm text-gray-500 mt-2">
Durasi: {Math.floor(currentMedia.duration / 60)}:{(currentMedia.duration % 60).toString().padStart(2, '0')}
</p>
)}
</div>
)}
</div>
{/* Progress Indicator */}
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span>Progress Media</span>
<span>{completedMedia.size} / {mediaItems.length} selesai</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${(completedMedia.size / mediaItems.length) * 100}%` }}
/>
</div>
</div>
{/* Media List */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{mediaItems.map((media, index) => (
<button
key={media.id}
onClick={() => setCurrentMediaIndex(index)}
className={`p-3 rounded-lg border text-left transition-colors ${
index === currentMediaIndex
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-2 mb-1">
{getMediaIcon(media.type)}
<span className="text-sm font-medium truncate">{media.title}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 capitalize">{media.type}</span>
{completedMedia.has(media.id) && (
<Badge className="bg-green-500 text-xs"></Badge>
)}
</div>
</button>
))}
</div>
</CardContent>
{/* Fullscreen Modal */}
{isFullscreen && (
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50">
<div className="relative max-w-6xl max-h-full p-4">
<Button
onClick={() => setIsFullscreen(false)}
className="absolute top-2 right-2 z-10"
variant="secondary"
size="sm"
>
<X className="w-4 h-4" />
</Button>
{currentMedia?.type === 'image' ? (
<img
src={currentMedia.src}
alt={currentMedia.title}
className="max-w-full max-h-full object-contain"
/>
) : currentMedia?.type === 'video' ? (
<video
src={currentMedia.src}
controls
className="max-w-full max-h-full"
autoPlay
/>
) : null}
</div>
</div>
)}
</Card>
);
}

View File

@ -386,17 +386,43 @@ export default function MiniSurveyReflection({
Sebelumnya Sebelumnya
</Button> </Button>
<div className="text-center text-sm text-gray-500"> <div className="text-center text-sm">
{Object.keys(answers).length} dari {config.questions.length} pertanyaan dijawab <div className="text-gray-500 mb-1">
{Object.keys(answers).length} dari {config.questions.length} pertanyaan dijawab
</div>
{isLastQuestion && !isCompleted && (
<div className="text-red-500 text-xs">
Selesaikan semua pertanyaan wajib untuk melanjutkan
</div>
)}
{isLastQuestion && isCompleted && (
<div className="text-green-600 text-xs">
Siap untuk menyelesaikan refleksi
</div>
)}
</div> </div>
<Button {isLastQuestion ? (
onClick={handleNext} <Button
disabled={currentQuestion.required && answers[currentQuestion.id] === undefined} onClick={handleNext}
className="bg-blue-600 hover:bg-blue-700" disabled={!isCompleted}
> className={`${
{isLastQuestion ? 'Selesai' : 'Selanjutnya'} isCompleted
</Button> ? 'bg-green-600 hover:bg-green-700'
: 'bg-gray-400 cursor-not-allowed'
}`}
>
{isCompleted ? '✓ Selesai' : 'Jawab Pertanyaan Wajib'}
</Button>
) : (
<Button
onClick={handleNext}
disabled={currentQuestion.required && answers[currentQuestion.id] === undefined}
className="bg-blue-600 hover:bg-blue-700"
>
Selanjutnya
</Button>
)}
</div> </div>
{/* Instructions */} {/* Instructions */}

View File

@ -27,7 +27,7 @@ interface PuzzlePiece {
id: string; id: string;
content: string; content: string;
correctPosition: number | string; correctPosition: number | string;
currentPosition: number; currentPosition: number | string;
isLocked?: boolean; isLocked?: boolean;
} }
@ -43,24 +43,20 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
question, question,
questionIndex, questionIndex,
onAnswerChange, onAnswerChange,
adminLanguage adminLanguage = 'id'
}) => { }) => {
const { state, setPuzzleState } = useExam(); const { state, setPuzzleState } = useExam();
const [puzzleState, setLocalPuzzleState] = useState<PuzzleState>({ const [localPuzzleState, setLocalPuzzleState] = useState<PuzzleState | null>(null);
pieces: [],
isComplete: false,
moves: 0,
startTime: Date.now(),
hints: 0
});
const [draggedPiece, setDraggedPiece] = useState<string | null>(null); const [draggedPiece, setDraggedPiece] = useState<string | null>(null);
const [showHint, setShowHint] = useState(false);
const [selectedPieces, setSelectedPieces] = useState<string[]>([]); const [selectedPieces, setSelectedPieces] = useState<string[]>([]);
const [showHint, setShowHint] = useState(false);
const [hintMessage, setHintMessage] = useState('');
const [hoveredDropZone, setHoveredDropZone] = useState<string | null>(null); // Add hover state
const puzzleRef = useRef<HTMLDivElement>(null); const puzzleRef = useRef<HTMLDivElement>(null);
// Initialize puzzle // Initialize puzzle
useEffect(() => { useEffect(() => {
if (question.puzzleData) { if (question.puzzleData && !localPuzzleState) {
const savedState = state.puzzleStates?.[questionIndex]; const savedState = state.puzzleStates?.[questionIndex];
if (savedState) { if (savedState) {
setLocalPuzzleState(savedState); setLocalPuzzleState(savedState);
@ -122,6 +118,18 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
})); }));
} }
break; break;
case 'slider':
if (question.puzzleData.sliders) {
pieces = question.puzzleData.sliders.map((slider, index) => ({
id: slider.id,
content: slider.label,
correctPosition: slider.correctValue,
currentPosition: Math.floor((slider.minValue + slider.maxValue) / 2), // Start at middle value
isLocked: false
}));
}
break;
} }
const newState: PuzzleState = { const newState: PuzzleState = {
@ -148,7 +156,9 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
// Handle piece movement // Handle piece movement
const movePiece = (fromIndex: number, toIndex: number) => { const movePiece = (fromIndex: number, toIndex: number) => {
const newPieces = [...puzzleState.pieces]; if (!localPuzzleState) return;
const newPieces = [...localPuzzleState.pieces];
const piece = newPieces[fromIndex]; const piece = newPieces[fromIndex];
if (piece.isLocked) return; if (piece.isLocked) return;
@ -163,10 +173,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
const isComplete = checkCompletion(newPieces); const isComplete = checkCompletion(newPieces);
const newState: PuzzleState = { const newState: PuzzleState = {
...puzzleState, ...localPuzzleState,
pieces: newPieces, pieces: newPieces,
isComplete, isComplete,
moves: puzzleState.moves + 1 moves: localPuzzleState.moves + 1
}; };
setLocalPuzzleState(newState); setLocalPuzzleState(newState);
@ -176,32 +186,54 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
// Check if puzzle is complete // Check if puzzle is complete
const checkCompletion = (pieces: PuzzlePiece[]): boolean => { const checkCompletion = (pieces: PuzzlePiece[]): boolean => {
const puzzleType = question.puzzleData?.type;
if (puzzleType === 'slider') {
const sliders = question.puzzleData?.sliders || [];
return pieces.every(piece => {
const slider = sliders.find(s => s.id === piece.id);
if (!slider) return false;
// Ensure both positions are numbers for arithmetic operations
const currentPos = typeof piece.currentPosition === 'number' ? piece.currentPosition : 0;
const correctPos = typeof piece.correctPosition === 'number' ? piece.correctPosition : 0;
// Allow tolerance of ±1 for correct value
const tolerance = 1;
return Math.abs(currentPos - correctPos) <= tolerance;
});
}
return pieces.every(piece => piece.currentPosition === piece.correctPosition); return pieces.every(piece => piece.currentPosition === piece.correctPosition);
}; };
// Handle drag start // Handle drag start
const handleDragStart = (e: React.DragEvent, pieceId: string) => { const handleDragStart = (e: React.DragEvent, pieceId: string) => {
console.log('🚀 DRAG START:', {
pieceId,
draggedPiece: draggedPiece,
localPuzzleState: localPuzzleState?.pieces?.length || 0,
targetAreas: question.puzzleData?.targetAreas?.length || 0
});
setDraggedPiece(pieceId); setDraggedPiece(pieceId);
e.dataTransfer.setData('text/plain', pieceId); e.dataTransfer.setData('text/plain', pieceId);
}; e.dataTransfer.effectAllowed = 'move';
// Handle drag over
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
}; };
// Handle drop in sequence puzzle // Handle drop in sequence puzzle
const handleDropInSequence = (e: React.DragEvent, targetPosition: number) => { const handleDropInSequence = (e: React.DragEvent, targetPosition: number) => {
e.preventDefault(); e.preventDefault();
if (!localPuzzleState) return;
const pieceId = e.dataTransfer.getData('text/plain'); const pieceId = e.dataTransfer.getData('text/plain');
const piece = puzzleState.pieces.find(p => p.id === pieceId); const piece = localPuzzleState.pieces.find(p => p.id === pieceId);
if (!piece || piece.isLocked) return; if (!piece || piece.isLocked) return;
let newPieces = [...puzzleState.pieces]; let newPieces = [...localPuzzleState.pieces];
// Find if there's already a piece in the target position // Find if there's already a piece in the target position
const existingPiece = puzzleState.pieces.find(p => p.correctPosition === targetPosition); const existingPiece = localPuzzleState.pieces.find(p => p.correctPosition === targetPosition);
if (existingPiece && existingPiece.currentPosition === targetPosition) { if (existingPiece && existingPiece.currentPosition === targetPosition) {
// Swap positions // Swap positions
@ -219,10 +251,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
const isComplete = checkCompletion(newPieces); const isComplete = checkCompletion(newPieces);
const newState: PuzzleState = { const newState: PuzzleState = {
...puzzleState, ...localPuzzleState,
pieces: newPieces, pieces: newPieces,
isComplete, isComplete,
moves: puzzleState.moves + 1 moves: localPuzzleState.moves + 1
}; };
setLocalPuzzleState(newState); setLocalPuzzleState(newState);
@ -234,22 +266,46 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
// Handle drop in matching puzzle // Handle drop in matching puzzle
const handleDropInMatching = (e: React.DragEvent, targetPosition: number) => { const handleDropInMatching = (e: React.DragEvent, targetPosition: number) => {
e.preventDefault(); e.preventDefault();
setHoveredDropZone(null); // Clear hover state
if (!localPuzzleState) return;
const pieceId = e.dataTransfer.getData('text/plain'); const pieceId = e.dataTransfer.getData('text/plain');
const piece = puzzleState.pieces.find(p => p.id === pieceId); const piece = localPuzzleState.pieces.find(p => p.id === pieceId);
if (!piece || piece.isLocked) return; if (!piece || piece.isLocked) return;
const newPieces = [...puzzleState.pieces]; // Get the target area ID from puzzleData
const targetAreaId = question.puzzleData?.targetAreas?.[targetPosition]?.id;
if (!targetAreaId) return;
const newPieces = [...localPuzzleState.pieces];
const pieceIndex = newPieces.findIndex(p => p.id === pieceId); const pieceIndex = newPieces.findIndex(p => p.id === pieceId);
newPieces[pieceIndex].currentPosition = targetPosition;
// Clear any previous placement of this piece in other positions
newPieces.forEach((p, idx) => {
if (p.id === pieceId) {
// Reset this piece's position
p.currentPosition = p.id; // Reset to original position (piece ID)
}
});
// Clear any other piece that might be in this target position
newPieces.forEach((p, idx) => {
if (p.currentPosition === targetAreaId && p.id !== pieceId) {
p.currentPosition = p.id; // Reset to original position
}
});
// Set the new position using the target area ID
newPieces[pieceIndex].currentPosition = targetAreaId;
const isComplete = checkCompletion(newPieces); const isComplete = checkCompletion(newPieces);
const newState: PuzzleState = { const newState: PuzzleState = {
...puzzleState, ...localPuzzleState,
pieces: newPieces, pieces: newPieces,
isComplete, isComplete,
moves: puzzleState.moves + 1 moves: localPuzzleState.moves + 1
}; };
setLocalPuzzleState(newState); setLocalPuzzleState(newState);
@ -258,11 +314,39 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
setDraggedPiece(null); setDraggedPiece(null);
}; };
// Handle drag over with hover state
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
// Handle drag enter for hover effect
const handleDragEnter = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
if (draggedPiece) {
setHoveredDropZone(targetId);
}
};
// Handle drag leave for hover effect
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Only clear hover if we're actually leaving the drop zone
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setHoveredDropZone(null);
}
};
// Handle drop // Handle drop
const handleDrop = (e: React.DragEvent, targetIndex: number) => { const handleDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault(); e.preventDefault();
if (!localPuzzleState) return;
const pieceId = e.dataTransfer.getData('text/plain'); const pieceId = e.dataTransfer.getData('text/plain');
const sourceIndex = puzzleState.pieces.findIndex(p => p.id === pieceId); const sourceIndex = localPuzzleState.pieces.findIndex(p => p.id === pieceId);
if (sourceIndex !== -1 && sourceIndex !== targetIndex) { if (sourceIndex !== -1 && sourceIndex !== targetIndex) {
movePiece(sourceIndex, targetIndex); movePiece(sourceIndex, targetIndex);
@ -273,11 +357,13 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
// Handle piece selection (for touch devices) // Handle piece selection (for touch devices)
const handlePieceSelection = (pieceId: string) => { const handlePieceSelection = (pieceId: string) => {
if (!localPuzzleState) return;
if (selectedPieces.length === 0) { if (selectedPieces.length === 0) {
setSelectedPieces([pieceId]); setSelectedPieces([pieceId]);
} else if (selectedPieces.length === 1) { } else if (selectedPieces.length === 1) {
const firstPieceIndex = puzzleState.pieces.findIndex(p => p.id === selectedPieces[0]); const firstPieceIndex = localPuzzleState.pieces.findIndex(p => p.id === selectedPieces[0]);
const secondPieceIndex = puzzleState.pieces.findIndex(p => p.id === pieceId); const secondPieceIndex = localPuzzleState.pieces.findIndex(p => p.id === pieceId);
if (firstPieceIndex !== secondPieceIndex) { if (firstPieceIndex !== secondPieceIndex) {
movePiece(firstPieceIndex, secondPieceIndex); movePiece(firstPieceIndex, secondPieceIndex);
@ -294,20 +380,22 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
// Shuffle puzzle // Shuffle puzzle
const shufflePuzzle = () => { const shufflePuzzle = () => {
const unlockedPieces = puzzleState.pieces.filter(p => !p.isLocked); if (!localPuzzleState) return;
const unlockedPieces = localPuzzleState.pieces.filter(p => !p.isLocked);
const shuffledPositions = shuffleArray(unlockedPieces.map(p => p.currentPosition)); const shuffledPositions = shuffleArray(unlockedPieces.map(p => p.currentPosition));
const newPieces = [...puzzleState.pieces]; const newPieces = [...localPuzzleState.pieces];
unlockedPieces.forEach((piece, index) => { unlockedPieces.forEach((piece, index) => {
const pieceIndex = newPieces.findIndex(p => p.id === piece.id); const pieceIndex = newPieces.findIndex(p => p.id === piece.id);
newPieces[pieceIndex].currentPosition = shuffledPositions[index]; newPieces[pieceIndex].currentPosition = shuffledPositions[index];
}); });
const newState: PuzzleState = { const newState: PuzzleState = {
...puzzleState, ...localPuzzleState,
pieces: newPieces, pieces: newPieces,
isComplete: false, isComplete: false,
moves: puzzleState.moves + 1 moves: localPuzzleState.moves + 1
}; };
setLocalPuzzleState(newState); setLocalPuzzleState(newState);
@ -316,23 +404,26 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
// Show hint // Show hint
const showPuzzleHint = () => { const showPuzzleHint = () => {
if (puzzleState.hints >= 3) return; // Maksimal 3 hint if (!localPuzzleState || localPuzzleState.hints >= 3) return; // Maksimal 3 hint
const incorrectPieces = puzzleState.pieces.filter( const incorrectPieces = localPuzzleState.pieces.filter(
piece => piece.currentPosition !== piece.correctPosition && !piece.isLocked piece => piece.currentPosition !== piece.correctPosition && !piece.isLocked
); );
if (incorrectPieces.length > 0) { if (incorrectPieces.length > 0) {
const randomPiece = incorrectPieces[Math.floor(Math.random() * incorrectPieces.length)]; const randomPiece = incorrectPieces[Math.floor(Math.random() * incorrectPieces.length)];
const correctIndex = puzzleState.pieces.findIndex(p => p.correctPosition === randomPiece.correctPosition); const correctIndex = localPuzzleState.pieces.findIndex(p => p.correctPosition === randomPiece.correctPosition);
if (correctIndex !== -1) { if (correctIndex !== -1) {
movePiece(puzzleState.pieces.findIndex(p => p.id === randomPiece.id), correctIndex); movePiece(localPuzzleState.pieces.findIndex(p => p.id === randomPiece.id), correctIndex);
setLocalPuzzleState(prev => ({ setLocalPuzzleState(prev => {
...prev, if (!prev) return null;
hints: prev.hints + 1 return {
})); ...prev,
hints: prev.hints + 1
};
});
} }
} }
}; };
@ -359,10 +450,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
isDragging && 'opacity-30 scale-110 rotate-2 shadow-2xl z-10', isDragging && 'opacity-30 scale-110 rotate-2 shadow-2xl z-10',
isSelected && 'ring-4 ring-blue-500 ring-offset-2 shadow-lg scale-105', isSelected && 'ring-4 ring-blue-500 ring-offset-2 shadow-lg scale-105',
isDropTarget && 'border-dashed border-blue-500 bg-blue-50 scale-105', isDropTarget && 'border-dashed border-blue-500 bg-blue-50 scale-105',
isCorrect && puzzleState.isComplete && 'bg-green-100 border-green-500 shadow-md', isCorrect && localPuzzleState?.isComplete && 'bg-green-100 border-green-500 shadow-md',
piece.isLocked && 'bg-gray-100 border-gray-300 cursor-not-allowed opacity-60', piece.isLocked && 'bg-gray-100 border-gray-300 cursor-not-allowed opacity-60',
!piece.isLocked && !isCorrect && 'bg-white border-gray-300 hover:border-blue-400 hover:shadow-lg hover:bg-blue-50', !piece.isLocked && !isCorrect && 'bg-white border-gray-300 hover:border-blue-400 hover:shadow-lg hover:bg-blue-50',
!piece.isLocked && isCorrect && !puzzleState.isComplete && 'bg-green-50 border-green-400 shadow-sm' !piece.isLocked && isCorrect && !localPuzzleState?.isComplete && 'bg-green-50 border-green-400 shadow-sm'
)} )}
style={{ style={{
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
@ -404,7 +495,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
)} )}
{/* Correct indicator */} {/* Correct indicator */}
{isCorrect && puzzleState.isComplete && ( {isCorrect && localPuzzleState?.isComplete && (
<div className="absolute top-1 right-1 bg-green-500 rounded-full p-1 animate-pulse"> <div className="absolute top-1 right-1 bg-green-500 rounded-full p-1 animate-pulse">
<Check size={12} className="text-white" /> <Check size={12} className="text-white" />
</div> </div>
@ -450,17 +541,29 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
// Render puzzle grid with proper drop zones // Render puzzle grid with proper drop zones
const renderPuzzleGrid = () => { const renderPuzzleGrid = () => {
if (!localPuzzleState) return null;
const puzzleType = question.puzzleData?.type; const puzzleType = question.puzzleData?.type;
const contentPuzzleType = (question.content as any)?.puzzleType;
console.log('🎯 PUZZLE TYPE CHECK:', {
puzzleDataType: puzzleType,
contentPuzzleType: contentPuzzleType,
hasTargetAreas: !!question.puzzleData?.targetAreas?.length,
targetAreasCount: question.puzzleData?.targetAreas?.length || 0
});
if (puzzleType === 'sequence') { if (puzzleType === 'sequence') {
return renderSequencePuzzle(); return renderSequencePuzzle();
} else if (puzzleType === 'jigsaw') { } else if (puzzleType === 'jigsaw' || contentPuzzleType === 'matching') {
return renderMatchingPuzzle(); return renderMatchingPuzzle();
} else if (puzzleType === 'slider') {
return renderSliderPuzzle();
} }
// Fallback to original grid // Fallback to original grid
const gridCols = question.puzzleData?.gridSize?.cols || Math.ceil(Math.sqrt(puzzleState.pieces.length)); const gridCols = question.puzzleData?.gridSize?.cols || Math.ceil(Math.sqrt(localPuzzleState.pieces.length));
const gridRows = question.puzzleData?.gridSize?.rows || Math.ceil(puzzleState.pieces.length / gridCols); const gridRows = question.puzzleData?.gridSize?.rows || Math.ceil(localPuzzleState.pieces.length / gridCols);
return ( return (
<div <div
@ -471,14 +574,20 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
gridTemplateRows: `repeat(${gridRows}, 1fr)` gridTemplateRows: `repeat(${gridRows}, 1fr)`
}} }}
> >
{puzzleState.pieces.map((piece, index) => renderPuzzlePiece(piece, index))} {localPuzzleState.pieces.map((piece, index) => renderPuzzlePiece(piece, index))}
</div> </div>
); );
}; };
// Render sequence puzzle with clear drop zones // Render sequence puzzle with clear drop zones
const renderSequencePuzzle = () => { const renderSequencePuzzle = () => {
const sortedPieces = [...puzzleState.pieces].sort((a, b) => a.currentPosition - b.currentPosition); if (!localPuzzleState) return null;
const sortedPieces = [...localPuzzleState.pieces].sort((a, b) => {
const aPos = typeof a.currentPosition === 'number' ? a.currentPosition : 0;
const bPos = typeof b.currentPosition === 'number' ? b.currentPosition : 0;
return aPos - bPos;
});
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -519,9 +628,9 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
<Target className="mr-2" size={16} /> <Target className="mr-2" size={16} />
Urutan yang benar: Urutan yang benar:
</h4> </h4>
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${puzzleState.pieces.length}, 1fr)` }}> <div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${localPuzzleState.pieces.length}, 1fr)` }}>
{Array.from({ length: puzzleState.pieces.length }, (_, index) => { {Array.from({ length: localPuzzleState.pieces.length }, (_, index) => {
const pieceInPosition = puzzleState.pieces.find(p => p.correctPosition === index); const pieceInPosition = localPuzzleState.pieces.find(p => p.correctPosition === index);
const isCorrectlyPlaced = pieceInPosition && pieceInPosition.currentPosition === index; const isCorrectlyPlaced = pieceInPosition && pieceInPosition.currentPosition === index;
return ( return (
@ -565,12 +674,28 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
// Render matching puzzle with clear areas // Render matching puzzle with clear areas
const renderMatchingPuzzle = () => { const renderMatchingPuzzle = () => {
const items = puzzleState.pieces; if (!localPuzzleState) return null;
const definitions = question.puzzleData?.pieces?.map((_, index) => ({
id: index, const items = localPuzzleState.pieces;
label: `Definisi ${index + 1}`, const targetAreas = question.puzzleData?.targetAreas || [];
correctPieceId: question.puzzleData?.pieces?.[index]?.id
})) || []; console.log('🎮 RENDER MATCHING PUZZLE:', {
questionId: question.id,
questionContent: question.content,
itemsCount: items?.length || 0,
targetAreasCount: targetAreas?.length || 0,
items: items?.map(item => ({
id: item.id,
content: item.content,
correctPosition: item.correctPosition,
currentPosition: item.currentPosition,
isLocked: item.isLocked
})),
targetAreas: targetAreas?.map(area => ({ id: area.id, label: area.label })),
draggedPiece,
hoveredDropZone,
fullPuzzleData: question.puzzleData
});
return ( return (
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
@ -608,31 +733,103 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
<Target className="mr-2" size={16} /> <Target className="mr-2" size={16} />
Cocokkan dengan definisi: Cocokkan dengan definisi:
</h4> </h4>
<div className="space-y-3"> <div className="space-y-6">
{definitions.map((def, index) => { {targetAreas.map((targetArea, index) => {
const matchedPiece = items.find(p => p.correctPosition === index && p.currentPosition === index); console.log('🎯 RENDERING TARGET AREA:', {
const isCorrect = matchedPiece !== undefined; targetArea: targetArea.id,
index,
label: targetArea.label,
hoveredDropZone,
isHovered: hoveredDropZone === targetArea.id
});
const targetAreaId = targetArea.id;
const matchedPiece = items.find(p => p.currentPosition === targetAreaId);
const isCorrect = matchedPiece && matchedPiece.correctPosition === targetAreaId;
const isHovered = hoveredDropZone === targetAreaId;
return ( return (
<div <div
key={def.id} key={targetAreaId}
onDragOver={handleDragOver} onDragOver={(e) => {
onDrop={(e) => handleDropInMatching(e, index)} e.preventDefault();
e.stopPropagation();
console.log('📋 DRAG OVER:', { targetAreaId, draggedPiece, hoveredDropZone });
}}
onDragEnter={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('📥 DRAG ENTER:', { targetAreaId, draggedPiece });
if (draggedPiece) {
setHoveredDropZone(targetAreaId);
console.log('✅ SET HOVERED ZONE:', targetAreaId);
}
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('📤 DRAG LEAVE:', { targetAreaId, hoveredDropZone });
// Check if we're really leaving the drop zone
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setHoveredDropZone(null);
console.log('❌ CLEAR HOVERED ZONE');
}
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('🎯 DROP EVENT:', {
targetAreaId,
index,
draggedPiece,
dataTransfer: e.dataTransfer.getData('text/plain')
});
handleDropInMatching(e, index);
setHoveredDropZone(null);
}}
onClick={() => {
// Handle click-to-place for mobile devices
if (selectedPieces.length === 1) {
const selectedPieceId = selectedPieces[0];
const piece = items.find(p => p.id === selectedPieceId);
if (piece && !piece.isLocked) {
// Simulate drop event
const fakeEvent = {
preventDefault: () => {},
stopPropagation: () => {},
dataTransfer: {
getData: () => selectedPieceId
}
} as unknown as React.DragEvent;
console.log('Click-to-place triggered for:', selectedPieceId, 'to target:', targetAreaId);
handleDropInMatching(fakeEvent, index);
setSelectedPieces([]); // Clear selection
}
}
}}
className={cn( className={cn(
'min-h-16 p-3 border-2 border-dashed rounded-lg transition-all duration-200', 'min-h-20 p-4 border-3 border-dashed rounded-xl transition-all duration-300',
'flex items-center justify-between relative', 'flex items-center justify-between relative cursor-pointer',
draggedPiece && 'border-green-400 bg-green-100 scale-102', 'bg-white shadow-lg hover:shadow-xl',
isCorrect && 'bg-green-100 border-green-400 border-solid', // Hover state when dragging
!isCorrect && !draggedPiece && 'border-gray-300 bg-gray-50', isHovered && draggedPiece && 'border-green-500 bg-green-100 scale-105 shadow-2xl ring-2 ring-green-300',
!isCorrect && draggedPiece && 'hover:border-green-500 hover:bg-green-100' // Correct match state
isCorrect && 'bg-green-100 border-green-500 border-solid shadow-xl ring-2 ring-green-200',
// Default state
!isCorrect && !draggedPiece && selectedPieces.length === 0 && 'border-gray-500 bg-gray-50 hover:border-blue-400 hover:bg-blue-50',
// Dragging but not hovering this zone
!isCorrect && draggedPiece && !isHovered && 'border-gray-400 bg-gray-100 opacity-75',
// Selected piece ready to place
!isCorrect && selectedPieces.length === 1 && 'border-blue-500 bg-blue-100 hover:border-blue-600 hover:bg-blue-200 shadow-xl ring-2 ring-blue-300 animate-pulse'
)} )}
> >
{/* Definition label */} {/* Definition label */}
<div className="flex-1"> <div className="flex-1">
<div className="text-xs text-gray-600 mb-1">Definisi {index + 1}:</div> <div className="text-xs text-gray-600 mb-1">Target {index + 1}:</div>
<div className="text-sm text-gray-800"> <div className="text-sm text-gray-800">
{/* You can add actual definitions here based on your data structure */} {targetArea.content || targetArea.label || `Target area ${index + 1}`}
Definisi untuk item {index + 1}
</div> </div>
</div> </div>
@ -644,10 +841,28 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
<Check size={16} className="text-green-600" /> <Check size={16} className="text-green-600" />
</div> </div>
) : ( ) : (
<div className="p-2 border-2 border-dashed border-gray-400 rounded text-center"> <div className={cn(
<div className="text-xs text-gray-500"> "p-4 border-2 border-dashed rounded-xl text-center transition-all duration-300 min-h-16",
{draggedPiece ? 'Lepas di sini' : 'Kosong'} "bg-gradient-to-br from-white to-gray-50 shadow-inner",
isHovered && draggedPiece ? "border-green-500 bg-gradient-to-br from-green-50 to-green-100 shadow-lg animate-bounce" :
selectedPieces.length === 1 ? "border-blue-500 bg-gradient-to-br from-blue-50 to-blue-100 shadow-lg animate-pulse" :
"border-gray-500 bg-gradient-to-br from-gray-50 to-gray-100 hover:border-blue-400 hover:from-blue-50 hover:to-blue-100"
)}>
<div className="text-sm font-bold text-gray-700 mb-1">
{isHovered && draggedPiece ? '🎯 LEPAS DI SINI!' :
selectedPieces.length === 1 ? '👆 KLIK UNTUK MENEMPATKAN' :
'📋 AREA DROP'}
</div> </div>
<div className="text-xs text-gray-500">
{isHovered && draggedPiece ? 'Lepaskan item yang sedang diseret' :
selectedPieces.length === 1 ? 'Klik untuk menempatkan item terpilih' :
'Seret item ke sini atau klik setelah memilih'}
</div>
{matchedPiece && !isCorrect && (
<div className="text-sm text-orange-700 mt-2 font-medium bg-orange-100 p-2 rounded border border-orange-300">
{matchedPiece.content}
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -660,9 +875,125 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
); );
}; };
// Render slider puzzle
const renderSliderPuzzle = () => {
if (!localPuzzleState) return null;
const sliders = question.puzzleData?.sliders || [];
const handleSliderChange = (sliderId: string, value: number) => {
const newPieces = [...localPuzzleState.pieces];
const pieceIndex = newPieces.findIndex(p => p.id === sliderId);
if (pieceIndex !== -1) {
newPieces[pieceIndex].currentPosition = value;
const isComplete = checkSliderCompletion(newPieces);
const newState: PuzzleState = {
...localPuzzleState,
pieces: newPieces,
isComplete,
moves: localPuzzleState.moves + 1
};
setLocalPuzzleState(newState);
setPuzzleState(questionIndex.toString(), newState);
onAnswerChange(newState);
}
};
const checkSliderCompletion = (pieces: PuzzlePiece[]) => {
return pieces.every(piece => {
const slider = sliders.find(s => s.id === piece.id);
if (!slider) return false;
// Ensure both positions are numbers for arithmetic operations
const currentPos = typeof piece.currentPosition === 'number' ? piece.currentPosition : 0;
const correctPos = typeof piece.correctPosition === 'number' ? piece.correctPosition : 0;
// Allow tolerance of ±1 for correct value
const tolerance = 1;
return Math.abs(currentPos - correctPos) <= tolerance;
});
};
return (
<div className="space-y-6">
<div className="p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
<h4 className="text-sm font-semibold text-blue-900 mb-4 flex items-center">
<Target className="mr-2" size={16} />
Atur nilai yang tepat untuk setiap parameter:
</h4>
<div className="space-y-6">
{sliders.map((slider, index) => {
const piece = localPuzzleState.pieces.find(p => p.id === slider.id);
const currentValue = typeof piece?.currentPosition === 'number' ? piece.currentPosition : slider.minValue;
const isCorrect = piece && Math.abs(currentValue - slider.correctValue) <= 1;
return (
<div key={slider.id} className="space-y-3">
<div className="flex justify-between items-center">
<label className="text-sm font-medium text-gray-900">
{slider.label}
</label>
<div className={cn(
"px-3 py-1 rounded-full text-sm font-bold",
isCorrect ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
)}>
{currentValue}{slider.unit}
</div>
</div>
<div className="relative">
<input
type="range"
min={slider.minValue}
max={slider.maxValue}
value={currentValue}
onChange={(e) => handleSliderChange(slider.id, parseInt(e.target.value))}
className={cn(
"w-full h-2 rounded-lg appearance-none cursor-pointer",
"bg-gray-200 slider-thumb",
isCorrect ? "accent-green-500" : "accent-blue-500"
)}
style={{
background: `linear-gradient(to right, ${isCorrect ? '#10b981' : '#3b82f6'} 0%, ${isCorrect ? '#10b981' : '#3b82f6'} ${((currentValue - slider.minValue) / (slider.maxValue - slider.minValue)) * 100}%, #e5e7eb ${((currentValue - slider.minValue) / (slider.maxValue - slider.minValue)) * 100}%, #e5e7eb 100%)`
}}
/>
{/* Range labels */}
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{slider.minValue}{slider.unit}</span>
<span className="text-green-600 font-medium">
Target: {slider.correctValue}{slider.unit}
</span>
<span>{slider.maxValue}{slider.unit}</span>
</div>
</div>
{/* Correctness indicator */}
{isCorrect && (
<div className="flex items-center space-x-2 text-green-600">
<Check size={16} />
<span className="text-sm font-medium">Nilai sudah tepat!</span>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};
// Render puzzle stats // Render puzzle stats
const renderPuzzleStats = () => { const renderPuzzleStats = () => {
const timeElapsed = Math.floor((Date.now() - puzzleState.startTime) / 1000); if (!localPuzzleState) return null;
const timeElapsed = Math.floor((Date.now() - localPuzzleState.startTime) / 1000);
const minutes = Math.floor(timeElapsed / 60); const minutes = Math.floor(timeElapsed / 60);
const seconds = timeElapsed % 60; const seconds = timeElapsed % 60;
@ -676,19 +1007,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Target size={16} /> <Target size={16} />
<span>{puzzleState.moves} {adminLanguage === 'id' ? 'gerakan' : 'moves'}</span> <span>{localPuzzleState.moves} {adminLanguage === 'id' ? 'gerakan' : 'moves'}</span>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Lightbulb size={16} /> <Lightbulb size={16} />
<span>{puzzleState.hints}/3 {adminLanguage === 'id' ? 'petunjuk' : 'hints'}</span> <span>{localPuzzleState.hints}/3 {adminLanguage === 'id' ? 'petunjuk' : 'hints'}</span>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <button
onClick={showPuzzleHint} onClick={showPuzzleHint}
disabled={puzzleState.hints >= 3 || puzzleState.isComplete} disabled={localPuzzleState.hints >= 3 || localPuzzleState.isComplete}
className="px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{adminLanguage === 'id' ? 'Petunjuk' : 'Hint'} {adminLanguage === 'id' ? 'Petunjuk' : 'Hint'}
@ -696,7 +1027,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
<button <button
onClick={shufflePuzzle} onClick={shufflePuzzle}
disabled={puzzleState.isComplete} disabled={localPuzzleState.isComplete}
className="px-3 py-1 text-xs bg-blue-100 text-blue-800 rounded hover:bg-blue-200 disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 text-xs bg-blue-100 text-blue-800 rounded hover:bg-blue-200 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Shuffle size={12} /> <Shuffle size={12} />
@ -722,10 +1053,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
); );
} }
if (!localPuzzleState) {
return (
<div className="p-6 text-center text-gray-500">
<Puzzle className="mx-auto mb-2" size={48} />
<p>{adminLanguage === 'id' ? 'Memuat puzzle...' : 'Loading puzzle...'}</p>
</div>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Tutorial untuk pertama kali */} {/* Tutorial untuk pertama kali */}
{puzzleState.moves === 0 && ( {localPuzzleState.moves === 0 && (
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg"> <div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -769,19 +1109,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
)} )}
{/* Progress indicator */} {/* Progress indicator */}
{puzzleState.pieces.length > 0 && ( {localPuzzleState && localPuzzleState.pieces.length > 0 && (
<div className="bg-white border border-gray-200 rounded-lg p-3"> <div className="bg-white border border-gray-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Progress</span> <span className="text-sm font-medium text-gray-700">Progress</span>
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{puzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length} / {puzzleState.pieces.length} {localPuzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length} / {localPuzzleState.pieces.length}
</span> </span>
</div> </div>
<div className="w-full bg-gray-200 rounded-full h-2"> <div className="w-full bg-gray-200 rounded-full h-2">
<div <div
className="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500" className="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500"
style={{ style={{
width: `${(puzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length / puzzleState.pieces.length) * 100}%` width: `${(localPuzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length / localPuzzleState.pieces.length) * 100}%`
}} }}
></div> ></div>
</div> </div>
@ -795,7 +1135,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
{renderPuzzleGrid()} {renderPuzzleGrid()}
{/* Completion message */} {/* Completion message */}
{puzzleState.isComplete && ( {localPuzzleState?.isComplete && (
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg"> <div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -808,7 +1148,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
🎉 Selamat! Puzzle berhasil diselesaikan! 🎉 Selamat! Puzzle berhasil diselesaikan!
</p> </p>
<p className="text-sm text-green-700 mt-1"> <p className="text-sm text-green-700 mt-1">
Diselesaikan dalam <strong>{puzzleState.moves} gerakan</strong> dengan <strong>{puzzleState.hints} petunjuk</strong>. Diselesaikan dalam <strong>{localPuzzleState.moves} gerakan</strong> dengan <strong>{localPuzzleState.hints} petunjuk</strong>.
</p> </p>
</div> </div>
</div> </div>

View File

@ -19,14 +19,10 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
}) => { }) => {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const isIndonesian = adminLanguage === 'id';
const steps = [ const steps = [
{ {
title: isIndonesian ? 'Selamat Datang di Simulasi Skenario!' : 'Welcome to Scenario Simulation!', title: 'Selamat Datang di Simulasi Skenario!',
content: isIndonesian content: 'Anda akan menghadapi situasi nyata di dapur dan harus membuat keputusan yang tepat. Setiap pilihan akan mempengaruhi skor dan kondisi dapur Anda.',
? 'Anda akan menghadapi situasi nyata di dapur dan harus membuat keputusan yang tepat. Setiap pilihan akan mempengaruhi skor dan kondisi dapur Anda.'
: 'You will face real kitchen situations and must make the right decisions. Each choice will affect your score and kitchen conditions.',
icon: <ChefHat className="text-blue-600" size={48} />, icon: <ChefHat className="text-blue-600" size={48} />,
visual: ( visual: (
<div className="bg-gradient-to-br from-blue-100 to-indigo-100 p-6 rounded-lg"> <div className="bg-gradient-to-br from-blue-100 to-indigo-100 p-6 rounded-lg">
@ -43,82 +39,76 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
) )
}, },
{ {
title: isIndonesian ? 'Cara Bermain' : 'How to Play', title: 'Cara Bermain',
content: isIndonesian content: 'Baca situasi dengan cermat, pilih tindakan terbaik dari opsi yang tersedia, lalu klik "Jalankan Tindakan" untuk melihat hasilnya.',
? 'Baca situasi dengan cermat, pilih tindakan terbaik dari opsi yang tersedia, lalu klik "Jalankan Tindakan" untuk melihat hasilnya.'
: 'Read the situation carefully, choose the best action from available options, then click "Execute Action" to see the results.',
icon: <Play className="text-green-600" size={48} />, icon: <Play className="text-green-600" size={48} />,
visual: ( visual: (
<div className="space-y-3"> <div className="space-y-3">
<div className="p-3 bg-blue-50 border-l-4 border-blue-400 rounded-r"> <div className="p-3 bg-blue-50 border-l-4 border-blue-400 rounded-r">
<p className="text-sm text-blue-800"> <p className="text-sm text-blue-800">
{isIndonesian ? '📖 1. Baca situasi' : '📖 1. Read situation'} 📖 1. Baca situasi
</p> </p>
</div> </div>
<div className="p-3 bg-yellow-50 border-l-4 border-yellow-400 rounded-r"> <div className="p-3 bg-yellow-50 border-l-4 border-yellow-400 rounded-r">
<p className="text-sm text-yellow-800"> <p className="text-sm text-yellow-800">
{isIndonesian ? '🎯 2. Pilih tindakan' : '🎯 2. Choose action'} 🎯 2. Pilih tindakan
</p> </p>
</div> </div>
<div className="p-3 bg-green-50 border-l-4 border-green-400 rounded-r"> <div className="p-3 bg-green-50 border-l-4 border-green-400 rounded-r">
<p className="text-sm text-green-800"> <p className="text-sm text-green-800">
{isIndonesian ? '⚡ 3. Jalankan & lihat hasil' : '⚡ 3. Execute & see results'} 3. Jalankan & lihat hasil
</p> </p>
</div> </div>
</div> </div>
) )
}, },
{ {
title: isIndonesian ? 'Memahami Indikator' : 'Understanding Indicators', title: 'Memahami Indikator',
content: isIndonesian content: 'Perhatikan skor, waktu, kepuasan pelanggan, dan efisiensi. Indikator ini menunjukkan performa Anda dalam mengelola dapur.',
? 'Perhatikan skor, waktu, kepuasan pelanggan, dan efisiensi. Indikator ini menunjukkan performa Anda dalam mengelola dapur.'
: 'Pay attention to score, time, customer satisfaction, and efficiency. These indicators show your performance in managing the kitchen.',
icon: <Target className="text-purple-600" size={48} />, icon: <Target className="text-purple-600" size={48} />,
visual: ( visual: (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-red-50 rounded-lg text-center"> <div className="p-3 bg-red-50 rounded-lg text-center">
<div className="text-2xl font-bold text-red-600">85</div> <div className="text-2xl font-bold text-red-600">85</div>
<div className="text-xs text-red-700">{isIndonesian ? 'Skor' : 'Score'}</div> <div className="text-xs text-red-700">Skor</div>
</div> </div>
<div className="p-3 bg-blue-50 rounded-lg text-center"> <div className="p-3 bg-blue-50 rounded-lg text-center">
<div className="text-2xl font-bold text-blue-600">2:30</div> <div className="text-2xl font-bold text-blue-600">2:30</div>
<div className="text-xs text-blue-700">{isIndonesian ? 'Waktu' : 'Time'}</div> <div className="text-xs text-blue-700">Waktu</div>
</div> </div>
<div className="p-3 bg-green-50 rounded-lg text-center"> <div className="p-3 bg-green-50 rounded-lg text-center">
<div className="text-2xl font-bold text-green-600">92%</div> <div className="text-2xl font-bold text-green-600">92%</div>
<div className="text-xs text-green-700">{isIndonesian ? 'Kepuasan' : 'Satisfaction'}</div> <div className="text-xs text-green-700">Kepuasan</div>
</div> </div>
<div className="p-3 bg-yellow-50 rounded-lg text-center"> <div className="p-3 bg-yellow-50 rounded-lg text-center">
<div className="text-2xl font-bold text-yellow-600">78%</div> <div className="text-2xl font-bold text-yellow-600">78%</div>
<div className="text-xs text-yellow-700">{isIndonesian ? 'Efisiensi' : 'Efficiency'}</div> <div className="text-xs text-yellow-700">Efisiensi</div>
</div> </div>
</div> </div>
) )
}, },
{ {
title: isIndonesian ? 'Tips Sukses' : 'Success Tips', title: 'Tips Sukses',
content: isIndonesian content: 'Prioritaskan keamanan pangan, kelola waktu dengan baik, dan pertimbangkan dampak jangka panjang dari setiap keputusan Anda.',
? 'Prioritaskan keamanan pangan, kelola waktu dengan baik, dan pertimbangkan dampak jangka panjang dari setiap keputusan Anda.'
: 'Prioritize food safety, manage time well, and consider the long-term impact of each decision you make.',
icon: <Lightbulb className="text-yellow-600" size={48} />, icon: <Lightbulb className="text-yellow-600" size={48} />,
visual: ( visual: (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center space-x-2 p-2 bg-green-50 rounded"> <div className="flex items-center space-x-2 p-2 bg-green-50 rounded">
<CheckCircle className="text-green-600" size={16} /> <CheckCircle className="text-green-600" size={16} />
<span className="text-sm text-green-800"> <span className="text-sm text-green-800">
{isIndonesian ? 'Keamanan pangan adalah prioritas utama' : 'Food safety is top priority'} Keamanan pangan adalah prioritas utama
</span> </span>
</div> </div>
<div className="flex items-center space-x-2 p-2 bg-blue-50 rounded"> <div className="flex items-center space-x-2 p-2 bg-blue-50 rounded">
<CheckCircle className="text-blue-600" size={16} /> <CheckCircle className="text-blue-600" size={16} />
<span className="text-sm text-blue-800"> <span className="text-sm text-blue-800">
{isIndonesian ? 'Kelola waktu dengan efisien' : 'Manage time efficiently'} Kelola waktu dengan efisien
</span> </span>
</div> </div>
<div className="flex items-center space-x-2 p-2 bg-purple-50 rounded"> <div className="flex items-center space-x-2 p-2 bg-purple-50 rounded">
<CheckCircle className="text-purple-600" size={16} /> <CheckCircle className="text-purple-600" size={16} />
<span className="text-sm text-purple-800"> <span className="text-sm text-purple-800">
{isIndonesian ? 'Pikirkan dampak jangka panjang' : 'Think long-term impact'} Pikirkan dampak jangka panjang
</span> </span>
</div> </div>
</div> </div>
@ -154,7 +144,7 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
<div> <div>
<h2 className="text-xl font-bold">{currentStepData.title}</h2> <h2 className="text-xl font-bold">{currentStepData.title}</h2>
<p className="text-blue-100 text-sm"> <p className="text-blue-100 text-sm">
{isIndonesian ? 'Langkah' : 'Step'} {currentStep + 1} {isIndonesian ? 'dari' : 'of'} {steps.length} Langkah {currentStep + 1} dari {steps.length}
</p> </p>
</div> </div>
</div> </div>
@ -197,13 +187,10 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
<ChefHat className="text-orange-600 mt-1" size={20} /> <ChefHat className="text-orange-600 mt-1" size={20} />
<div> <div>
<h4 className="font-semibold text-orange-900 mb-1"> <h4 className="font-semibold text-orange-900 mb-1">
{isIndonesian ? 'Khusus Simulasi Dapur MBG' : 'MBG Kitchen Simulation'} Simulasi Dapur MBG
</h4> </h4>
<p className="text-orange-800 text-sm"> <p className="text-orange-800 text-sm">
{isIndonesian Simulasi ini dibuat khusus untuk melatih kemampuan Anda mengelola dapur dengan standar kebersihan dan keamanan makanan yang baik sesuai aturan MBG.
? 'Simulasi ini dirancang khusus untuk menguji kemampuan Anda dalam mengelola dapur sesuai standar keamanan pangan MBG.'
: 'This simulation is specifically designed to test your ability to manage a kitchen according to MBG food safety standards.'
}
</p> </p>
</div> </div>
</div> </div>
@ -223,8 +210,8 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
: 'bg-gray-300 text-gray-700 hover:bg-gray-400' : 'bg-gray-300 text-gray-700 hover:bg-gray-400'
)} )}
> >
{isIndonesian ? 'Sebelumnya' : 'Previous'} Sebelumnya
</button> </button>
<div className="flex space-x-2"> <div className="flex space-x-2">
{steps.map((_, index) => ( {steps.map((_, index) => (
@ -239,14 +226,14 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
</div> </div>
<button <button
onClick={handleNext} onClick={handleNext}
className="px-6 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-medium hover:from-blue-700 hover:to-indigo-700 transition-colors" className="px-6 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-medium hover:from-blue-700 hover:to-indigo-700 transition-colors"
> >
{currentStep === steps.length - 1 {currentStep === steps.length - 1
? (isIndonesian ? 'Mulai Simulasi' : 'Start Simulation') ? 'Mulai Bermain'
: (isIndonesian ? 'Selanjutnya' : 'Next') : 'Lanjut'
} }
</button> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,326 @@
import React, { useState, useRef, useEffect } from 'react';
import { Play, Pause, RotateCcw, Volume2, VolumeX, Maximize, Clock, AlertCircle, CheckCircle } from 'lucide-react';
import { cn } from '../../utils/cn';
interface VideoScenarioProps {
videoSrc: string;
title: string;
description: string;
scenarios: VideoScenarioStep[];
onScenarioComplete: (results: ScenarioResult[]) => void;
className?: string;
}
interface VideoScenarioStep {
id: string;
timestamp: number; // detik
title: string;
question: string;
options: VideoOption[];
correctAnswer: number;
explanation: string;
pauseVideo?: boolean;
}
interface VideoOption {
id: string;
text: string;
consequence?: string;
isCorrect?: boolean;
}
interface ScenarioResult {
stepId: string;
selectedOption: number;
isCorrect: boolean;
timestamp: number;
reactionTime: number;
}
const VideoScenario: React.FC<VideoScenarioProps> = ({
videoSrc,
title,
description,
scenarios,
onScenarioComplete,
className
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [currentScenario, setCurrentScenario] = useState<VideoScenarioStep | null>(null);
const [scenarioResults, setScenarioResults] = useState<ScenarioResult[]>([]);
const [completedScenarios, setCompletedScenarios] = useState<Set<string>>(new Set());
const [showQuestion, setShowQuestion] = useState(false);
const [questionStartTime, setQuestionStartTime] = useState(0);
// Monitor video progress
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
const time = video.currentTime;
setCurrentTime(time);
// Check for scenario triggers
const activeScenario = scenarios.find(
scenario =>
Math.abs(time - scenario.timestamp) < 0.5 &&
!completedScenarios.has(scenario.id)
);
if (activeScenario && !showQuestion) {
setCurrentScenario(activeScenario);
setShowQuestion(true);
setQuestionStartTime(Date.now());
if (activeScenario.pauseVideo) {
video.pause();
setIsPlaying(false);
}
}
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
};
}, [scenarios, completedScenarios, showQuestion]);
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
video.play();
}
setIsPlaying(!isPlaying);
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !isMuted;
setIsMuted(!isMuted);
};
const restart = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = 0;
setCurrentTime(0);
setScenarioResults([]);
setCompletedScenarios(new Set());
setCurrentScenario(null);
setShowQuestion(false);
};
const handleAnswerSelect = (optionIndex: number) => {
if (!currentScenario) return;
const reactionTime = Date.now() - questionStartTime;
const isCorrect = optionIndex === currentScenario.correctAnswer;
const result: ScenarioResult = {
stepId: currentScenario.id,
selectedOption: optionIndex,
isCorrect,
timestamp: currentTime,
reactionTime
};
const newResults = [...scenarioResults, result];
setScenarioResults(newResults);
setCompletedScenarios(prev => new Set([...Array.from(prev), currentScenario.id]));
setShowQuestion(false);
setCurrentScenario(null);
// Resume video if it was paused
const video = videoRef.current;
if (video && video.paused && currentScenario.pauseVideo) {
video.play();
setIsPlaying(true);
}
// Check if all scenarios completed
if (newResults.length === scenarios.length) {
onScenarioComplete(newResults);
}
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className={cn("bg-white rounded-lg shadow-lg overflow-hidden", className)}>
{/* Header */}
<div className="p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-blue-100 text-sm mt-1">{description}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1">
<Clock size={16} />
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<span className="flex items-center gap-1">
<CheckCircle size={16} />
{completedScenarios.size}/{scenarios.length} Scenario
</span>
</div>
</div>
{/* Video Container */}
<div className="relative bg-black">
<video
ref={videoRef}
src={videoSrc}
className="w-full aspect-video"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
{/* Video Controls Overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
{/* Progress Bar */}
<div className="mb-3">
<div className="w-full bg-white/20 rounded-full h-1">
<div
className="bg-blue-500 h-1 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
{/* Scenario Markers */}
{scenarios.map((scenario) => (
<div
key={scenario.id}
className={cn(
"absolute top-0 w-2 h-2 rounded-full transform -translate-x-1 -translate-y-1",
completedScenarios.has(scenario.id)
? "bg-green-400"
: "bg-yellow-400"
)}
style={{
left: `${duration > 0 ? (scenario.timestamp / duration) * 100 : 0}%`
}}
title={`Scenario: ${scenario.title}`}
/>
))}
</div>
{/* Control Buttons */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={togglePlay}
className="p-2 bg-white/20 hover:bg-white/30 rounded-full transition-colors"
>
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
</button>
<button
onClick={restart}
className="p-2 bg-white/20 hover:bg-white/30 rounded-full transition-colors"
>
<RotateCcw size={20} />
</button>
<button
onClick={toggleMute}
className="p-2 bg-white/20 hover:bg-white/30 rounded-full transition-colors"
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
<div className="text-white text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
{/* Question Overlay */}
{showQuestion && currentScenario && (
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-6">
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
<div className="flex items-center gap-2 mb-4">
<AlertCircle className="text-blue-600" size={24} />
<h4 className="text-lg font-semibold text-gray-800">
{currentScenario.title}
</h4>
</div>
<p className="text-gray-700 mb-6">{currentScenario.question}</p>
<div className="space-y-3">
{currentScenario.options.map((option, index) => (
<button
key={option.id}
onClick={() => handleAnswerSelect(index)}
className="w-full p-4 text-left border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors"
>
<div className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">
{String.fromCharCode(65 + index)}
</span>
<div>
<p className="font-medium text-gray-800">{option.text}</p>
{option.consequence && (
<p className="text-sm text-gray-600 mt-1">{option.consequence}</p>
)}
</div>
</div>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* Scenario Progress */}
<div className="p-4 bg-gray-50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Progress Scenario</span>
<span className="text-sm text-gray-600">
{completedScenarios.size}/{scenarios.length} selesai
</span>
</div>
<div className="flex gap-2">
{scenarios.map((scenario) => (
<div
key={scenario.id}
className={cn(
"flex-1 h-2 rounded-full",
completedScenarios.has(scenario.id)
? "bg-green-400"
: currentScenario?.id === scenario.id
? "bg-yellow-400"
: "bg-gray-200"
)}
title={scenario.title}
/>
))}
</div>
</div>
</div>
);
};
export default VideoScenario;

View File

@ -0,0 +1,101 @@
'use client';
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
resetError = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
const FallbackComponent = this.props.fallback;
return <FallbackComponent error={this.state.error} resetError={this.resetError} />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full">
<svg
className="w-6 h-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<div className="mt-4 text-center">
<h3 className="text-lg font-medium text-gray-900">
Terjadi Kesalahan
</h3>
<p className="mt-2 text-sm text-gray-500">
Maaf, terjadi kesalahan yang tidak terduga. Silakan coba lagi atau hubungi administrator.
</p>
{this.state.error && (
<details className="mt-4 text-left">
<summary className="text-sm text-gray-600 cursor-pointer">
Detail Error (untuk developer)
</summary>
<pre className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded overflow-auto">
{this.state.error.message}
{'\n'}
{this.state.error.stack}
</pre>
</details>
)}
<div className="mt-6 flex space-x-3">
<button
onClick={this.resetError}
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Coba Lagi
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 bg-gray-300 text-gray-700 px-4 py-2 rounded-md text-sm font-medium hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Refresh Halaman
</button>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -47,10 +47,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
// Public routes that don't require authentication
const publicRoutes = ['/login', '/register', '/forgot-password'];
const isPublicRoute = publicRoutes.includes(pathname);
useEffect(() => { useEffect(() => {
// Check for existing session on mount // Check for existing session on mount
const savedUser = localStorage.getItem('lms_user'); const savedUser = localStorage.getItem('lms_user');
@ -72,6 +68,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
// Redirect logic // Redirect logic
if (!isLoading) { if (!isLoading) {
// Public routes that don't require authentication
const publicRoutes = ['/login', '/register', '/forgot-password'];
const isPublicRoute = publicRoutes.includes(pathname);
if (!user && !isPublicRoute && pathname !== '/login') { if (!user && !isPublicRoute && pathname !== '/login') {
// User not logged in and trying to access protected route // User not logged in and trying to access protected route
router.push('/login'); router.push('/login');
@ -83,7 +83,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.push(dashboardPath); router.push(dashboardPath);
} }
} }
}, [user, isLoading, pathname, isPublicRoute, router]); }, [user, isLoading, pathname, router]);
const login = async (email: string, password: string): Promise<boolean> => { const login = async (email: string, password: string): Promise<boolean> => {
setIsLoading(true); setIsLoading(true);
@ -111,11 +111,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}; };
const logout = () => { const logout = () => {
setUser(null); try {
localStorage.removeItem('lms_user'); setUser(null);
// Also clear cookie
document.cookie = 'lms_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; // Safely remove from localStorage
router.push('/login'); if (typeof window !== 'undefined' && window.localStorage) {
localStorage.removeItem('lms_user');
}
// Safely clear cookie
if (typeof document !== 'undefined') {
document.cookie = 'lms_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
}
// Use setTimeout to avoid race conditions with state updates
setTimeout(() => {
router.push('/login');
}, 100);
} catch (error) {
console.error('Logout error:', error);
// Fallback: still try to redirect even if cleanup fails
router.push('/login');
}
}; };
const value = { const value = {

View File

@ -54,7 +54,7 @@ interface ExamState {
// Interactive Quiz Enhancement: Interactive Elements State // Interactive Quiz Enhancement: Interactive Elements State
interactiveState: { interactiveState: {
currentQuestionType: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection'; currentQuestionType: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection' | 'video_scenario' | 'image_hotspot' | 'media_gallery';
dragDropState: Record<string, any>; dragDropState: Record<string, any>;
puzzleState: Record<string, any>; puzzleState: Record<string, any>;
scenarioState: Record<string, any>; scenarioState: Record<string, any>;

View File

@ -21,6 +21,8 @@ export class DIContainer {
private services = new Map<string, ServiceRegistration>(); private services = new Map<string, ServiceRegistration>();
private singletons = new Map<string, any>(); private singletons = new Map<string, any>();
private resolving = new Set<string>(); private resolving = new Set<string>();
// Track pending resolutions to allow concurrent callers to await the same promise
private pendingResolutions = new Map<string, Promise<any>>();
/** /**
* Register a service with the container * Register a service with the container
@ -60,16 +62,21 @@ export class DIContainer {
* Resolve a service from the container * Resolve a service from the container
*/ */
async resolve<T>(token: string): Promise<T> { async resolve<T>(token: string): Promise<T> {
// Check for circular dependencies // Return singleton if already created (prefer fast path)
if (this.resolving.has(token)) {
throw new Error(`Circular dependency detected: ${token}`);
}
// Return singleton if already created
if (this.singletons.has(token)) { if (this.singletons.has(token)) {
return this.singletons.get(token); return this.singletons.get(token);
} }
// If a resolution is already in progress for this token, share the promise
if (this.pendingResolutions.has(token)) {
return await this.pendingResolutions.get(token) as T;
}
// Check for circular dependencies (true recursion within a single resolution chain)
if (this.resolving.has(token)) {
throw new Error(`Circular dependency detected: ${token}`);
}
const registration = this.services.get(token); const registration = this.services.get(token);
if (!registration) { if (!registration) {
throw new Error(`Service not found: ${token}`); throw new Error(`Service not found: ${token}`);
@ -77,32 +84,40 @@ export class DIContainer {
this.resolving.add(token); this.resolving.add(token);
try { // Create a shared promise for this resolution so concurrent callers can await it
// Resolve dependencies first const resolutionPromise = (async () => {
const dependencies: any[] = []; try {
if (registration.dependencies) { // Resolve dependencies first
for (const dep of registration.dependencies) { const dependencies: any[] = [];
dependencies.push(await this.resolve(dep)); if (registration.dependencies) {
for (const dep of registration.dependencies) {
dependencies.push(await this.resolve(dep));
}
} }
// Create service instance
const instance = await registration.factory();
// Initialize service if it implements IService
if (this.isService(instance)) {
await instance.initialize?.();
}
// Store singleton
if (registration.singleton) {
this.singletons.set(token, instance);
}
return instance as T;
} finally {
this.resolving.delete(token);
this.pendingResolutions.delete(token);
} }
})();
// Create service instance this.pendingResolutions.set(token, resolutionPromise);
const instance = await registration.factory();
// Initialize service if it implements IService return await resolutionPromise;
if (this.isService(instance)) {
await instance.initialize?.();
}
// Store singleton
if (registration.singleton) {
this.singletons.set(token, instance);
}
return instance;
} finally {
this.resolving.delete(token);
}
} }
/** /**

View File

@ -34,8 +34,10 @@ export const PayrollManagement: React.FC<PayrollManagementProps> = ({
}); });
useEffect(() => { useEffect(() => {
loadPayrollData(); if (payrollCalculator) {
}, []); loadPayrollData();
}
}, [payrollCalculator]);
const loadPayrollData = async () => { const loadPayrollData = async () => {
try { try {

View File

@ -27,10 +27,21 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
const [selectedPeriod, setSelectedPeriod] = useState<'week' | 'month' | 'quarter'>('month'); const [selectedPeriod, setSelectedPeriod] = useState<'week' | 'month' | 'quarter'>('month');
useEffect(() => { useEffect(() => {
loadRewardData(); if (rewardService) {
}, [userId, selectedPeriod]); loadRewardData();
} else {
// Reset loading state if service is not available
setLoading(false);
}
}, [userId, selectedPeriod, rewardService]);
const loadRewardData = async () => { const loadRewardData = async () => {
if (!rewardService) {
setError('Reward service not available');
setLoading(false);
return;
}
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -52,10 +63,6 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
} }
// Load rewards for the period // Load rewards for the period
if (!rewardService) {
throw new Error('Reward service not available');
}
const userRewards = await rewardService.getRewardsByUserId(userId, { const userRewards = await rewardService.getRewardsByUserId(userId, {
dateFrom: startDate, dateFrom: startDate,
dateTo: endDate dateTo: endDate

View File

@ -29,8 +29,10 @@ export const WalletBalance: React.FC<WalletBalanceProps> = ({
const [showAllTransactions, setShowAllTransactions] = useState(false); const [showAllTransactions, setShowAllTransactions] = useState(false);
useEffect(() => { useEffect(() => {
loadWalletData(); if (walletService) {
}, [userId]); loadWalletData();
}
}, [userId, walletService]);
const loadWalletData = async () => { const loadWalletData = async () => {
try { try {

View File

@ -15,6 +15,7 @@ import { EventBus } from '../../core/events/EventBus';
import { RewardDashboard } from './components/RewardDashboard'; import { RewardDashboard } from './components/RewardDashboard';
import { WalletBalance } from './components/WalletBalance'; import { WalletBalance } from './components/WalletBalance';
import { PayrollManagement } from './components/PayrollManagement'; import { PayrollManagement } from './components/PayrollManagement';
import { container as globalContainer } from '../../core/di/DIContainer';
export class PayrollRewardSystemModule implements IPlugin { export class PayrollRewardSystemModule implements IPlugin {
readonly metadata: PluginMetadata = { readonly metadata: PluginMetadata = {
@ -29,7 +30,7 @@ export class PayrollRewardSystemModule implements IPlugin {
private rewardRepository: MockRewardRepository; private rewardRepository: MockRewardRepository;
private rewardService: RewardService; private rewardService: RewardService;
private walletService: WalletService; private walletService: WalletService;
private payrollCalculator: PayrollCalculator; private payrollCalculator: PayrollCalculator | undefined;
private services: Map<string, any> = new Map(); private services: Map<string, any> = new Map();
constructor() { constructor() {
@ -37,21 +38,29 @@ export class PayrollRewardSystemModule implements IPlugin {
this.rewardRepository = new MockRewardRepository(); this.rewardRepository = new MockRewardRepository();
this.rewardService = new RewardService(this.rewardRepository, this.eventBus); this.rewardService = new RewardService(this.rewardRepository, this.eventBus);
this.walletService = new WalletService(); this.walletService = new WalletService();
this.payrollCalculator = new PayrollCalculator(this.rewardService); this.payrollCalculator = undefined;
this.isActive = false;
} }
async activate(): Promise<void> { async activate(): Promise<void> {
if (this.isActive) return; if (this.isActive) return;
// Initialize services // Initialize services
await this.rewardService.initialize(); await this.rewardService.initialize();
await this.walletService.initialize(); await this.walletService.initialize();
await this.payrollCalculator.initialize();
// Register reward-service to DIContainer first
this.services.set('reward-service', this.rewardService); this.services.set('reward-service', this.rewardService);
this.services.set('wallet-service', this.walletService); this.services.set('wallet-service', this.walletService);
// Register the already-initialized instances to the global DI container
globalContainer.registerInstance('reward-service', this.rewardService);
globalContainer.registerInstance('wallet-service', this.walletService);
// Baru inisialisasi PayrollCalculator setelah reward-service terdaftar
// Use resolveSync because reward-service is already registered as an instance
this.payrollCalculator = new PayrollCalculator(() => globalContainer.resolveSync('reward-service'));
await this.payrollCalculator.initialize();
this.services.set('payroll-calculator', this.payrollCalculator); this.services.set('payroll-calculator', this.payrollCalculator);
this.isActive = true; this.isActive = true;
console.log(`${this.metadata.name} module activated`); console.log(`${this.metadata.name} module activated`);
} }
@ -100,6 +109,11 @@ export class PayrollRewardSystemModule implements IPlugin {
} }
getServices(): ServiceConfig[] { getServices(): ServiceConfig[] {
if (!this.isActive) {
console.warn('PayrollRewardSystemModule is not active. Call activate() first.');
return [];
}
return [ return [
{ {
token: 'reward-service', token: 'reward-service',
@ -113,7 +127,12 @@ export class PayrollRewardSystemModule implements IPlugin {
}, },
{ {
token: 'payroll-calculator', token: 'payroll-calculator',
factory: () => this.payrollCalculator, factory: () => {
if (!this.payrollCalculator) {
throw new Error('PayrollCalculator not initialized. Make sure to call activate() first.');
}
return this.payrollCalculator;
},
singleton: true singleton: true
} }
]; ];

View File

@ -28,7 +28,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
private entries: Map<string, PayrollEntry> = new Map(); private entries: Map<string, PayrollEntry> = new Map();
private config: PayrollConfig = { ...defaultPayrollConfig }; private config: PayrollConfig = { ...defaultPayrollConfig };
constructor(private rewardService: IRewardService) { constructor(private rewardServiceFactory: () => IRewardService) {
super(); super();
} }
@ -47,7 +47,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
} }
// Get all approved rewards for the period // Get all approved rewards for the period
const rewards = await this.rewardService.getTotalRewardsByPeriod( const rewards = await this.rewardServiceFactory().getTotalRewardsByPeriod(
input.startDate, input.startDate,
input.endDate, input.endDate,
RewardStatus.APPROVED RewardStatus.APPROVED
@ -64,7 +64,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
let totalAmount = 0; let totalAmount = 0;
for (const userId of simulatedUsers) { for (const userId of simulatedUsers) {
const userRewardData = await this.rewardService.getRewardsByUserId(userId, { const userRewardData = await this.rewardServiceFactory().getRewardsByUserId(userId, {
status: RewardStatus.APPROVED, status: RewardStatus.APPROVED,
dateFrom: input.startDate, dateFrom: input.startDate,
dateTo: input.endDate dateTo: input.endDate
@ -287,7 +287,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
for (const entry of batch.entries) { for (const entry of batch.entries) {
for (const breakdown of entry.rewardBreakdown) { for (const breakdown of entry.rewardBreakdown) {
for (const rewardId of breakdown.rewardIds) { for (const rewardId of breakdown.rewardIds) {
await this.rewardService.markRewardAsPaid(rewardId, `payroll_${batchId}`); await this.rewardServiceFactory().markRewardAsPaid(rewardId, `payroll_${batchId}`);
} }
} }
} }

View File

@ -185,9 +185,13 @@ export function TopBar({ onMenuClick, sidebarCollapsed }: TopBarProps) {
<div className="border-t border-gray-100"> <div className="border-t border-gray-100">
<button <button
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => { onClick={async () => {
setShowUserMenu(false); try {
logout(); setShowUserMenu(false);
logout();
} catch (error) {
console.error('Logout button error:', error);
}
}} }}
> >
Keluar Keluar

View File

@ -410,7 +410,7 @@ export interface LoadingState {
// Enhanced Question Types for Interactive Quiz // Enhanced Question Types for Interactive Quiz
export interface InteractiveQuestion { export interface InteractiveQuestion {
id: string; id: string;
type: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection'; type: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection' | 'video_scenario' | 'image_hotspot' | 'media_gallery';
content: QuestionContent; content: QuestionContent;
interactiveElements?: InteractiveElement[]; interactiveElements?: InteractiveElement[];
behaviorMetrics?: BehaviorMetric[]; behaviorMetrics?: BehaviorMetric[];
@ -425,12 +425,21 @@ export interface InteractiveQuestion {
// Scenario-specific data // Scenario-specific data
scenarioData?: ScenarioData; scenarioData?: ScenarioData;
// Video scenario specific data
scenarios?: VideoScenarioStep[];
// Image hotspot specific data
hotspots?: ImageHotspot[];
// New quiz type properties // New quiz type properties
visualElements?: VisualMatchingConfig; visualElements?: VisualMatchingConfig;
reactionTimeConfig?: ReactionTimeConfig; reactionTimeConfig?: ReactionTimeConfig;
rpgElements?: RPGConfig; rpgElements?: RPGConfig;
reflectionConfig?: ReflectionConfig; reflectionConfig?: ReflectionConfig;
// Media gallery specific
mediaItems?: MediaItem[];
// Legacy properties for backward compatibility // Legacy properties for backward compatibility
text?: string; text?: string;
question?: string; question?: string;
@ -489,8 +498,27 @@ export interface MultimediaContent {
thumbnail?: string; thumbnail?: string;
} }
export interface MediaItem {
id: string;
type: 'image' | 'video' | 'audio' | 'document';
src: string;
title: string;
description?: string;
duration?: number; // in seconds for video/audio
size?: number; // file size in bytes
fileType?: string; // for document type (e.g., 'PDF', 'DOC', 'XLS')
fileSize?: string; // human readable file size (e.g., '2.5 MB')
thumbnail?: string;
metadata?: {
width?: number;
height?: number;
format?: string;
quality?: string;
};
}
export interface InteractiveElement { export interface InteractiveElement {
type: 'drag_drop' | 'matching' | 'sequencing' | 'slider' | 'hotspot' | 'timeline' | 'hint' | 'explanation'; type: 'drag_drop' | 'matching' | 'sequencing' | 'slider' | 'hotspot' | 'timeline' | 'hint' | 'explanation' | 'learning_objective' | 'assessment_criteria';
config?: any; // Flexible configuration object config?: any; // Flexible configuration object
content?: string; // For hint and explanation content content?: string; // For hint and explanation content
validation?: ValidationRule[]; validation?: ValidationRule[];
@ -502,15 +530,24 @@ export interface ValidationRule {
message: string; message: string;
} }
export interface SliderConfig {
id: string;
label: string;
minValue: number;
maxValue: number;
correctValue: number;
unit?: string;
}
// Puzzle Data Types // Puzzle Data Types
export interface PuzzleData { export interface PuzzleData {
type: 'jigsaw' | 'sequence' | 'word_puzzle'; type: 'jigsaw' | 'sequence' | 'word_puzzle' | 'slider';
instructions?: string; instructions?: string;
pieces?: PuzzlePiece[]; pieces?: PuzzlePiece[];
sequence?: string[]; sequence?: string[];
words?: string[]; words?: string[];
correctOrder?: number[]; correctOrder?: number[];
sliders?: any[]; sliders?: SliderConfig[];
targetAreas?: any[]; targetAreas?: any[];
gridSize?: { gridSize?: {
rows: number; rows: number;
@ -1049,6 +1086,42 @@ export interface EmojiScaleConfig {
allowHalfSteps: boolean; allowHalfSteps: boolean;
} }
// Video Scenario Types
export interface VideoScenarioStep {
id: string;
timestamp: number; // detik
title: string;
question: string;
options: VideoOption[];
correctAnswer: number;
explanation: string;
pauseVideo?: boolean;
}
export interface VideoOption {
id: string;
text: string;
consequence?: string;
isCorrect?: boolean;
}
// Image Hotspot Types
export interface ImageHotspot {
id: string;
x: number; // percentage (0-100)
y: number; // percentage (0-100)
width: number; // percentage
height: number; // percentage
type: 'correct' | 'incorrect' | 'neutral' | 'warning';
title: string;
description: string;
feedback: string;
points: number;
hint?: string;
consequence?: string;
isRequired?: boolean;
}
export interface EmojiOption { export interface EmojiOption {
value: number; value: number;
emoji: string; emoji: string;