From d893ab608efbe3e7cf825385ede69e5fbb363ef4 Mon Sep 17 00:00:00 2001 From: Thanh Hoang <126055913+hofang42@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:50:49 +0700 Subject: [PATCH 01/51] [FLN-133][Hoang][UI] Update UI for add course [07.10.2025] (#110) --- .../CRUDCourseAndLesson/CourseCurriculum.css | 1000 ++++--- .../assets/CRUDCourseAndLesson/CourseForm.css | 27 +- .../CRUDCourseAndLesson/CourseCurriculum.jsx | 2453 +++++------------ .../CRUDCourseAndLesson/CourseForm.jsx | 46 +- .../CRUDCourseAndLesson/CourseFormAdvance.jsx | 28 +- .../CRUDCourseAndLesson/CoursePublish.jsx | 14 - 6 files changed, 1272 insertions(+), 2296 deletions(-) diff --git a/FrontEnd/src/assets/CRUDCourseAndLesson/CourseCurriculum.css b/FrontEnd/src/assets/CRUDCourseAndLesson/CourseCurriculum.css index dd8f402..462f3e2 100644 --- a/FrontEnd/src/assets/CRUDCourseAndLesson/CourseCurriculum.css +++ b/FrontEnd/src/assets/CRUDCourseAndLesson/CourseCurriculum.css @@ -1,729 +1,681 @@ -/* Base Styles */ -.acc-app-container { - display: flex; - min-height: 100vh; - margin-bottom: 24px; - margin: 32px 10px 10px 0; +/* Remove number input spinners locally inside the curriculum component to avoid confusion */ +.curriculum-container input[type="number"]::-webkit-outer-spin-button, +.curriculum-container input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; } -/* Icon Sizes */ -.acc-icon-xs { - width: 16px; - height: 16px; +.curriculum-container input[type="number"] { + -moz-appearance: textfield; + appearance: textfield; } -.acc-icon-sm { - width: 20px; - height: 20px; +/* Button reset styles scoped to the curriculum component */ +.curriculum-container button { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline: inherit; } -/* Sidebar Styles */ -.acc-sidebar { - width: 256px; - background-color: #1d2026; - color: white; - display: flex; - flex-direction: column; +/* Exception for scrollable areas */ +.modal-body { + overflow-y: auto !important; } -.acc-logo-section { +/* Curriculum Container */ +.curriculum-container { + max-width: 1200px; + margin: 0 auto; padding: 24px; - border-bottom: 1px solid rgba(110, 116, 133, 0.2); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + sans-serif; + overflow: visible; + position: relative; } -.acc-logo { - display: flex; - align-items: center; - gap: 8px; +.curriculum-header { + margin-bottom: 32px; } -.acc-logo-icon { - width: 32px; - height: 32px; - background-color: #ff6636; +.curriculum-title { + font-size: 32px; + font-weight: bold; + margin-bottom: 24px; + color: #1f2937; +} + +/* Stats Overview */ +.stats-overview { + background: white; border-radius: 8px; + padding: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + margin-bottom: 24px; display: flex; - align-items: center; - justify-content: center; + gap: 32px; } -.acc-logo-text { - font-size: 20px; - font-weight: 600; +.stat-item { + text-align: center; } -.acc-navigation { - flex: 1; - padding: 16px; +.stat-number { + font-size: 48px; + font-weight: bold; + color: #f97316; /* Orange accent */ + line-height: 1; } -.acc-nav-items { - display: flex; - flex-direction: column; - gap: 8px; +.stat-label { + font-size: 14px; + color: #6b7280; + margin-top: 4px; } -.acc-nav-item { +/* Sections */ +.sections-container { display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; - border-radius: 8px; - color: #8c94a3; - text-decoration: none; - transition: all 0.2s ease; + flex-direction: column; + gap: 16px; + overflow: visible; position: relative; } -.acc-nav-item:hover { - background-color: rgba(110, 116, 133, 0.1); -} - -.acc-nav-item-active { - background-color: #ff6636; - color: white; -} - -.acc-nav-item-with-badge { +.section-card { + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: visible; position: relative; } -.acc-notification-badge { - position: absolute; - top: -4px; - right: -4px; - background-color: #ff6636; - color: white; - font-size: 12px; - width: 20px; - height: 20px; - border-radius: 50%; +.section-header { + padding: 16px; + cursor: pointer; display: flex; align-items: center; - justify-content: center; - padding: 0; + justify-content: space-between; + transition: background-color 0.2s; + overflow: visible; + position: relative; } -.acc-sidebar-footer { - padding: 16px; - border-top: 1px solid rgba(110, 116, 133, 0.2); +.section-header:hover { + background-color: #f9fafb; } -/* Main Content Layout (like CourseForm, but acc- prefix) */ -.acc-content-area { - flex: 1; +.section-left { display: flex; - flex-direction: column; + align-items: center; + gap: 12px; + flex: 1; } -.acc-main-container { - width: 100%; - max-width: 1200px; - margin: 0 auto; - padding: 0; - background: #fff; - border-radius: 16px; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); - min-height: calc(100vh - 96px); - display: flex; - flex-direction: column; +.section-chevron { + transition: transform 0.2s ease; + color: #6b7280; } -.acc-main-content { - flex: 1; - display: flex; - flex-direction: column; +.section-chevron.expanded { + transform: rotate(90deg); } -.acc-form-content { +.section-info { flex: 1; - padding: 32px; } -.acc-form-header { +.section-title-row { display: flex; align-items: center; - justify-content: space-between; - margin-bottom: 32px; - max-width: 1024px; + gap: 8px; + margin-bottom: 4px; } -.acc-form-title { - font-size: 24px; +.section-number { + font-size: 14px; + color: #6b7280; +} + +.section-title-input { font-weight: 600; - color: #1d2026; + font-size: 18px; + border: none; + outline: none; + background: transparent; + /* Hide progress percentage display as per design */ + .section-progress, + .lesson-progress { + display: none; + } + flex: 1; + color: #1f2937; } -.acc-form-actions { - display: flex; - gap: 12px; +.section-title-input::placeholder { + color: #9ca3af; } -/* Header Styles */ -.acc-header { - background-color: white; - border-bottom: 1px solid #e9eaf0; - padding: 16px 32px; +.section-meta { + font-size: 14px; + color: #6b7280; } -.acc-header-content { +.section-right { display: flex; align-items: center; - justify-content: space-between; + gap: 12px; + overflow: visible; + position: relative; } -.acc-greeting { - color: #8c94a3; +.section-progress { font-size: 14px; - margin: 0 0 4px 0; + font-weight: 500; + color: #f97316; /* Orange accent */ } -.acc-page-title { - font-size: 24px; - font-weight: 600; - color: #1d2026; - margin: 0; +.section-actions { + display: flex; + gap: 8px; } -.acc-header-right { +.dropdown-container { + position: relative; + display: inline-block; + z-index: 500; + overflow: visible; +} + +.curriculum-container .action-button { + padding: 8px !important; + border: none !important; + background: none; + border-radius: 4px; + cursor: pointer; display: flex; align-items: center; - gap: 16px; + justify-content: center; + transition: background-color 0.2s; + position: relative; + outline: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + min-width: 36px; + min-height: 36px; + box-shadow: none !important; + color: #6b7280; + font-size: 16px; } -.acc-search-container { - position: relative; +.curriculum-container .action-button:hover { + background-color: #fff7ed !important; /* subtle warm hover */ } -.acc-search-icon { +.curriculum-container .action-button.delete:hover { + background-color: #fef2f2 !important; + color: #dc2626 !important; +} + +.lesson-type-dropdown { position: absolute; - left: 12px; - top: 50%; - transform: translateY(-50%); - color: #8c94a3; - width: 16px; - height: 16px; -} - -.acc-search-input { - padding-left: 40px; - width: 320px; - background-color: #f5f7fa; - border: none; + right: 0; + top: calc(100% + 8px); + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.06); + border-radius: 12px; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12); + z-index: 1500; + width: auto; + min-width: 220px; /* larger by default */ + max-width: 340px; + overflow: visible; + pointer-events: auto; + padding: 12px; /* more breathing room */ +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.12s, transform 0.06s; + font-size: 15px; + color: #0f172a; + white-space: nowrap; + user-select: none; + border-radius: 6px; + border: 0; + background: transparent; + width: 100%; + text-align: left; + margin: 4px 0; /* space between options */ } -.acc-notification-icon { - width: 20px; - height: 20px; - color: #8c94a3; +.dropdown-item:hover { + background-color: #fff3e0; /* slightly stronger warm hover */ + color: #7c2d0e; + transform: translateY(-1px); } -.acc-user-avatar { - width: 32px; - height: 32px; +.dropdown-item:active { + background-color: rgba(249, 115, 22, 0.06); } -.acc-avatar-fallback { - background-color: #ff6636; - color: white; +/* Lessons */ +.lessons-container { + border-top: 1px solid #e5e7eb; } -/* Progress Section */ -.acc-progress-section { - background-color: white; - border-bottom: 1px solid #e9eaf0; - padding: 24px 32px; +.lesson-item { + border-bottom: 1px solid #e5e7eb; } -.acc-progress-steps { - display: flex; - align-items: center; - gap: 32px; +.lesson-item:last-child { + border-bottom: none; } -.acc-progress-step { +.lesson-header { + padding: 16px; + cursor: pointer; display: flex; align-items: center; gap: 12px; + transition: background-color 0.2s; } -.acc-step-icon { - width: 24px; - height: 24px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; +.lesson-header:hover { + background-color: #f9fafb; } -.acc-step-completed { - background-color: #23bd33; - color: white; +.lesson-drag-handle { + color: #9ca3af; } -.acc-step-current { - background-color: #ff6636; - color: white; +.lesson-icon { + color: #f97316; /* Primary web color */ } -.acc-step-pending { - border: 2px solid #e9eaf0; - background-color: white; +.lesson-info { + flex: 1; } -.acc-step-number { - color: #8c94a3; - font-size: 14px; +.lesson-title-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; } -.acc-step-label { - color: #8c94a3; +.lesson-number { + font-size: 12px; + color: #6b7280; } -.acc-step-label-current { - color: #1d2026; +.lesson-title-input { font-weight: 500; + border: none; + outline: none; + background: transparent; + flex: 1; + color: #1f2937; } -.acc-step-counter { - color: #8c94a3; - font-size: 14px; -} - -/* Course Structure */ -.acc-course-structure { - display: flex; - flex-direction: column; - gap: 16px; -} - -.acc-section-card { - background-color: white; - border-radius: 8px; - border: 1px solid #e9eaf0; - padding: 24px; +.lesson-title-input::placeholder { + color: #9ca3af; } -.acc-section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; +.lesson-meta { + font-size: 12px; + color: #6b7280; } -.acc-section-title-container { +.lesson-actions { display: flex; align-items: center; gap: 12px; } -.acc-section-indicator { - display: flex; - align-items: center; - gap: 8px; - color: #8c94a3; -} - -.acc-section-line { - width: 16px; - height: 2px; - background-color: #c2c8d0; - border-radius: 2px; - position: relative; - display: inline-block; - margin-right: 8px; +.lesson-chevron { + transition: transform 0.2s ease; + color: #6b7280; } -.acc-section-line::before, -.acc-section-line::after { - content: ""; - position: absolute; - left: 0; - width: 16px; - height: 2px; - background-color: #c2c8d0; - border-radius: 2px; -} - -.acc-section-line::before { - top: -5px; +.lesson-chevron.expanded { + transform: rotate(90deg); } -.acc-section-line::after { - top: 5px; +.lesson-progress { + font-size: 14px; + font-weight: 500; + color: #f97316; /* Primary web color */ } -.acc-section-label { - font-weight: 500; +/* Lesson Fields */ +.lesson-fields { + padding: 16px; + background-color: #f9fafb; + display: flex; + flex-direction: column; + gap: 16px; } -.acc-section-name { - color: #1d2026; +.field-card { + background: white; + padding: 16px; + border-radius: 8px; } -.acc-section-actions { +.field-header { display: flex; align-items: center; gap: 8px; + margin-bottom: 8px; } -.acc-action-button { - color: #8c94a3; +.field-title { + font-weight: 500; + color: #1f2937; } -.acc-action-button:hover { - color: #1d2026; +.field-check { + color: #10b981; } -/* Lectures */ -.acc-lectures-container { - display: flex; - flex-direction: column; - gap: 12px; - margin-left: 24px; +.field-input { + width: 100%; + padding: 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 14px; + color: #1f2937; } -.acc-lecture-item { - background-color: #f5f7fa; - border-radius: 8px; +.field-input:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} +/* File upload input styled as dashed upload button */ +.file-input { + width: 100%; padding: 12px; + border: 2px dashed #d1d5db; + border-radius: 8px; + background: none; + cursor: pointer; + color: #6b7280; + font-size: 14px; + transition: all 0.2s; +} +.file-input:hover { + border-color: #f97316; + background-color: #fff7ed; + color: #f97316; } -.acc-lecture-content { - display: flex; - align-items: center; - justify-content: space-between; +.field-textarea { + width: 100%; + padding: 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 14px; + color: #1f2937; + resize: none; } -.acc-lecture-title-container { - display: flex; - align-items: center; - gap: 12px; +.field-textarea:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); } -.acc-lecture-line { - width: 16px; - height: 2px; - background-color: #c2c8d0; - border-radius: 2px; - position: relative; - display: inline-block; - margin-right: 8px; +.field-note { + font-size: 12px; + color: #6b7280; + margin-top: 4px; } -.acc-lecture-line::before, -.acc-lecture-line::after { - content: ""; - position: absolute; - left: 0; - width: 16px; - height: 2px; - background-color: #c2c8d0; - border-radius: 2px; +.fields-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; } -.acc-lecture-line::before { - top: -5px; +.field-full { + grid-column: 1 / -1; } -.acc-lecture-line::after { - top: 5px; +/* Quiz Selection */ +.quiz-selector-button { + width: 100%; + margin-bottom: 12px; + padding: 8px; + border: 2px dashed #d1d5db; + border-radius: 8px; + background: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; + color: #6b7280; } -.acc-lecture-name { - color: #1d2026; +.quiz-selector-button:hover { + border-color: #2563eb; + background-color: #eff6ff; + color: #2563eb; } -.acc-lecture-actions { +.quiz-list { display: flex; - align-items: center; + flex-direction: column; gap: 8px; } -.acc-contents-button { - color: #ff6636; +.quiz-item { display: flex; - align-items: center; /* Add this line to vertically center children */ + align-items: center; + justify-content: space-between; + padding: 12px; + background-color: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; } -.acc-contents-button:hover { - background-color: #ffeee8; +.quiz-info { + flex: 1; } -.acc-chevron { - margin-left: 4px; - display: flex; /* Add this line */ - align-items: center; /* Add this line */ +.quiz-title { + font-weight: 500; + font-size: 14px; + color: #1f2937; } -.acc-dropdown-content { - width: 192px; +.quiz-meta { + font-size: 12px; + color: #6b7280; } -.acc-dropdown-item { - display: flex; - align-items: center; - gap: 8px; +.quiz-remove { + padding: 4px; + border: none; + background: none; + border-radius: 4px; + cursor: pointer; + color: #dc2626; + transition: background-color 0.2s; } -/* Expanded Content */ -.acc-expanded-content { - margin-left: 24px; - padding: 16px; - background-color: white; - border: 1px solid #e9eaf0; - border-radius: 8px; - margin-top: 12px; +.quiz-remove:hover { + background-color: #fef2f2; } -.acc-content-options { - display: flex; - flex-direction: column; - gap: 8px; +.empty-state { + font-size: 14px; + color: #6b7280; + text-align: center; + padding: 16px; } -.acc-content-option { +/* Add Section Button */ +.add-section-button { + width: 100%; + padding: 14px 20px; + border: 2px solid #f97316; /* orange outline */ + border-radius: 8px; + background: transparent; + cursor: pointer; display: flex; align-items: center; + justify-content: center; gap: 8px; - color: #8c94a3; - cursor: pointer; - transition: color 0.2s ease; -} - -.acc-content-option:hover { - color: #1d2026; + transition: background-color 0.2s, color 0.2s, border-color 0.2s; + color: #f97316; + font-weight: 600; } -/* Add Sections Button */ -.acc-add-sections-button { - width: 100%; - padding: 24px; - color: #ff6636; - border-color: #ff6636; - border-style: dashed; - background-color: rgba(255, 238, 232, 0.3); - display: flex; -} -.acc-add-sections-container { - display: flex; +.add-section-button { + display: inline-flex; align-items: center; justify-content: center; + gap: 8px; + min-width: 180px; + padding: 8px 20px; + border: 2px solid #f97316; + border-radius: 9999px; /* pill */ + background: #ffffff; + color: #f97316; + font-size: 16px; + font-weight: 600; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: background-color 0.2s, color 0.2s, box-shadow 0.2s; } -.acc-add-sections-button:hover { - background-color: #ffeee8; -} - -.acc-add-icon { - margin-right: 8px; +.add-section-button:hover { + background-color: #f97316; + color: #ffffff; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); } -/* Navigation Buttons */ -.acc-navigation-buttons { +.modal-header { display: flex; justify-content: space-between; - margin-top: 48px; + align-items: center; + margin-bottom: 16px; } -.acc-nav-button { - padding: 8px 32px; +.modal-title { + font-size: 18px; + font-weight: 500; + color: #1f2937; } -.acc-nav-button-primary { - background-color: #ff6636; +.modal-close { + font-size: 24px; + border: none; + background: none; + cursor: pointer; + color: #6b7280; + transition: color 0.2s; } -.acc-nav-button-primary:hover { - background-color: rgba(255, 102, 54, 0.9); +.modal-close:hover { + color: #374151; } -/* Footer */ -.acc-footer { - background-color: white; - border-top: 1px solid #e9eaf0; - padding: 16px 32px; +.modal-body { + max-height: 384px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; } - -.acc-footer-content { +/* Overlay for quiz selection modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; - justify-content: space-between; - font-size: 14px; - color: #8c94a3; + justify-content: center; + z-index: 2000; +} +/* Constrain modal content size */ +.modal-content { + width: 90%; + max-width: 600px; + margin: auto; + overflow: visible; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); } -.acc-footer-highlight { - color: #1d2026; +.modal-content { + background: #fff; + border-radius: 8px; + max-width: 550px; + width: 100%; + padding: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; } - -.acc-footer-links { +.modal-header { display: flex; - align-items: center; - gap: 24px; + justify-content: center; + position: relative; + padding-bottom: 8px; + border-bottom: 1px solid #e5e7eb; } - -.acc-footer-link { - color: #8c94a3; - text-decoration: none; - transition: color 0.2s ease; +.modal-close { + position: absolute; + top: 12px; + right: 12px; + font-size: 20px; + background: transparent; + border: none; + cursor: pointer; } - -.acc-footer-link:hover { - color: #1d2026; +.modal-body { + max-height: 60vh; + overflow-y: auto; + gap: 12px; + padding: 8px 0; } -/* Responsive Design */ -@media (max-width: 1200px) { - .acc-main-container { - max-width: 100vw; - } +/* Quiz options restyle */ +.quiz-option { + padding: 12px 16px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + margin-bottom: 8px; + transition: background-color 0.2s, border-color 0.2s; } - -@media (max-width: 768px) { - .acc-form-content { - padding: 16px; - } - .acc-form-header { - align-items: flex-start; - gap: 8px; - margin-bottom: 16px; - max-width: 100vw; - } - .acc-form-title { - font-size: 18px; - } - .acc-main-container { - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - padding: 0; - min-height: unset; - max-width: 100vw; - width: 100vw; - } +.quiz-option:hover { + background: #fff7ed; + border-color: #f97316; } - -@media (max-width: 600px) { - .acc-main-container { - padding: 0 !important; - width: 100vw !important; - max-width: 100vw !important; - } - .acc-form-content { - padding: 4px !important; - width: 100vw !important; - max-width: 100vw !important; - } - .acc-form-header { - flex-direction: row; - align-items: flex-start; - gap: 8px; - margin-bottom: 12px; - max-width: 100vw; - justify-self: center; - } - .acc-form-title { - font-size: 16px; - } - .acc-lecture-content { - flex-direction: column !important; - align-items: flex-start !important; - gap: 6px !important; - width: 100% !important; - } - .acc-lecture-title-container { - flex-wrap: wrap !important; - gap: 6px !important; - min-width: 0 !important; - width: 100% !important; - } - .acc-lecture-name, - .acc-lecture-label, - .acc-section-label { - min-width: 0 !important; - max-width: 100% !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - white-space: nowrap !important; - display: block !important; - } - .acc-lecture-item { - width: 100% !important; - box-sizing: border-box !important; - } - .acc-lecture-title-container input { - width: 100% !important; - min-width: 0 !important; - max-width: 100% !important; - } - /* Modal overlay */ - .cf-app-container > div[style*="position: fixed"] { - align-items: center !important; - justify-content: center !important; - padding-top: 0 !important; - } - /* Modal content */ - .cf-app-container > div[style*="position: fixed"] > div { - width: 98vw !important; - min-width: 0 !important; - max-width: 98vw !important; - padding: 12px !important; - border-radius: 8px !important; - box-sizing: border-box !important; - margin: 0 auto !important; - } - /* Modal textarea/input */ - .cf-app-container textarea, - .cf-app-container input[type="text"], - .cf-app-container input[type="file"] { - width: 100% !important; - min-width: 0 !important; - max-width: 100% !important; - box-sizing: border-box !important; - } - /* Modal footer buttons */ - .cf-app-container .CustomButton { - min-width: 80px !important; - font-size: 15px !important; - padding: 8px 12px !important; - } - .acc-expanded-content .lucide { - display: none !important; - } - .acc-expanded-content > div > div { - display: flex !important; - flex-direction: row !important; - align-items: center !important; - gap: 8px !important; - width: 100% !important; - margin-bottom: 10px !important; - box-sizing: border-box !important; - padding: 8px 4px !important; - } - .acc-expanded-content .acc-lecture-label, - .acc-expanded-content .acc-section-label, - .acc-expanded-content span, - .acc-expanded-content .acc-lecture-name { - font-size: 15px !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - word-break: normal !important; - } - .acc-expanded-content .acc-lecture-label { - margin-bottom: 0 !important; - } - .acc-expanded-content [data-video-preview] { - width: 100% !important; - max-width: 100% !important; - min-width: 0 !important; - margin-top: 8px !important; - margin-bottom: 8px !important; - } - .acc-expanded-content video { - width: 100% !important; - height: auto !important; - max-height: 180px !important; - object-fit: contain !important; - border-radius: 6px !important; - } +.quiz-option.selected { + background: #fff1e6; + border-color: #f97316; +} +.acc-navigation-buttons { + display: flex; + justify-content: space-between; + margin-top: 24px; } diff --git a/FrontEnd/src/assets/CRUDCourseAndLesson/CourseForm.css b/FrontEnd/src/assets/CRUDCourseAndLesson/CourseForm.css index 882926c..7eabbd9 100644 --- a/FrontEnd/src/assets/CRUDCourseAndLesson/CourseForm.css +++ b/FrontEnd/src/assets/CRUDCourseAndLesson/CourseForm.css @@ -127,11 +127,27 @@ body { margin-bottom: 24px; } +.cf-form-row-3 { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 24px; + margin-bottom: 24px; +} + .cf-duration-container { display: flex; gap: 8px; } +/* Custom field sizing */ +.cf-small-input { + max-width: 250px; +} + +.cf-price-input { + max-width: 300px; +} + .cf-form-actions-bottom { display: flex; justify-content: space-between; @@ -187,6 +203,14 @@ body { .cf-form-row-4 { grid-template-columns: 1fr 1fr; } + + .cf-form-row-3 { + grid-template-columns: 1fr 1fr; + } + + .cf-form-row-3 .cf-price-input { + grid-column: 1 / -1; + } } @media (max-width: 768px) { @@ -194,7 +218,8 @@ body { padding: 16px; } - .cf-form-row { + .cf-form-row, + .cf-form-row-3 { grid-template-columns: 1fr; } diff --git a/FrontEnd/src/components/CRUDCourseAndLesson/CourseCurriculum.jsx b/FrontEnd/src/components/CRUDCourseAndLesson/CourseCurriculum.jsx index 5951376..cefa205 100644 --- a/FrontEnd/src/components/CRUDCourseAndLesson/CourseCurriculum.jsx +++ b/FrontEnd/src/components/CRUDCourseAndLesson/CourseCurriculum.jsx @@ -1,58 +1,59 @@ import { useState, useEffect } from "react"; -import CustomButton from "../common/CustomButton/CustomButton"; -import { Plus, Trash2, FileText, Video, Type, StickyNote } from "lucide-react"; +import { + Plus, + Trash2, + Video, + BookOpen, + HelpCircle, + Clock, + Upload, + Check, + GripVertical, + ChevronRight, + FileText, + X, +} from "lucide-react"; import "../../assets/CRUDCourseAndLesson/CourseCurriculum.css"; import ProgressTabs from "./ProgressTabs"; -import apiClient from "../../services/authService"; -import { toast } from "react-toastify"; +import CustomButton from "../common/CustomButton/CustomButton"; + +// Mock quiz data - Replace with API call later +const MOCK_QUIZZES = [ + { + _id: "quiz1", + title: "Introduction to JavaScript Quiz", + questions: 10, + duration: 600, + }, + { + _id: "quiz2", + title: "React Fundamentals Assessment", + questions: 15, + duration: 900, + }, + { + _id: "quiz3", + title: "Advanced CSS Techniques", + questions: 8, + duration: 480, + }, + { _id: "quiz4", title: "Node.js Backend Quiz", questions: 12, duration: 720 }, + { + _id: "quiz5", + title: "Database Design Principles", + questions: 10, + duration: 600, + }, +]; function Modal({ open, onClose, title, children }) { if (!open) return null; return ( -
-
-
-

{title}

-
@@ -62,502 +63,124 @@ function Modal({ open, onClose, title, children }) { ); } -// Định nghĩa hàm defaultLesson để tránh lỗi no-undef -function defaultLesson(order = 1) { - return { +function defaultLesson(order = 1, type = "video") { + const baseLesson = { title: `Lesson ${order}`, - videoUrl: "", - captions: "", + type: type, description: "", lessonNotes: "", order, + duration: 0, }; + + switch (type) { + case "video": + case "article": + return { ...baseLesson, materialFile: null }; + case "quiz": + return { ...baseLesson, title: `Quiz ${order}`, quizIds: [] }; + default: + return { ...baseLesson, materialFile: null }; + } } export default function CourseCurriculum({ - onNext = () => {}, - onPrev = () => {}, - initialData = {}, - completedTabs = [], - onTabClick = () => {}, + initialData, + onNext, + onPrev, + completedTabs, + onTabClick, }) { - const [expandedLecture, setExpandedLecture] = useState(1); - const [modal, setModal] = useState({ open: false, type: null }); - const [lessonVideoFile, setLessonVideoFile] = useState(null); - const [lessonVideoPreview, setLessonVideoPreview] = useState(null); - const [lessonFile, setLessonFile] = useState(null); - const [lessonFilePreview, setLessonFilePreview] = useState(null); - const [uploading, setUploading] = useState(false); - const [uploadedFiles, setUploadedFiles] = useState( - initialData.uploadedFiles || {} - ); // Track uploaded files by type - const [captionText, setCaptionText] = useState(""); // State for caption text - const [descriptionText, setDescriptionText] = useState(""); // State for description text - const [notesText, setNotesText] = useState(""); // State for notes text const [sections, setSections] = useState( - (initialData.sections && Array.isArray(initialData.sections) + initialData?.sections?.length > 0 ? initialData.sections - : initialData.curriculum && Array.isArray(initialData.curriculum) - ? initialData.curriculum - : [ - { - name: "Section 1", - order: 1, - lessons: [defaultLesson(1)], - }, - ] - ).map((section, idx) => { - const lessons = - Array.isArray(section.lessons) && section.lessons.length > 0 - ? section.lessons - : [defaultLesson(1)]; - return { - ...section, - lessons, - order: section.order || idx + 1, - }; - }) + : [{ name: "Section 1", order: 1, lessons: [defaultLesson(1)] }] ); - const [editing, setEditing] = useState({ + const [expandedSections, setExpandedSections] = useState(new Set([0])); + const [expandedLessons, setExpandedLessons] = useState(new Set()); + const [showLessonTypeDropdown, setShowLessonTypeDropdown] = useState({}); + const [showQuizSelector, setShowQuizSelector] = useState({ + open: false, sectionIdx: null, lessonIdx: null, - field: null, }); - const [modalValue, setModalValue] = useState(""); - const [uploadingVideo, setUploadingVideo] = useState(false); - const [selectedVideoFile, setSelectedVideoFile] = useState(null); - const [selectedVideoPreview, setSelectedVideoPreview] = useState(null); - const [uploadedVideoUrl, setUploadedVideoUrl] = useState(""); - const [expandedSectionIdx, setExpandedSectionIdx] = useState(null); - const [expandedLessonIdx, setExpandedLessonIdx] = useState({}); // {sectionIdx: lessonIdx} + const [availableQuizzes] = useState(MOCK_QUIZZES); + // Close dropdown when clicking outside useEffect(() => { - if (initialData.uploadedFiles) { - setUploadedFiles(initialData.uploadedFiles); - } - }, [initialData]); - - const toggleLecture = (index) => { - setExpandedLecture(expandedLecture === index ? null : index); - }; - - // Remove redundant button style objects and use only CustomButton for actions - - const inputStyle = { - width: "100%", - padding: "12px", - border: "1px solid #e9eaf0", - borderRadius: "6px", - fontSize: "15px", - marginTop: "8px", - background: "#fafbfc", - color: "#1d2026", - outline: "none", - boxSizing: "border-box", - }; - - const textareaStyle = { - ...inputStyle, - minHeight: 100, - resize: "vertical", - }; - - const fileBoxStyle = { - border: "1px solid #e9eaf0", - borderRadius: "6px", - background: "#fafbfc", - padding: "24px 0", - textAlign: "center", - marginTop: "8px", - marginBottom: "16px", - }; - - const labelStyle = { - fontWeight: 500, - marginBottom: 8, - display: "block", - color: "#1d2026", - fontSize: 14, - }; - - const noteStyle = { - fontSize: 12, - color: "#8c94a3", - marginTop: 4, - textAlign: "left", - }; - - const modalFooterStyle = { - display: "flex", - justifyContent: "space-between", - gap: 16, - marginTop: 16, - }; - - // Handlers for file input - const handleLessonVideoChange = (e) => { - const file = e.target.files[0]; - setLessonVideoFile(file); - setLessonVideoPreview(file ? URL.createObjectURL(file) : null); - }; - const handleLessonFileChange = (e) => { - const file = e.target.files[0]; - setLessonFile(file); - setLessonFilePreview(file ? URL.createObjectURL(file) : null); - }; - - // Handler for uploading lesson video/file and creating lesson - const handleUploadLessonFile = async () => { - setUploading(true); - try { - let videoUrl, fileUrl; - if (lessonVideoFile) { - const formData = new FormData(); - formData.append("file", lessonVideoFile); - const res = await apiClient.post("/admin/upload", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - videoUrl = res.data.url; - setUploadedFiles((prev) => ({ - ...prev, - lessonVideo: { url: videoUrl, name: lessonVideoFile.name }, - })); - } - if (lessonFile) { - const formData = new FormData(); - formData.append("file", lessonFile); - const res = await apiClient.post("/admin/upload", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - fileUrl = res.data.url; - setUploadedFiles((prev) => ({ - ...prev, - lessonFile: { url: fileUrl, name: lessonFile.name }, - })); + const handleClickOutside = (event) => { + if (!event.target.closest(".dropdown-container")) { + setShowLessonTypeDropdown({}); } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const formatDuration = (seconds) => { + if (!seconds) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${String(secs).padStart(2, "0")}`; + }; - toast.success("Lesson uploaded successfully!"); - - // Close modal after successful upload - setTimeout(() => { - setModal({ open: false, type: null }); - // Clear file inputs - setLessonVideoFile(null); - setLessonVideoPreview(null); - setLessonFile(null); - setLessonFilePreview(null); - }, 1500); - } catch (err) { - toast.error( - err.response?.data?.message || err.message || "Failed to upload lesson" - ); - } finally { - setUploading(false); + const getLessonIcon = (type) => { + switch (type) { + case "video": + return Video; + case "article": + return BookOpen; + case "quiz": + return HelpCircle; + default: + return Video; } }; - // Handler for adding caption - const handleAddCaption = () => { - if (!captionText.trim()) return; - - setUploadedFiles((prev) => ({ - ...prev, - caption: { text: captionText.trim() }, - })); - - toast.success("Caption added successfully!"); + const getLessonProgress = (lesson) => { + let completed = 0, + total = 0; + + total++; + if (lesson.title?.trim()) completed++; + total++; + if (lesson.duration > 0) completed++; + total++; + if (lesson.description?.trim()) completed++; + total++; + if (lesson.lessonNotes?.trim()) completed++; + + if (lesson.type === "video" || lesson.type === "article") { + total++; + if (lesson.materialUrl?.trim()) completed++; + } else if (lesson.type === "quiz") { + total++; + if (lesson.quizIds?.length > 0) completed++; + } - // Close modal after successful add - setTimeout(() => { - setModal({ open: false, type: null }); - setCaptionText(""); // Clear caption text - }, 1500); + return { + completed, + total, + percentage: Math.round((completed / total) * 100), + }; }; - // Handler for adding description - const handleAddDescription = () => { - if (!descriptionText.trim()) return; - - setUploadedFiles((prev) => ({ - ...prev, - description: { text: descriptionText.trim() }, - })); - - toast.success("Description added successfully!"); - - // Close modal after successful add - setTimeout(() => { - setModal({ open: false, type: null }); - setDescriptionText(""); // Clear description text - }, 1500); + const toggleSection = (idx) => { + const newExpanded = new Set(expandedSections); + newExpanded.has(idx) ? newExpanded.delete(idx) : newExpanded.add(idx); + setExpandedSections(newExpanded); }; - // Handler for adding notes - const handleAddNotes = () => { - if (!notesText.trim()) return; - - setUploadedFiles((prev) => ({ - ...prev, - notes: { text: notesText.trim() }, - })); - - toast.success("Notes added successfully!"); - - // Close modal after successful add - setTimeout(() => { - setModal({ open: false, type: null }); - setNotesText(""); // Clear notes text - }, 1500); + const toggleLesson = (sIdx, lIdx) => { + const key = `${sIdx}-${lIdx}`; + const newExpanded = new Set(expandedLessons); + newExpanded.has(key) ? newExpanded.delete(key) : newExpanded.add(key); + setExpandedLessons(newExpanded); }; - const renderModalContent = () => { - switch (modal.type) { - case "video": - return ( - <> -
- -
- - - document.getElementById("lesson-video-upload").click() - } - disabled={uploading} - > - {uploading ? "Uploading..." : "Choose Video File"} - - {lessonVideoPreview && ( -
-
-
- setModal({ open: false, type: null })} - > - Cancel - - - {uploading ? "Uploading..." : "Upload Video"} - -
- - ); - case "file": - return ( - <> -
- -
- - - document.getElementById("lesson-file-upload").click() - } - disabled={uploading} - > - {uploading ? "Uploading..." : "Choose File"} - - {lessonFilePreview && ( - - Preview File - - )} -
-
-
- setModal({ open: false, type: null })} - > - Cancel - - - {uploading ? "Uploading..." : "Attach File"} - -
- - ); - case "caption": - return ( - <> -
- - +
+
+
+ + +
+
+ + ); +}; export default function CensorInstructor() { - const [applications, setApplications] = useState(MOCK_APPLICATIONS); - const [actionLoading, setActionLoading] = useState({}); - const [previewImg, setPreviewImg] = useState(null); - const [confirming, setConfirming] = useState({}); - const [query, setQuery] = useState(""); + const [applications, setApplications] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); - const [selectedApp, setSelectedApp] = useState(null); // for details modal - const [hasCertFilter, setHasCertFilter] = useState("all"); - const [sortBy, setSortBy] = useState("newest"); - - const handleAction = (appId, action) => { - // small confirm step: toggle confirming state on first click for destructive actions - if (!confirming[appId]) { - setConfirming((s) => ({ ...s, [appId]: action })); - // Reset the confirmation after 3s - setTimeout(() => { - setConfirming((s) => { - const n = { ...s }; - delete n[appId]; - return n; - }); - }, 3000); + const [dateFilter, setDateFilter] = useState("all"); + const [fromDate, setFromDate] = useState(null); + const [toDate, setToDate] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 6; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedApplication, setSelectedApplication] = useState(null); + const [selectedImage, setSelectedImage] = useState(null); + const [showActionMenu, setShowActionMenu] = useState(false); + const [showRejectModal, setShowRejectModal] = useState(false); + const [selectedReasons, setSelectedReasons] = useState([]); + const [customReason, setCustomReason] = useState(""); + const [isApproving, setIsApproving] = useState(false); + const [isRejecting, setIsRejecting] = useState(false); + const [isSubmittingRejection, setIsSubmittingRejection] = useState(false); + + const fetchApplications = async () => { + setLoading(true); + setError(null); + try { + const response = await getInstructorApplications({ limit: 10000 }); + console.log("API Response:", response.data); // Log the API response + setApplications(response.data.data || []); + } catch (err) { + console.error("API Error:", err); // Log any API errors + setError("Failed to fetch applications. Please try again."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchApplications(); + }, []); + + const filteredApplications = useMemo(() => { + let filtered = applications; + + if (searchTerm) { + filtered = filtered.filter((app) => + [app.firstName, app.lastName, app.email] + .filter(Boolean) + .some((field) => + field.toLowerCase().includes(searchTerm.toLowerCase()) + ) + ); + } + + if (statusFilter !== "all") { + filtered = filtered.filter((app) => app.status === statusFilter); + } + + if (dateFilter === "today") { + filtered = filtered.filter((app) => + dayjs(app.createdAt).isSame(dayjs(), "day") + ); + } else if (dateFilter === "month") { + filtered = filtered.filter((app) => + dayjs(app.createdAt).isSame(dayjs(), "month") + ); + } else if (dateFilter === "custom" && fromDate && toDate) { + filtered = filtered.filter((app) => + dayjs(app.createdAt).isBetween(fromDate, toDate, "day", "[]") + ); + } + + return filtered; + }, [applications, searchTerm, statusFilter, dateFilter, fromDate, toDate]); + + const paginatedApplications = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return filteredApplications.slice(startIndex, startIndex + itemsPerPage); + }, [filteredApplications, currentPage, itemsPerPage]); + + const totalApplications = filteredApplications.length; + const totalPages = Math.ceil(totalApplications / itemsPerPage); + + const openModal = (application) => { + setSelectedApplication(application); + }; + + const closeModal = () => { + setSelectedApplication(null); + setShowActionMenu(false); + setShowRejectModal(false); + setSelectedReasons([]); + setCustomReason(""); + }; + + const openImageViewer = (imageUrl) => { + setSelectedImage(imageUrl); + }; + + const closeImageViewer = () => { + setSelectedImage(null); + }; + + // Predefined rejection reasons + const rejectionReasons = [ + "Thông tin cá nhân không đầy đủ hoặc không chính xác", + "Tài liệu chứng minh chuyên môn không hợp lệ", + "Kinh nghiệm giảng dạy chưa đáp ứng yêu cầu tối thiểu", + "Lĩnh vực chuyên môn không phù hợp với nền tảng", + "Hồ sơ thiếu tài liệu cần thiết", + ]; + + const toggleReason = (reason) => { + if (selectedReasons.includes(reason)) { + setSelectedReasons(selectedReasons.filter((r) => r !== reason)); + } else { + setSelectedReasons([...selectedReasons, reason]); + } + }; + + const openRejectModal = () => { + setShowActionMenu(false); + setShowRejectModal(true); + }; + + const closeRejectModal = () => { + setShowRejectModal(false); + setSelectedReasons([]); + setCustomReason(""); + }; + + const handleApprove = async () => { + if (!selectedApplication) return; + + setIsApproving(true); // Set loading state + + try { + await approveInstructor({ applicationId: selectedApplication._id }); + toast.success("Application approved successfully!"); + closeModal(); + fetchApplications(); + } catch (error) { + console.error("Error approving application:", error); + toast.error("Failed to approve the application. Please try again."); + } finally { + setIsApproving(false); // Reset loading state + } + }; + + const handleReject = async () => { + if (!selectedApplication) return; + + if (selectedReasons.length === 0 && !customReason.trim()) { + toast.warn( + "Please select at least one reason or provide a custom reason." + ); + openRejectModal(); // Ensure modal opens when validation fails return; } - setActionLoading((s) => ({ ...s, [appId]: true })); - setTimeout(() => { - setApplications((prev) => prev.filter((a) => a._id !== appId)); - setActionLoading((s) => { - const n = { ...s }; - delete n[appId]; - return n; - }); - setConfirming((s) => { - const n = { ...s }; - delete n[appId]; - return n; + setIsSubmittingRejection(true); // Set loading state for rejection modal submit + + try { + await denyInstructor({ + applicationId: selectedApplication._id, + reasons: selectedReasons, + customReason: customReason.trim(), }); - }, 600); + toast.success("Application denied successfully!"); + closeRejectModal(); + closeModal(); + fetchApplications(); + } catch (error) { + console.error("Error denying application:", error); + toast.error("Failed to deny the application. Please try again."); + } finally { + setIsSubmittingRejection(false); // Reset loading state + } }; - const openPreview = (src) => setPreviewImg(src); - const closePreview = () => setPreviewImg(null); - - const openDetails = (app) => setSelectedApp(app); - const closeDetails = () => setSelectedApp(null); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - let items = applications.filter((a) => { - if (statusFilter !== "all") return false; // placeholder - if (q) { - const match = - a.user.firstName.toLowerCase().includes(q) || - a.user.lastName.toLowerCase().includes(q) || - a.user.userName.toLowerCase().includes(q) || - a.user.email.toLowerCase().includes(q) || - (a.education || "").toLowerCase().includes(q); - if (!match) return false; - } - if (hasCertFilter === "yes" && !a.certificateImage) return false; - if (hasCertFilter === "no" && a.certificateImage) return false; - return true; - }); - - // sort - if (sortBy === "name") { - items.sort((x, y) => - (x.user.firstName + x.user.lastName).localeCompare( - y.user.firstName + y.user.lastName - ) - ); + const handleSendVerificationEmail = async () => { + if (!selectedApplication) return; + + try { + await resendVerificationLink(selectedApplication.email); + toast.success("Verification email sent successfully!"); + closeModal(); + fetchApplications(); + } catch (error) { + console.error("Error sending verification email:", error); + toast.error("Failed to send verification email. Please try again."); } - // newest/oldest are no-ops because mock data has no date; leave as is - return items; - }, [applications, query, statusFilter, hasCertFilter, sortBy]); + }; return ( -
-

Censor Instructor Applications

- {applications.length === 0 &&

No pending applications.

} -
- setQuery(e.target.value)} - /> - - - - -
- -
- {filtered.map((app) => ( -
-
- avatar -
-
-
-
- {app.user?.firstName} {app.user?.lastName} -
-
{app.user?.userName}
-
-
{app.user?.email}
-
- Education: {app.education || "N/A"} + +
+ {loading && ( +
+
+

Loading applications...

+
+ )} + {!loading && error && ( +
+

{error}

+ +
+ )} + {!loading && !error && ( + <> +
+
+ + setSearchTerm(e.target.value)} + className="amu-search-input" + />
- {app.certificateImage && ( -
- cert openPreview(app.certificateImage)} + +
+ Joined Date: +
+ + + +
+
+ { + if (dates && dates.length === 2) { + setDateFilter("custom"); + setFromDate(dates[0]); + setToDate(dates[1]); + } else { + setDateFilter("all"); + setFromDate(null); + setToDate(null); + } + }} + allowClear />
- )} -
-
- handleAction(app._id, "approve")} - disabled={!!actionLoading[app._id]} - > - {actionLoading[app._id] - ? "Approving..." - : confirming[app._id] === "approve" - ? "Confirm Approve" - : "Approve"} - - handleAction(app._id, "reject")} - disabled={!!actionLoading[app._id]} - > - {actionLoading[app._id] - ? "Rejecting..." - : confirming[app._id] === "reject" - ? "Confirm Reject" - : "Reject"} - - openDetails(app)} - > - Details - +
-
- ))} -
- {/* Preview modal */} - {previewImg && ( -
-
e.stopPropagation()} - > - preview -
- - Close - -
-
-
- )} - - {/* Details modal */} - {selectedApp && ( -
-
e.stopPropagation()} - > -

Application details

-
- Name: {selectedApp.user.firstName}{" "} - {selectedApp.user.lastName} -
-
- Username: {selectedApp.user.userName} +
+ + + + + + + + + + + + + {paginatedApplications.map((app, index) => { + const rowNumber = + (currentPage - 1) * itemsPerPage + index + 1; + return ( + + + + + + + + + ); + })} + +
NO.ApplicantEmailStatusDate AppliedActions
+ {rowNumber} + +
+ { +
+
+ {app.firstName} {app.lastName} +
+
{app.email}
+
+
+
+ {app.email} + + + {app.status} + + + {dayjs(app.createdAt).format("DD/MM/YYYY")} + + +
-
- Email: {selectedApp.user.email} -
-
- Education: {selectedApp.education} + +
+

+ Showing {currentPage * itemsPerPage - itemsPerPage + 1} to{" "} + {Math.min(currentPage * itemsPerPage, totalApplications)} of{" "} + {totalApplications} results +

+
+ + {[...Array(totalPages).keys()].map((num) => ( + + ))} + +
- {selectedApp.certificateImage && ( -
- Certificate: -
- cert + + )} + {/* Modal for application details */} + {selectedApplication && ( +
+
e.stopPropagation()} + > +
+

Application Details

+ +
+ +
+ {/* Personal Information Section */} +
+

Personal Information

+
+
+ Name: + + {selectedApplication.firstName}{" "} + {selectedApplication.lastName} + +
+
+ Email: + + {selectedApplication.email} + +
+
+ Phone: + + {selectedApplication.phone} + +
+
+ Bio: + + {selectedApplication.bio} + +
+
+
+ + {/* Professional Information Section */} +
+

Professional Information

+
+
+ Expertise: +
+ {(selectedApplication.expertise || []).map( + (exp, idx) => ( + + {exp} + + ) + )} +
+
+
+ Experience: + + {selectedApplication.experience} + +
+
+
+ + {/* Banking Information Section */} +
+

Banking Information

+
+
+ Bank Name: + + {selectedApplication.bankName} + +
+
+ Account Number: + + {selectedApplication.accountNumber} + +
+
+ Account Holder: + + {selectedApplication.accountHolderName} + +
+
+
+ + {/* Documents Section */} + {selectedApplication.documents && + selectedApplication.documents.length > 0 && ( +
+

Documents

+
+ {selectedApplication.documents.map((doc, idx) => ( +
+ {`Document openImageViewer(doc)} + /> + + Document {idx + 1} + +
+ ))} +
+
+ )} + + {/* Status and Timeline Section */} +
+

Status & Timeline

+
+
+ Status: + + {selectedApplication.status} + +
+ +
+ Created At: + + {dayjs(selectedApplication.createdAt).format( + "DD/MM/YYYY HH:mm" + )} + +
+
+ Updated At: + + {dayjs(selectedApplication.updatedAt).format( + "DD/MM/YYYY HH:mm" + )} + +
+
+
+
+ +
+ +
+ + {showActionMenu && ( +
+ {selectedApplication?.status === "emailNotVerified" ? ( + + ) : ( + <> + + + + )} +
+ )} +
- )} -
- { - handleAction(selectedApp._id, "approve"); - closeDetails(); - }} - > - Approve - - { - handleAction(selectedApp._id, "reject"); - closeDetails(); - }} - > - Reject - - - Close -
-
- )} -
+ )} + + {/* Rejection Reason Modal */} + + + {/* Image Viewer Modal */} + {selectedImage && ( +
+ +
e.stopPropagation()} + > + Document Preview +
+
+ )} +
+ ); } diff --git a/FrontEnd/src/services/adminService.js b/FrontEnd/src/services/adminService.js index f19f007..2ec62ca 100644 --- a/FrontEnd/src/services/adminService.js +++ b/FrontEnd/src/services/adminService.js @@ -12,13 +12,13 @@ export const updateUserStatus = (userId, status) => export const updateUserRole = (userId, role) => apiClient.put(`/admin/users/${userId}/role`, { role }); -// Get pending instructor applications +// Get instructor requests export const getInstructorApplications = (params) => - apiClient.get(`/admin/instructor-applications`, { params }); + apiClient.get(`/admin/instructor-requests`, { params }); // Approve or reject an instructor application export const reviewInstructorApplication = (applicationId, action) => - apiClient.put(`/admin/instructor-applications/${applicationId}`, { action }); + apiClient.put(`/admin/instructor/${applicationId}`, { action }); // Get all categories export const getAllCategories = () => apiClient.get("/admin/categories"); @@ -101,3 +101,21 @@ export const uploadQuizDocument = async (file, courseId) => { throw error; } }; + +export const approveInstructor = async ({ applicationId }) => { + return await apiClient.post(`/admin/instructors/approve`, { + applicationId, + }); +}; + +export const denyInstructor = async ({ + applicationId, + reasons, + customReason, +}) => { + return await apiClient.post(`/admin/instructors/deny`, { + applicationId, + reasons, + customReason, + }); +}; From 186c15b140720f5d1880b266174241471d0055c3 Mon Sep 17 00:00:00 2001 From: Nguyen Doan Trong Duc <89635693+trongducdoan25@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:15:48 +0700 Subject: [PATCH 09/51] [FLN][Duc][UI] Reapply APIs for instructor [18.10.2025] (#118) --- .../assets/AdminMyCourse/AdminAllCourse.css | 43 + .../CRUDCourseAndLesson/CourseCurriculum.css | 69 +- .../src/assets/WatchCourse/CourseHeader.css | 14 + .../CRUDCourseAndLesson/CourseCurriculum.jsx | 57 +- .../CRUDCourseAndLesson/CourseFormAdvance.jsx | 59 +- .../InstructorAllCourses.jsx | 435 +++++++++ .../InstructorMyCourse/InstructorMyCourse.jsx | 848 ++++++++++++++++++ .../components/WatchCourse/CourseHeader.jsx | 3 +- .../components/WatchCourse/WatchCourse.jsx | 1 + FrontEnd/src/routes/instructorRoutes.jsx | 4 + FrontEnd/src/services/authService.js | 9 +- FrontEnd/src/services/instructorService.js | 112 +++ FrontEnd/src/services/lessonService.js | 21 +- FrontEnd/src/services/uploadService.js | 256 ++++++ 14 files changed, 1841 insertions(+), 90 deletions(-) create mode 100644 FrontEnd/src/components/InstructorMyCourse/InstructorAllCourses.jsx create mode 100644 FrontEnd/src/components/InstructorMyCourse/InstructorMyCourse.jsx create mode 100644 FrontEnd/src/services/uploadService.js diff --git a/FrontEnd/src/assets/AdminMyCourse/AdminAllCourse.css b/FrontEnd/src/assets/AdminMyCourse/AdminAllCourse.css index edca408..e0b8593 100644 --- a/FrontEnd/src/assets/AdminMyCourse/AdminAllCourse.css +++ b/FrontEnd/src/assets/AdminMyCourse/AdminAllCourse.css @@ -6,6 +6,42 @@ color: #1f2937; } +/* Header Section */ +.admin-all-course-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 32px; + background-color: #ffffff; + border-bottom: 1px solid #e5e7eb; +} + +.admin-all-course-title { + font-size: 24px; + font-weight: 600; + color: #1f2937; + margin: 0; +} + +.admin-all-course-create-btn { + padding: 12px 24px; + background-color: #f97316; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.admin-all-course-create-btn:hover { + background-color: #ea580c; +} + /* Filters Section */ .aac-filters { display: flex; @@ -16,6 +52,13 @@ border-bottom: 1px solid #e5e7eb; } +.aac-filter-group { + display: flex; + gap: 16px; + align-items: center; + flex: 1; +} + .aac-search-box { flex: 1; max-width: 400px; diff --git a/FrontEnd/src/assets/CRUDCourseAndLesson/CourseCurriculum.css b/FrontEnd/src/assets/CRUDCourseAndLesson/CourseCurriculum.css index 3255b4c..91bceb1 100644 --- a/FrontEnd/src/assets/CRUDCourseAndLesson/CourseCurriculum.css +++ b/FrontEnd/src/assets/CRUDCourseAndLesson/CourseCurriculum.css @@ -773,10 +773,75 @@ display: flex; gap: 8px; margin-left: 16px; + opacity: 1 !important; + visibility: visible !important; +} + +/* New curriculum-specific styles to avoid conflicts */ +.curriculum-quiz-actions { + display: flex !important; + gap: 8px; + margin-left: 16px; + opacity: 1 !important; + visibility: visible !important; +} + +.curriculum-quiz-edit-btn { + display: inline-flex !important; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px !important; + min-width: 40px; + background-color: #10b981 !important; + color: white !important; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + opacity: 1 !important; + visibility: visible !important; +} + +.curriculum-quiz-edit-btn:hover { + background-color: #059669 !important; + transform: scale(1.05); +} + +.curriculum-quiz-edit-btn:focus { + outline: 2px solid #10b981; + outline-offset: 2px; +} + +.curriculum-quiz-edit-btn.newly-created { + animation: highlightEdit 2s ease-out; +} + +.curriculum-quiz-remove-btn { + display: inline-flex !important; + align-items: center; + justify-content: center; + padding: 8px; + background-color: transparent; + color: #dc2626; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + opacity: 1 !important; + visibility: visible !important; +} + +.curriculum-quiz-remove-btn:hover { + background-color: #fef2f2 !important; + color: #b91c1c !important; + transform: translateY(-1px); } .quiz-edit-button { - display: inline-flex; + display: inline-flex !important; align-items: center; gap: 6px; padding: 8px 16px; @@ -788,6 +853,8 @@ font-weight: 500; cursor: pointer; transition: background-color 0.2s; + opacity: 1 !important; + visibility: visible !important; } .quiz-edit-button:hover { diff --git a/FrontEnd/src/assets/WatchCourse/CourseHeader.css b/FrontEnd/src/assets/WatchCourse/CourseHeader.css index 3bfeff5..d98241f 100644 --- a/FrontEnd/src/assets/WatchCourse/CourseHeader.css +++ b/FrontEnd/src/assets/WatchCourse/CourseHeader.css @@ -133,6 +133,20 @@ color: #fff; } +.f-review-icon-button { + padding: 0.75rem; + min-width: auto; + display: flex; + align-items: center; + justify-content: center; + background: #ffa726; +} + +.f-review-icon-button:hover { + background: #ff9800; + transform: scale(1.05); +} + /* Tablet Styles */ @media (max-width: 1024px) { .f-course-header { diff --git a/FrontEnd/src/components/CRUDCourseAndLesson/CourseCurriculum.jsx b/FrontEnd/src/components/CRUDCourseAndLesson/CourseCurriculum.jsx index 4d4c76a..c9383fc 100644 --- a/FrontEnd/src/components/CRUDCourseAndLesson/CourseCurriculum.jsx +++ b/FrontEnd/src/components/CRUDCourseAndLesson/CourseCurriculum.jsx @@ -16,6 +16,7 @@ import ProgressTabs from "./ProgressTabs"; import CustomButton from "../common/CustomButton/CustomButton"; import { toast } from "react-toastify"; import apiClient from "../../services/authService"; +import { uploadFile } from "../../services/uploadService"; import { uploadWordQuiz, updateQuiz, linkQuizToCourse } from "../../services/quizService"; import { deleteLessonFile, updateLessonFile } from "../../services/lessonService"; import QuizEditorModal from "./QuizEditorModal"; @@ -930,16 +931,13 @@ export default function CourseCurriculum({ handleLessonFieldChange(sIdx, lIdx, "uploadingQuiz", true); try { - // Upload file to get Firebase URL but don't create quiz in DB - const formData = new FormData(); - formData.append("file", file); - formData.append("fileType", "quiz"); - - const uploadResponse = await apiClient.post("/admin/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, + // Upload file using uploadService (auto-detects role) + const uploadResponse = await uploadFile(file, { + courseId: courseId, + fileType: "quiz", }); - if (!uploadResponse.data || !uploadResponse.data.url) { + if (!uploadResponse || !uploadResponse.url) { throw new Error("Failed to upload quiz file"); } @@ -948,12 +946,8 @@ export default function CourseCurriculum({ const title = fileName || "Untitled Quiz"; const description = `Quiz created from ${file.name}`; - // Try to parse quiz from uploaded Word document - try { - // Test API call to parse Word document - const currentSection = sections[sIdx]; - // Parse Word document WITHOUT saving to database + // Parse Word document to extract quiz questions without saving to database const parseResponse = await uploadWordQuiz(file, null, title, description); if (parseResponse.success && parseResponse.data) { @@ -985,7 +979,7 @@ export default function CourseCurriculum({ title: title, description: description, fileName: file.name, - firebaseUrl: uploadResponse.data.url, + firebaseUrl: uploadResponse.url, // ✅ Fixed: uploadService already returns response.data isTemporary: !hasRealQuizId, // Not temporary if has real ID questions: transformedQuestions, estimatedDuration: transformedQuestions.length * 60, @@ -996,7 +990,7 @@ export default function CourseCurriculum({ name: file.name, size: file.size, type: file.type, - url: uploadResponse.data.url + url: uploadResponse.url // ✅ Fixed: uploadService already returns response.data } }; @@ -1030,7 +1024,7 @@ export default function CourseCurriculum({ title: title, description: description, fileName: file.name, - firebaseUrl: uploadResponse.data.url, + firebaseUrl: uploadResponse.url, // ✅ Fixed: uploadService already returns response.data isTemporary: true, // Mark as temporary (not saved to DB) questions: [], // Empty questions for manual editing estimatedDuration: 0, @@ -1039,7 +1033,7 @@ export default function CourseCurriculum({ name: file.name, size: file.size, type: file.type, - url: uploadResponse.data.url + url: uploadResponse.url // ✅ Fixed: uploadService already returns response.data } }; @@ -1084,28 +1078,25 @@ export default function CourseCurriculum({ throw new Error(response.message || "Failed to update file"); } } else { - // New lesson - upload to Firebase only - const formData = new FormData(); - formData.append("file", file); - formData.append("fileType", "lesson"); - - const res = await apiClient.post("/admin/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, + // New lesson - upload using uploadService (auto-detects role) + const uploadResponse = await uploadFile(file, { + courseId: courseId, + fileType: "lesson", }); - if (!res.data || !res.data.url) { + if (!uploadResponse || !uploadResponse.url) { throw new Error("Failed to upload file"); } // Update lesson with new URL and file info - handleLessonFieldChange(sIdx, lIdx, "materialUrl", res.data.url); - handleLessonFieldChange(sIdx, lIdx, "videoUrl", res.data.url); + handleLessonFieldChange(sIdx, lIdx, "materialUrl", uploadResponse.url); + handleLessonFieldChange(sIdx, lIdx, "videoUrl", uploadResponse.url); handleLessonFieldChange(sIdx, lIdx, "materialFile", file); // Create temporary file info for display const fileInfo = { fileName: file.name, - url: res.data.url, + url: uploadResponse.url, uploadedAt: new Date().toISOString(), canDelete: true }; @@ -1175,7 +1166,7 @@ export default function CourseCurriculum({
-
+
diff --git a/FrontEnd/src/components/CRUDCourseAndLesson/CourseFormAdvance.jsx b/FrontEnd/src/components/CRUDCourseAndLesson/CourseFormAdvance.jsx index accd6c7..40be911 100644 --- a/FrontEnd/src/components/CRUDCourseAndLesson/CourseFormAdvance.jsx +++ b/FrontEnd/src/components/CRUDCourseAndLesson/CourseFormAdvance.jsx @@ -5,6 +5,7 @@ import ProgressTabs from "./ProgressTabs"; import Input from "../common/Input"; import CustomButton from "../common/CustomButton/CustomButton"; import apiClient from "../../services/authService"; +import { uploadFile, moveFileToCourse } from "../../services/uploadService"; import { toast } from "react-toastify"; export default function CourseForm({ @@ -63,7 +64,7 @@ export default function CourseForm({ // Move thumbnail if it's in temporary folder if (mediaUrls.thumbnail && mediaUrls.thumbnail.includes("temporary/")) { - const moveRequest = apiClient.post("/admin/move-file", { + const moveRequest = moveFileToCourse({ fromUrl: mediaUrls.thumbnail, courseId: courseId, fileType: "thumbnail", @@ -71,8 +72,8 @@ export default function CourseForm({ movePromises.push( moveRequest .then((res) => { - if (res.data?.url) { - updatedUrls.thumbnail = res.data.url; + if (res?.url) { + updatedUrls.thumbnail = res.url; } }) .catch((err) => { @@ -83,7 +84,7 @@ export default function CourseForm({ // Move trailer if it's in temporary folder if (mediaUrls.trailer && mediaUrls.trailer.includes("temporary/")) { - const moveRequest = apiClient.post("/admin/move-file", { + const moveRequest = moveFileToCourse({ fromUrl: mediaUrls.trailer, courseId: courseId, fileType: "trailer", @@ -91,8 +92,8 @@ export default function CourseForm({ movePromises.push( moveRequest .then((res) => { - if (res.data?.url) { - updatedUrls.trailer = res.data.url; + if (res?.url) { + updatedUrls.trailer = res.url; } }) .catch((err) => { @@ -236,27 +237,18 @@ export default function CourseForm({ // Auto upload immediately setUploadingThumbnail(true); try { - const formData = new FormData(); - formData.append("file", file); - - // Add courseId to help server determine the correct folder - if (courseId) { - formData.append("courseId", courseId); - } - - // Specify this is a course thumbnail - formData.append("fileType", "thumbnail"); - - const res = await apiClient.post("/admin/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, + // Use uploadService which automatically detects role and uses correct endpoint + const response = await uploadFile(file, { + courseId: courseId, + fileType: "thumbnail", }); - if (!res.data || !res.data.url) { - throw new Error(res.data?.message || "Failed to upload thumbnail"); + if (!response || !response.url) { + throw new Error(response?.message || "Failed to upload thumbnail"); } // Set new URL and clear file after successful upload - const newThumbnailUrl = res.data.url; + const newThumbnailUrl = response.url; // Update state immediately setThumbnailUrl(newThumbnailUrl); @@ -295,27 +287,18 @@ export default function CourseForm({ // Auto upload immediately setUploadingTrailer(true); try { - const formData = new FormData(); - formData.append("file", file); - - // Add courseId to help server determine the correct folder - if (courseId) { - formData.append("courseId", courseId); - } - - // Specify this is a course trailer - formData.append("fileType", "trailer"); - - const res = await apiClient.post("/admin/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, + // Use uploadService which automatically detects role and uses correct endpoint + const response = await uploadFile(file, { + courseId: courseId, + fileType: "trailer", }); - if (!res.data || !res.data.url) { - throw new Error(res.data?.message || "Failed to upload trailer"); + if (!response || !response.url) { + throw new Error(response?.message || "Failed to upload trailer"); } // Set new URL and clear file after successful upload - const newTrailerUrl = res.data.url; + const newTrailerUrl = response.url; // Update state immediately setTrailerUrl(newTrailerUrl); diff --git a/FrontEnd/src/components/InstructorMyCourse/InstructorAllCourses.jsx b/FrontEnd/src/components/InstructorMyCourse/InstructorAllCourses.jsx new file mode 100644 index 0000000..2528163 --- /dev/null +++ b/FrontEnd/src/components/InstructorMyCourse/InstructorAllCourses.jsx @@ -0,0 +1,435 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import Card from "../common/Card/Card"; +import { IoClose, IoWarning } from "react-icons/io5"; +import { MdKeyboardArrowLeft, MdKeyboardArrowRight } from "react-icons/md"; +import { FaEye, FaEdit, FaTrash } from "react-icons/fa"; +import { + getInstructorCourses, + deleteCourse, + getAllCategories, +} from "../../services/instructorService"; +import { toast } from "react-toastify"; +import "../../assets/AdminMyCourse/AdminAllCourse.css"; + +const InstructorAllCourses = () => { + const navigate = useNavigate(); + const [sortBy, setSortBy] = useState("latest"); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [ratingFilter, setRatingFilter] = useState("all"); + const [coursesData, setCoursesData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [categories, setCategories] = useState([]); + const [loadingCategories, setLoadingCategories] = useState(false); + + // Delete modal state + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [courseToDelete, setCourseToDelete] = useState(null); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [coursesPerPage] = useState(8); + + useEffect(() => { + fetchCourses(); + fetchCategories(); + }, []); + + const fetchCourses = async () => { + try { + setLoading(true); + setError(null); + const response = await getInstructorCourses(); + + let courses; + if (response.data && response.data.data) { + courses = response.data.data; + } else if (response.data) { + courses = response.data; + } else { + courses = response; + } + + const transformedCourses = courses.map((course) => ({ + id: course._id || course.id, + image: + course.thumbnail || + "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=300&h=200&fit=crop", + category: + course.category || course.categoryIds?.[0]?.name || "DEVELOPMENTS", + categoryBgColor: "#e8f4fd", + categoryTextColor: "#1e40af", + price: course.price ? `$${course.price}` : "$0.00", + originalPrice: course.originalPrice ? `$${course.originalPrice}` : null, + title: course.title || "Untitled Course", + rating: course.rating || 0, + students: + course.studentsCount || + course.enrolledStudents || + course.studentsEnrolled?.length || + "0", + actions: ["View Details", "Edit Course", "Delete Course"], + originalData: course, + })); + + console.log("Instructor courses:", transformedCourses); + setCoursesData(transformedCourses); + } catch (error) { + console.error("Error fetching instructor courses:", error); + setError(error.response?.data?.message || "Failed to fetch courses"); + toast.error("Failed to load courses"); + } finally { + setLoading(false); + } + }; + + const fetchCategories = async () => { + try { + setLoadingCategories(true); + const response = await getAllCategories(); + + let categoriesData; + if (response.data && response.data.data) { + categoriesData = response.data.data; + } else if (response.data) { + categoriesData = response.data; + } else { + categoriesData = response; + } + + const categoryNames = categoriesData.map( + (cat) => cat.name || cat.title || cat + ); + + setCategories(categoryNames); + } catch (error) { + console.error("Error fetching categories:", error); + const fallbackCategories = [ + "Development", + "Design", + "Business", + "Marketing", + "IT & Software", + ]; + setCategories(fallbackCategories); + } finally { + setLoadingCategories(false); + } + }; + + const filteredCourses = coursesData.filter((course) => { + const matchesCategory = + categoryFilter === "all" || + course.category.toLowerCase() === categoryFilter.toLowerCase() || + course.category.toLowerCase().includes(categoryFilter.toLowerCase()); + const matchesRating = + ratingFilter === "all" || + (ratingFilter === "4+" && course.rating >= 4) || + (ratingFilter === "3+" && course.rating >= 3); + + return matchesCategory && matchesRating; + }); + + const sortedCourses = [...filteredCourses].sort((a, b) => { + switch (sortBy) { + case "latest": + return ( + new Date(b.originalData?.createdAt || 0) - + new Date(a.originalData?.createdAt || 0) + ); + case "oldest": + return ( + new Date(a.originalData?.createdAt || 0) - + new Date(b.originalData?.createdAt || 0) + ); + case "popular": + const aCount = Array.isArray(a.originalData?.studentsEnrolled) + ? a.originalData.studentsEnrolled.length + : a.originalData?.enrolledStudents || + a.originalData?.studentsCount || + 0; + const bCount = Array.isArray(b.originalData?.studentsEnrolled) + ? b.originalData.studentsEnrolled.length + : b.originalData?.enrolledStudents || + b.originalData?.studentsCount || + 0; + return bCount - aCount; + case "rating": + return b.rating - a.rating; + default: + return 0; + } + }); + + const totalCourses = sortedCourses.length; + const totalPages = Math.ceil(totalCourses / coursesPerPage); + const indexOfLastCourse = currentPage * coursesPerPage; + const indexOfFirstCourse = indexOfLastCourse - coursesPerPage; + const currentCourses = sortedCourses.slice( + indexOfFirstCourse, + indexOfLastCourse + ); + + const paginate = (pageNumber) => { + if (pageNumber >= 1 && pageNumber <= totalPages) { + setCurrentPage(pageNumber); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }; + + // Define menu actions for course cards + const getMenuActions = (course) => [ + { + label: "View Details", + type: "primary", + icon: , + }, + { + label: "Edit Course", + type: "secondary", + icon: , + }, + { + label: "Delete Course", + type: "danger", + icon: , + }, + ]; + + // Handle menu action (compatible with Card component) + const handleMenuAction = (course, action) => { + switch (action.label) { + case "View Details": + navigate(`/instructor/courses/${course.id}`, { + state: { courseData: course.originalData }, + }); + break; + case "Edit Course": + navigate(`/instructor/courses/edit/${course.id}`, { + state: { courseData: course.originalData }, + }); + break; + case "Delete Course": + setCourseToDelete(course); + setShowDeleteModal(true); + break; + default: + break; + } + }; + + const handleDeleteCourse = async () => { + if (!courseToDelete) return; + + const studentsEnrolled = + courseToDelete.originalData?.enrollmentCount || + (courseToDelete.originalData?.studentsEnrolled + ? courseToDelete.originalData.studentsEnrolled.length + : 0); + + if (studentsEnrolled > 0) { + toast.info( + `Course "${ + courseToDelete.title + }" has ${studentsEnrolled} enrolled student${ + studentsEnrolled > 1 ? "s" : "" + }. Cannot delete.` + ); + setShowDeleteModal(false); + setCourseToDelete(null); + return; + } + + try { + await deleteCourse(courseToDelete.id); + toast.success("Course deleted successfully"); + await fetchCourses(); + setShowDeleteModal(false); + setCourseToDelete(null); + } catch (error) { + console.error("Error deleting course:", error); + toast.error(error.response?.data?.message || "Failed to delete course"); + setShowDeleteModal(false); + setCourseToDelete(null); + } + }; + + return ( +
+
+
+ + + + + +
+
+ + {loading ? ( +
+
+

Loading courses...

+
+ ) : error ? ( +
+

{error}

+
+ ) : currentCourses.length === 0 ? ( +
+

No courses found. Create your first course!

+ +
+ ) : ( + <> +
+ {currentCourses.map((course) => ( +
+ navigate(`/instructor/courses/${course.id}`, { + state: { courseData: course.originalData }, + }) + } + > + handleMenuAction(course, action)} + /> +
+ ))} +
+ + {totalPages > 1 && ( +
+ + + {[...Array(totalPages)].map((_, index) => ( + + ))} + + +
+ )} + + )} + + {/* Delete Confirmation Modal */} + {showDeleteModal && courseToDelete && ( +
+
+
+

Delete Course

+ +
+
+
+ +
+

+ Are you sure you want to delete this course? +

+

+ "{courseToDelete.title}" will be permanently + deleted. This action cannot be undone. +

+
+
+ + +
+
+
+ )} +
+ ); +}; + +export default InstructorAllCourses; diff --git a/FrontEnd/src/components/InstructorMyCourse/InstructorMyCourse.jsx b/FrontEnd/src/components/InstructorMyCourse/InstructorMyCourse.jsx new file mode 100644 index 0000000..dbb1cf9 --- /dev/null +++ b/FrontEnd/src/components/InstructorMyCourse/InstructorMyCourse.jsx @@ -0,0 +1,848 @@ +import React, { useEffect, useState } from "react"; +import { useParams, useLocation, useNavigate } from "react-router-dom"; +import { Line } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler, + ArcElement, +} from "chart.js"; +import "../../assets/AdminMyCourse/AdminMyCourse.css"; +import { getCourseById, deleteCourse } from "../../services/instructorService"; +import { toast } from "react-toastify"; + +// Đăng ký các module cần thiết của Chart.js +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler, + ArcElement +); + +// --- CÁC COMPONENT BIỂU ĐỒ --- + +const RevenueChart = () => { + const data = { + labels: ["Aug 01", "Aug 10", "Aug 20", "Aug 31"], + datasets: [ + { + label: "Revenue", + data: [50000, 150000, 51749, 120000], + borderColor: "#23bd33", + borderWidth: 3, + pointRadius: 0, + pointHoverRadius: 6, + pointHoverBackgroundColor: "#fff", + pointHoverBorderColor: "#23bd33", + pointHoverBorderWidth: 3, + tension: 0.4, + fill: true, + backgroundColor: (context) => { + const ctx = context.chart.ctx; + if (!ctx) return null; + const gradient = ctx.createLinearGradient(0, 0, 0, 200); + gradient.addColorStop(0, "rgba(35, 189, 51, 0.1)"); + gradient.addColorStop(1, "rgba(35, 189, 51, 0)"); + return gradient; + }, + }, + ], + }; + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + backgroundColor: "#000", + titleColor: "#fff", + bodyColor: "#fff", + padding: 10, + cornerRadius: 4, + displayColors: false, + callbacks: { + title: () => null, + label: (context) => `$${context.parsed.y.toLocaleString()}`, + afterBody: (context) => context[0].label.replace("Aug ", "") + " Aug", + }, + }, + }, + scales: { + x: { grid: { display: false }, ticks: { color: "#9ca3af" } }, + y: { + grid: { color: "#e5e7eb", borderDash: [5, 5] }, + ticks: { + color: "#9ca3af", + callback: (value) => { + if (value >= 1000) return `${value / 1000}k`; + return value; + }, + }, + }, + }, + }; + return ; +}; + +const CourseOverviewChart = () => { + const data = { + labels: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + datasets: [ + { + label: "Comments", + data: [120, 190, 150, 220, 180, 250, 210], + borderColor: "#ff6636", + tension: 0.4, + pointRadius: 0, + }, + { + label: "View", + data: [80, 110, 90, 150, 130, 170, 140], + borderColor: "#564ffd", + tension: 0.4, + pointRadius: 0, + }, + ], + }; + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + mode: "index", + intersect: false, + padding: 10, + cornerRadius: 4, + callbacks: { + label: (context) => `${context.dataset.label}: ${context.parsed.y}k`, + }, + }, + }, + scales: { + x: { grid: { display: false }, ticks: { color: "#9ca3af" } }, + y: { + grid: { color: "#e5e7eb" }, + ticks: { + color: "#9ca3af", + callback: (value) => `${value}k`, + }, + }, + }, + }; + return ; +}; + +const RatingLineChart = () => { + const data = { + labels: ["", "", "", "", "", "", ""], + datasets: [ + { + data: [20, 40, 30, 50, 45, 60, 55], + borderColor: "#f97316", + borderWidth: 2.5, + tension: 0.4, + }, + ], + }; + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false } }, + elements: { point: { radius: 0 } }, + }; + return ; +}; + +const StarIcon = ({ filled, className }) => ( + + + +); + +const StatCard = ({ icon, number, label }) => ( +
+
+
{icon.component}
+
+
{number}
+
{label}
+
+
+
+); + +// --- COMPONENT CHÍNH --- +function InstructorMyCourse() { + const { id } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + + const [courseData, setCourseData] = useState( + location.state?.courseData || null + ); + const [loading, setLoading] = useState(!location.state?.courseData); + const [error, setError] = useState(null); + const [showDropdown, setShowDropdown] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + useEffect(() => { + if (!courseData) { + setLoading(true); + setError(null); + getCourseById(id) + .then((res) => { + console.log("Course data from API:", res.data); + setCourseData(res.data); + }) + .catch(() => setError("Course not found or failed to fetch.")) + .finally(() => setLoading(false)); + } + }, [id, courseData]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (!event.target.closest(".amc-dropdown-container")) { + setShowDropdown(false); + } + }; + + if (showDropdown) { + document.addEventListener("click", handleClickOutside); + } + + return () => { + document.removeEventListener("click", handleClickOutside); + }; + }, [showDropdown]); + + // Close modal when pressing ESC + useEffect(() => { + const handleEscKey = (event) => { + if (event.key === "Escape") { + setShowDeleteModal(false); + } + }; + + if (showDeleteModal) { + document.addEventListener("keydown", handleEscKey); + } + + return () => { + document.removeEventListener("keydown", handleEscKey); + }; + }, [showDeleteModal]); + + const handleEdit = () => { + setShowDropdown(false); + navigate(`/instructor/courses/edit/${id}`, { state: { courseData } }); + }; + + const handleDelete = () => { + setShowDropdown(false); + setShowDeleteModal(true); + }; + + const confirmDelete = async () => { + if (courseData) { + const studentsEnrolled = + courseData.enrollmentCount || + (courseData.studentsEnrolled ? courseData.studentsEnrolled.length : 0); + + if (studentsEnrolled > 0) { + toast.info( + `Course "${ + courseData.title + }" has ${studentsEnrolled} enrolled student${ + studentsEnrolled > 1 ? "s" : "" + }. Deletion cancelled for student protection.` + ); + setShowDeleteModal(false); + return; + } + + try { + await deleteCourse(id); + toast.success("Course deleted successfully"); + navigate("/instructor/courses"); + } catch (error) { + console.error("Error deleting course:", error); + toast.error(error.response?.data?.message || "Failed to delete course"); + } finally { + setShowDeleteModal(false); + } + } + }; + + const cancelDelete = () => { + setShowDeleteModal(false); + }; + + const toggleDropdown = () => { + setShowDropdown(!showDropdown); + }; + + const handleTrailerClick = () => { + if (trailerUrl) { + const isYouTube = + trailerUrl.includes("youtube.com") || trailerUrl.includes("youtu.be"); + const isExternalVideo = + trailerUrl.includes("http") && + !trailerUrl.includes(window.location.hostname); + + if (isYouTube || isExternalVideo) { + window.open(trailerUrl, "_blank"); + } else { + window.open(trailerUrl, "_blank"); + } + } else { + navigate(`/instructor/courses/edit/${id}`, { + state: { courseData, focusSection: "trailer" }, + }); + } + }; + + if (loading) return
Loading...
; + if (error || !courseData) + return ( +
+ {error || "Course not found"} +
+ ); + + const studentsEnrolled = + courseData.enrollmentCount || + (courseData.studentsEnrolled ? courseData.studentsEnrolled.length : 0); + const courseImage = + courseData.thumbnail || + courseData.image || + "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=320&h=192&fit=crop"; + const trailerUrl = + courseData.trailerUrl || + courseData.trailer || + courseData.previewVideo || + courseData.videoUrl || + courseData.introVideo || + null; + const coursePrice = courseData.price ? `$${courseData.price}` : "N/A"; + const courseRating = courseData.rating || 0; + const courseLevel = courseData.level || "Beginner"; + const uploaded = courseData.createdAt + ? new Date(courseData.createdAt).toLocaleDateString() + : "-"; + const lastUpdated = courseData.updatedAt + ? new Date(courseData.updatedAt).toLocaleDateString() + : "-"; + + const stats = [ + { + icon: { + component: ( + + + + ), + color: "amc-orange", + }, + number: courseData.sections ? courseData.sections.length : 0, + label: "Sections", + }, + { + icon: { + component: ( + + + + ), + color: "amc-red", + }, + number: studentsEnrolled, + label: "Students enrolled", + }, + { + icon: { + component: ( + + + + ), + color: "amc-green", + }, + number: courseLevel, + label: "Course level", + }, + { + icon: { + component: ( + + + + ), + color: "amc-gray", + }, + number: courseData.duration || "N/A", + label: "Hours", + }, + ]; + + return ( + <> +
+
+ navigate("/instructor/courses")} + className="amc-breadcrumb-link" + > + My Courses + + / + {courseData.title} +
+
+ +
+
+
+
+
+
+ Course thumbnail +
+
+
+

+ Uploaded: {uploaded}
Last Updated:{" "} + {lastUpdated} +

+

{courseData.title}

+

+ {courseData.detail?.description || + courseData.description} +

+
+
+ +
+ + {showDropdown && ( +
+ + +
+ )} +
+
+
+
+
+ + Created by: + + + {courseData.instructorName || "Me"} + +
+
+ {[...Array(5)].map((_, i) => ( + + ))} + {courseRating} + + ({studentsEnrolled} Ratings) + +
+
+
+
+

{coursePrice}

+

Course prices

+
+
+

$0.00

+

USD dollar revenue

+
+
+
+
+
+
+ + {/* Course Trailer Section */} +
+
+

Course Trailer

+
+
+
+ {trailerUrl ? ( + + ) : ( +
+
+
+ + + +
+
+

Add Course Trailer

+

Click to add a trailer to showcase your course

+
+
+ Course preview +
+ )} +
+
+
+
+ + {/* Sidebar */} +
+
+
+

Overall Course Rating

+ +
+
+
+
+
+ {courseRating} +
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
Course Rating
+
+
+ +
+
+
+ {[ + { stars: 5, percentage: 67 }, + { stars: 4, percentage: 27 }, + { stars: 3, percentage: 5 }, + { stars: 2, percentage: 1 }, + { stars: 1, percentage: 1, isLessThan: true }, + ].map((rating) => ( +
+
+ {[...Array(5)].map((_, i) => ( + + ))} + + {rating.stars} Star + +
+
+
+
+ + {rating.isLessThan + ? `<${rating.percentage}` + : rating.percentage} + % + +
+ ))} +
+
+
+
+
+ + {/* Stats */} +
+ {stats.map((stat, index) => ( + + ))} +
+ + {/* Charts */} +
+
+
+

Revenue

+ +
+
+
+ +
+
+
+
+
+

Course Overview

+
+
+
+ + Comments + +
+
+
+ + View + +
+ +
+
+
+
+ +
+
+
+
+
+ + {/* Delete Confirmation Modal */} + {showDeleteModal && + courseData && + (() => { + const studentsEnrolled = + courseData.enrollmentCount || + (courseData.studentsEnrolled + ? courseData.studentsEnrolled.length + : 0); + const hasStudents = studentsEnrolled > 0; + + return ( +
+
+
+

+ {hasStudents ? "Course Information" : "Delete Course"} +

+ +
+
+
+ + + +
+

+ {hasStudents + ? "Cannot Delete Course with Enrolled Students" + : "Are you sure you want to delete this course?"} +

+

+ {hasStudents ? ( + <> + "{courseData.title}" currently has{" "} + + {studentsEnrolled} enrolled student + {studentsEnrolled > 1 ? "s" : ""} + + . This course cannot be deleted to protect student + learning progress and data. + + ) : ( + <> + "{courseData.title}" will be + permanently deleted. This action cannot be undone and + all course data will be lost. + + )} +

+
+

+ Students enrolled: {studentsEnrolled} +

+ {hasStudents && ( +

+ ⚠️ To delete this course, please wait for students to + complete their learning or contact support for + assistance. +

+ )} +
+
+
+ + {!hasStudents && ( + + )} +
+
+
+ ); + })()} + + ); +} + +export default InstructorMyCourse; diff --git a/FrontEnd/src/components/WatchCourse/CourseHeader.jsx b/FrontEnd/src/components/WatchCourse/CourseHeader.jsx index 236cd44..b8c884e 100644 --- a/FrontEnd/src/components/WatchCourse/CourseHeader.jsx +++ b/FrontEnd/src/components/WatchCourse/CourseHeader.jsx @@ -13,6 +13,7 @@ const CourseHeader = ({ onNextLecture, showReviewButton = true, isLastLesson = false, + allLessonsCompleted = false, }) => { return (
@@ -66,7 +67,7 @@ const CourseHeader = ({ Complete all lessons to write a review
)} - {!isLastLesson && ( + {!allLessonsCompleted && !isLastLesson && ( diff --git a/FrontEnd/src/components/WatchCourse/WatchCourse.jsx b/FrontEnd/src/components/WatchCourse/WatchCourse.jsx index 1b35fd5..1418dfc 100644 --- a/FrontEnd/src/components/WatchCourse/WatchCourse.jsx +++ b/FrontEnd/src/components/WatchCourse/WatchCourse.jsx @@ -363,6 +363,7 @@ const WatchCourse = ({ courseId: propCourseId }) => { reviewMode={!!courseFeedback} onNextLecture={handleNextLecture} showReviewButton={allLessonsCompleted} + allLessonsCompleted={allLessonsCompleted} isLastLesson={(() => { if (!sections?.length || !currentLesson?._id) return false; const flat = sections.flatMap(s => s.lessons || []); diff --git a/FrontEnd/src/routes/instructorRoutes.jsx b/FrontEnd/src/routes/instructorRoutes.jsx index dd0a794..b62e84a 100644 --- a/FrontEnd/src/routes/instructorRoutes.jsx +++ b/FrontEnd/src/routes/instructorRoutes.jsx @@ -4,11 +4,15 @@ import { Route } from "react-router-dom"; import InstructorDashboard from "../components/InstructorDashboard/InstructorDashboard"; import InstructorDiscount from "../components/InstructorDiscount/InstructorDiscount"; import CourseWizard from "../components/CRUDCourseAndLesson/CourseWizard"; +import InstructorMyCourse from "../components/InstructorMyCourse/InstructorMyCourse"; +import InstructorAllCourses from "../components/InstructorMyCourse/InstructorAllCourses"; const instructorRoutesContent = ( <> } /> } /> + } /> + } /> } /> } /> diff --git a/FrontEnd/src/services/authService.js b/FrontEnd/src/services/authService.js index bab0b79..3bac6ca 100644 --- a/FrontEnd/src/services/authService.js +++ b/FrontEnd/src/services/authService.js @@ -4,10 +4,11 @@ import { store } from "../store"; import { logout } from "../store/authSlice"; // Tạo một instance của axios với cấu hình chung +const API_BASE_URL = process.env.REACT_APP_API_URL ; +console.log("REACT_APP_API_URL (build-time) =>", process.env.REACT_APP_API_URL); +console.log("API_BASE_URL (runtime) =>", API_BASE_URL); const apiClient = axios.create({ - baseURL: - // "https://flearning-api-a5h6hbcphdcbhndv.southeastasia-01.azurewebsites.net/api", - "https://flearning-api-a5h6hbcphdcbhndv.southeastasia-01.azurewebsites.net/api", + baseURL: API_BASE_URL, withCredentials: true, }); @@ -74,7 +75,7 @@ apiClient.interceptors.response.use( try { const { data } = await axios.post( - "https://flearning-api-a5h6hbcphdcbhndv.southeastasia-01.azurewebsites.net/api/auth/refresh-token", + `${API_BASE_URL}/auth/refresh-token`, {}, { withCredentials: true } ); diff --git a/FrontEnd/src/services/instructorService.js b/FrontEnd/src/services/instructorService.js index 04ded28..3539270 100644 --- a/FrontEnd/src/services/instructorService.js +++ b/FrontEnd/src/services/instructorService.js @@ -72,3 +72,115 @@ export const uploadQuizDocument = async (file, courseId) => { throw error; } }; + +// ========== COURSE MANAGEMENT APIs ========== + +// Create a new course +export const createCourse = async (courseData) => { + try { + return await apiClient.post("/instructor/courses", courseData); + } catch (error) { + console.error("Error creating course:", error); + throw error; + } +}; + +// Update existing course +export const updateCourse = async (courseId, courseData) => { + try { + return await apiClient.put(`/instructor/courses/${courseId}`, courseData); + } catch (error) { + console.error("Error updating course:", error); + throw error; + } +}; + +// Delete course by ID +export const deleteCourse = async (courseId) => { + try { + return await apiClient.delete(`/instructor/courses/${courseId}`); + } catch (error) { + console.error("Error deleting course:", error); + throw error; + } +}; + +// ========== SECTION MANAGEMENT APIs ========== + +// Create a new section in a course +export const createSection = async (courseId, sectionData) => { + try { + return await apiClient.post( + `/instructor/courses/${courseId}/sections`, + sectionData + ); + } catch (error) { + console.error("Error creating section:", error); + throw error; + } +}; + +// Update existing section +export const updateSection = async (courseId, sectionId, sectionData) => { + try { + return await apiClient.put( + `/instructor/courses/${courseId}/sections/${sectionId}`, + sectionData + ); + } catch (error) { + console.error("Error updating section:", error); + throw error; + } +}; + +// Delete section by ID +export const deleteSection = async (courseId, sectionId) => { + try { + return await apiClient.delete( + `/instructor/courses/${courseId}/sections/${sectionId}` + ); + } catch (error) { + console.error("Error deleting section:", error); + throw error; + } +}; + +// ========== LESSON MANAGEMENT APIs ========== + +// Create a new lesson in a section +export const createLesson = async (courseId, sectionId, lessonData) => { + try { + return await apiClient.post( + `/instructor/courses/${courseId}/sections/${sectionId}/lessons`, + lessonData + ); + } catch (error) { + console.error("Error creating lesson:", error); + throw error; + } +}; + +// Update existing lesson +export const updateLesson = async (courseId, lessonId, lessonData) => { + try { + return await apiClient.put( + `/instructor/courses/${courseId}/lessons/${lessonId}`, + lessonData + ); + } catch (error) { + console.error("Error updating lesson:", error); + throw error; + } +}; + +// Delete lesson by ID +export const deleteLesson = async (courseId, lessonId) => { + try { + return await apiClient.delete( + `/instructor/courses/${courseId}/lessons/${lessonId}` + ); + } catch (error) { + console.error("Error deleting lesson:", error); + throw error; + } +}; diff --git a/FrontEnd/src/services/lessonService.js b/FrontEnd/src/services/lessonService.js index bfea84b..99e6ff4 100644 --- a/FrontEnd/src/services/lessonService.js +++ b/FrontEnd/src/services/lessonService.js @@ -1,9 +1,10 @@ import apiClient from "./authService"; +import { uploadFile as uploadFileService } from "./uploadService"; // Delete lesson file (video/article) export const deleteLessonFile = async (lessonId) => { try { - const response = await apiClient.delete(`/admin/lessons/${lessonId}/file`); + const response = await apiClient.delete(`/instructor/lessons/${lessonId}/file`); return response.data; } catch (error) { console.error("Error deleting lesson file:", error); @@ -18,30 +19,24 @@ export const deleteLessonFile = async (lessonId) => { // Update lesson file (video/article) export const updateLessonFile = async (lessonId, file) => { try { - // First, upload the file to get the URL - const formData = new FormData(); - formData.append("file", file); - formData.append("fileType", "lesson"); - - const uploadResponse = await apiClient.post("/admin/upload", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, + // Upload the file using uploadService (auto-detects role) + const uploadResponse = await uploadFileService(file, { + fileType: "lesson", }); - if (!uploadResponse.data || !uploadResponse.data.url) { + if (!uploadResponse || !uploadResponse.url) { throw new Error("Failed to upload file"); } // Then, update the lesson with the file URL const updateData = { - fileUrl: uploadResponse.data.url, + fileUrl: uploadResponse.url, fileName: file.name, fileSize: file.size, fileType: file.type }; - const response = await apiClient.put(`/admin/lessons/${lessonId}/file`, updateData, { + const response = await apiClient.put(`/instructor/lessons/${lessonId}/file`, updateData, { headers: { "Content-Type": "application/json", }, diff --git a/FrontEnd/src/services/uploadService.js b/FrontEnd/src/services/uploadService.js new file mode 100644 index 0000000..8f3654b --- /dev/null +++ b/FrontEnd/src/services/uploadService.js @@ -0,0 +1,256 @@ +import apiClient from "./authService"; + +/** + * Upload Service - Automatically determines correct endpoint based on user role + * Supports both Admin and Instructor file uploads + */ + +/** + * Get user role from localStorage or Redux store + * @returns {string} User role ('admin', 'instructor', 'student', etc.) + */ +const getUserRole = () => { + try { + const currentUser = JSON.parse(localStorage.getItem("currentUser")); + return currentUser?.role?.toLowerCase() || "student"; + } catch (error) { + console.error("Error getting user role:", error); + return "student"; + } +}; + +/** + * Get role-based endpoint helper function + * @param {string} instructorPath - Path for instructor role + * @param {string} adminPath - Path for admin role + * @returns {string} Appropriate endpoint based on user role + */ +const getRoleBasedEndpoint = (instructorPath, adminPath) => { + const role = getUserRole(); + + if (role === "instructor") { + return instructorPath; + } + + if (role === "admin") { + return adminPath; + } + + // Default to instructor for backward compatibility + return instructorPath; +}; + +/** + * Get upload endpoint based on user role + * @returns {string} Upload endpoint path + */ +const getUploadEndpoint = () => { + return getRoleBasedEndpoint("/instructor/upload", "/admin/upload"); +}; + +/** + * Get move-file endpoint based on user role + * @returns {string} Move file endpoint path + */ +const getMoveFileEndpoint = () => { + return getRoleBasedEndpoint("/instructor/move-to-course", "/admin/move-file"); +}; + +/** + * Get delete file endpoint based on user role + * @returns {string} Delete file endpoint path + */ +const getDeleteFileEndpoint = () => { + return getRoleBasedEndpoint("/instructor/files", "/admin/files"); +}; + +/** + * Upload a file (thumbnail, trailer, lesson video, etc.) + * @param {File} file - File to upload + * @param {Object} options - Upload options + * @param {string} options.courseId - Course ID (optional) + * @param {string} options.fileType - File type (thumbnail, trailer, video, etc.) + * @param {Function} options.onUploadProgress - Progress callback + * @returns {Promise} Upload response with URL + */ +export const uploadFile = async (file, options = {}) => { + const { courseId, fileType, onUploadProgress } = options; + + try { + const formData = new FormData(); + formData.append("file", file); + + // Add optional metadata + if (courseId) { + formData.append("courseId", courseId); + } + + if (fileType) { + formData.append("fileType", fileType); + } + + // Get correct endpoint based on role + const endpoint = getUploadEndpoint(); + + console.log(`[Upload Service] Uploading to: ${endpoint}`, { + fileType, + courseId, + role: getUserRole(), + }); + + const response = await apiClient.post(endpoint, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + onUploadProgress: onUploadProgress + ? (progressEvent) => { + const percentCompleted = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ); + onUploadProgress(percentCompleted); + } + : undefined, + }); + + if (!response.data || !response.data.url) { + throw new Error(response.data?.message || "Upload failed - no URL returned"); + } + + console.log(`[Upload Service] Upload successful:`, response.data.url); + return response.data; + } catch (error) { + console.error("[Upload Service] Upload error:", error); + + // Provide more detailed error message + const errorMessage = error.response?.data?.message + || error.message + || "Failed to upload file"; + + throw new Error(errorMessage); + } +}; + +/** + * Move file from temporary folder to course folder + * @param {Object} params - Move parameters + * @param {string} params.fromUrl - Source file URL (in temporary folder) + * @param {string} params.courseId - Target course ID + * @param {string} params.fileType - File type (thumbnail, trailer, video) + * @returns {Promise} Response with new URL + */ +export const moveFileToCourse = async ({ fromUrl, courseId, fileType }) => { + try { + const endpoint = getMoveFileEndpoint(); + + console.log(`[Upload Service] Moving file to course:`, { + endpoint, + fromUrl, + courseId, + fileType, + }); + + const response = await apiClient.post(endpoint, { + fromUrl, + courseId, + fileType, + }); + + if (!response.data || !response.data.url) { + throw new Error(response.data?.message || "Failed to move file"); + } + + console.log(`[Upload Service] File moved successfully:`, response.data.url); + return response.data; + } catch (error) { + console.error("[Upload Service] Move file error:", error); + + const errorMessage = error.response?.data?.message + || error.message + || "Failed to move file"; + + throw new Error(errorMessage); + } +}; + +/** + * Delete a file + * @param {string} fileUrl - URL of file to delete + * @returns {Promise} Delete response + */ +export const deleteFile = async (fileUrl) => { + try { + const endpoint = getDeleteFileEndpoint(); + + console.log(`[Upload Service] Deleting file:`, { + endpoint, + fileUrl, + }); + + const response = await apiClient.delete(endpoint, { + data: { fileUrl }, + }); + + console.log(`[Upload Service] File deleted successfully`); + return response.data; + } catch (error) { + console.error("[Upload Service] Delete file error:", error); + + const errorMessage = error.response?.data?.message + || error.message + || "Failed to delete file"; + + throw new Error(errorMessage); + } +}; + +/** + * Get temporary files by folder type + * @param {string} folderType - Folder type (thumbnail, trailer, video, etc.) + * @returns {Promise} List of temporary files + */ +export const getTemporaryFiles = async (folderType) => { + try { + const role = getUserRole(); + const endpoint = role === "instructor" + ? `/instructor/temporary-files/${folderType}` + : `/admin/temporary-files/${folderType}`; + + const response = await apiClient.get(endpoint); + return response.data; + } catch (error) { + console.error("[Upload Service] Get temporary files error:", error); + throw error; + } +}; + +/** + * Get course files by folder type + * @param {string} courseId - Course ID + * @param {string} folderType - Folder type (thumbnail, trailer, video, etc.) + * @returns {Promise} List of course files + */ +export const getCourseFiles = async (courseId, folderType) => { + try { + const role = getUserRole(); + const endpoint = role === "instructor" + ? `/instructor/courses/${courseId}/files/${folderType}` + : `/admin/courses/${courseId}/files/${folderType}`; + + const response = await apiClient.get(endpoint); + return response.data; + } catch (error) { + console.error("[Upload Service] Get course files error:", error); + throw error; + } +}; + +const uploadServiceDefault = { + uploadFile, + moveFileToCourse, + deleteFile, + getTemporaryFiles, + getCourseFiles, + getUserRole, +}; + +export default uploadServiceDefault; From 960990a0eceffefde61b656d9dd9c64d036fe6a4 Mon Sep 17 00:00:00 2001 From: Huynh Dinh Thien <136036261+Nomsociuu@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:50:19 +0700 Subject: [PATCH 10/51] [FLN-130][Thien][UI] Implement PayOs payment code [18.10.2025] (#119) --- .../components/CourseDetails/PricingCard.jsx | 284 +++++++----------- .../components/ShoppingCart/PayOSPayment.jsx | 75 +++++ .../ShoppingCart/PaymentCancelledPage.jsx | 108 +++++++ .../ShoppingCart/PaymentSuccessPage.jsx | 144 +++++++++ .../components/ShoppingCart/QRCodePayment.jsx | 43 ++- FrontEnd/src/routes/mainRoutes.jsx | 4 + FrontEnd/src/services/paymentService.js | 74 +++-- 7 files changed, 509 insertions(+), 223 deletions(-) create mode 100644 FrontEnd/src/components/ShoppingCart/PayOSPayment.jsx create mode 100644 FrontEnd/src/components/ShoppingCart/PaymentCancelledPage.jsx create mode 100644 FrontEnd/src/components/ShoppingCart/PaymentSuccessPage.jsx diff --git a/FrontEnd/src/components/CourseDetails/PricingCard.jsx b/FrontEnd/src/components/CourseDetails/PricingCard.jsx index a4a7da5..15abfba 100644 --- a/FrontEnd/src/components/CourseDetails/PricingCard.jsx +++ b/FrontEnd/src/components/CourseDetails/PricingCard.jsx @@ -1,13 +1,22 @@ -import { Clock, BarChart3, Users, Book, Subtitles, Layers } from "lucide-react"; -import { Copy } from "lucide-react"; +import { + Clock, + BarChart3, + Users, + Book, + Subtitles, + Layers, + Copy, +} from "lucide-react"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; import { addToWishlist, getWishlist } from "../../services/wishlistService"; import { toast } from "react-toastify"; import { addToCart } from "../../services/cartService"; import { useState } from "react"; -import QRCodePayment from "../ShoppingCart/QRCodePayment"; -import { useEffect } from "react"; +import Swal from "sweetalert2"; + +// THÊM: Import service thanh toán mới để sử dụng trong `ActionButtons` +import { createPayOSLink } from "../../services/paymentService"; const ICON_MAP = { Level: BarChart3, @@ -24,70 +33,19 @@ const formatDiscount = (str) => { }; const capitalizeFirstLetter = (str) => { - if (!str) return ""; - const firstLetterIndex = str.search(/[a-zA-Z]/); + const stringValue = String(str || ""); + if (!stringValue) return ""; + const firstLetterIndex = stringValue.search(/[a-zA-Z]/); if (firstLetterIndex > 0) { return ( - str.slice(0, firstLetterIndex) + - str.charAt(firstLetterIndex).toUpperCase() + - str.slice(firstLetterIndex + 1) + stringValue.slice(0, firstLetterIndex) + + stringValue.charAt(firstLetterIndex).toUpperCase() + + stringValue.slice(firstLetterIndex + 1) ); } - return str.charAt(0).toUpperCase() + str.slice(1); + return stringValue.charAt(0).toUpperCase() + stringValue.slice(1); }; -// --- Modal Component for the QR Code --- -function QRPaymentModal({ isOpen, onClose, children }) { - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - > - - {children} -
-
- ); -} - const PriceSection = ({ currentPrice, originalPrice, discount, timeLeft }) => (
@@ -183,77 +141,81 @@ const ShareSection = ({ buttons }) => { ); }; -const ActionButtons = ({ onBuyNowClick }) => { +// Component ActionButtons được cập nhật để xử lý luồng thanh toán mới +const ActionButtons = ({ course }) => { const { courseId } = useParams(); const navigate = useNavigate(); const dispatch = useDispatch(); - const currentUser = useSelector((state) => state.auth.currentUser); - /* cart slice ------------------------------------------------------------ */ - const { isLoading: isLoadingCart, errorMsg: errorMsgCart } = useSelector( + // State loading riêng cho nút "Buy Now" + const [isBuying, setIsBuying] = useState(false); + const { isLoading: isLoadingCart } = useSelector( (state) => state.cart.addItemToCart ); + const { isLoading: isLoadingWishlist } = useSelector( + (state) => state.wishlist.addItemToWishlist + ); - /* wishlist slice -------------------------------------------------------- */ - const { isLoading: isLoadingWishlist, errorMsg: errorMsgWishlist } = - useSelector((state) => state.wishlist.addItemToWishlist); - - /* helpers ----------------------------------------------------------------*/ const isEnrolledCourse = (id) => currentUser?.enrolledCourses?.includes(id); - /* handlers ---------------------------------------------------------------*/ const handleAddToWishList = async () => { if (!currentUser) return navigate("/login"); - try { await addToWishlist(currentUser._id, courseId, dispatch); await getWishlist(dispatch, currentUser._id); - toast.success("Added to wishlist successfully!", { - position: "top-right", - autoClose: 3000, - }); - } catch { - toast.error( - errorMsgWishlist || "Failed to add to wishlist. Please try again.", - { position: "top-right", autoClose: 3000 } - ); + toast.success("Added to wishlist successfully!"); + } catch (err) { + toast.error("Failed to add to wishlist. Please try again."); } }; const handleAddToCart = async () => { if (!currentUser) return navigate("/login"); - try { await addToCart(currentUser._id, courseId, dispatch); - toast.success("Add course to cart success", { - position: "top-right", - autoClose: 3000, - }); - } catch { - toast.error( - errorMsgCart || "Error adding course to cart, please try again", - { position: "top-right", autoClose: 3000 } - ); + toast.success("Add course to cart success"); + } catch (err) { + toast.error("Error adding course to cart, please try again"); } }; - const handleBuyNow = () => { + // Logic "Buy Now" mới, gọi thẳng đến PayOS + const handleBuyNow = async () => { if (!currentUser) return navigate("/login"); - if (typeof onBuyNowClick === "function") { - onBuyNowClick(); // use caller‑supplied handler - } else { - handleAddToCart(); // fallback: just add to cart first - navigate("/cart"); // and push user to checkout page + if (!course) { + toast.error("Course information is missing."); + return; + } + + setIsBuying(true); + try { + const paymentData = { + description: `TT khoa hoc ${course._id.slice(-6)}`, + price: 2000, + packageType: "COURSE_PURCHASE", + courseIds: [course._id], + cancelUrl: window.location.href, + }; + + const { checkoutUrl } = await createPayOSLink(paymentData); + + if (checkoutUrl) { + window.location.href = checkoutUrl; + } else { + throw new Error("Checkout URL not received."); + } + } catch (error) { + console.error("Error creating payment link:", error); + // ... xử lý lỗi + setIsBuying(false); } }; - /* render -----------------------------------------------------------------*/ return (
{!isEnrolledCourse(courseId) ? ( <> - {/* Add‑to‑Cart ---------------------------------------------------- */} - {/* Buy‑Now ------------------------------------------------------- */} ) : ( - /* Already enrolled => show “Go To Course” */ )} - {/* Wishlist / Gift buttons ------------------------------------------- */}
- {/* implement your own gift flow later */} @@ -340,69 +292,45 @@ export default function PricingCard({ details, includes, shareButtons, - course, // Accept the full course object + course, }) { - // State to manage the QR code modal - const [isModalOpen, setIsModalOpen] = useState(false); - const currentUser = useSelector((state) => state.auth.currentUser); return ( - <> -
-
-
- - ({ - label: capitalizeFirstLetter(label), - value: capitalizeFirstLetter(value), - }))} - /> - setIsModalOpen(true)} /> -

- Note: all courses have 30-days money-back guarantee -

- - ({ - ...btn, - label: capitalizeFirstLetter(btn.label), - }))} - /> -
-
-
- - {/* --- Render the Modal with QRCodePayment --- */} - setIsModalOpen(false)} +
+
- {course ? ( - + - ) : ( -
-

Loading payment details...

-
- )} - - + ({ + label: capitalizeFirstLetter(label), + value: capitalizeFirstLetter(value), + }))} + /> + + {/* Truyền toàn bộ object `course` vào ActionButtons */} + + +

+ Note: all courses have 30-days money-back guarantee +

+ + ({ + ...btn, + label: capitalizeFirstLetter(btn.label), + }))} + /> +
+
+
); } diff --git a/FrontEnd/src/components/ShoppingCart/PayOSPayment.jsx b/FrontEnd/src/components/ShoppingCart/PayOSPayment.jsx new file mode 100644 index 0000000..369814a --- /dev/null +++ b/FrontEnd/src/components/ShoppingCart/PayOSPayment.jsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { useSelector } from "react-redux"; +import Swal from "sweetalert2"; +import { createPayOSLink } from "../../services/paymentService"; + +export default function PayOSPayment({ amount, coursesInCart }) { + const currentUser = useSelector((state) => state.auth.currentUser); + const [isLoading, setIsLoading] = useState(false); + + const handlePayment = async () => { + if (!currentUser) { + Swal.fire("Lỗi", "Vui lòng đăng nhập để thực hiện thanh toán.", "error"); + return; + } + + setIsLoading(true); + + try { + const paymentData = { + description: `Thanh toán cho ${coursesInCart.length} khóa học`, + price: amount, + packageType: "COURSE_PURCHASE", + courseIds: coursesInCart.map((course) => course._id), + }; + + // 1. Gọi API backend để lấy checkoutUrl + const { checkoutUrl } = await createPayOSLink(paymentData); + + // 2. Chuyển hướng người dùng đến cổng thanh toán PayOS + if (checkoutUrl) { + window.location.href = checkoutUrl; + } else { + throw new Error("Không nhận được đường link thanh toán."); + } + } catch (error) { + console.error("Lỗi khi tạo link thanh toán:", error); + Swal.fire( + "Đã có lỗi xảy ra", + error.message || "Không thể tạo link thanh toán. Vui lòng thử lại.", + "error" + ); + setIsLoading(false); + } + }; + + return ( +
+

Xác nhận thanh toán

+
+ Tổng số tiền: + + {amount.toLocaleString("vi-VN")} VND + +
+ +
+ ); +} diff --git a/FrontEnd/src/components/ShoppingCart/PaymentCancelledPage.jsx b/FrontEnd/src/components/ShoppingCart/PaymentCancelledPage.jsx new file mode 100644 index 0000000..dfe93d6 --- /dev/null +++ b/FrontEnd/src/components/ShoppingCart/PaymentCancelledPage.jsx @@ -0,0 +1,108 @@ +import React, { useEffect } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import Swal from "sweetalert2"; +import "sweetalert2/dist/sweetalert2.min.css"; + +import apiClient from "../../services/authService"; + +// --- BẮT ĐẦU KHỐI CSS --- +// Toàn bộ CSS từ file .css được đặt trong một biến string +const css = ` +.payment-cancelled-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 80vh; + text-align: center; + color: #555; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +.loader { + border: 8px solid #f3f3f3; /* Light grey */ + border-top: 8px solid #e07181; /* Theme color */ + border-radius: 50%; + width: 60px; + height: 60px; + animation: spin 1.5s linear infinite; + margin-bottom: 20px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.payment-cancelled-container h2 { + font-size: 1.5rem; + margin-bottom: 10px; +} + +.payment-cancelled-container p { + font-size: 1rem; +} +`; +// --- KẾT THÚC KHỐI CSS --- + +const PaymentCancelledPage = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const orderCode = searchParams.get("orderCode"); + + useEffect(() => { + if (orderCode) { + const cancelOrderAndShowAlert = async () => { + try { + await apiClient.put(`/payment/cancel/${orderCode}`); + console.log(`Đã gửi yêu cầu hủy cho đơn hàng: ${orderCode}`); + + Swal.fire({ + title: "Đã hủy giao dịch", + text: "Đơn hàng của bạn đã được hủy trong hệ thống. Bạn sẽ được chuyển về giỏ hàng.", + icon: "info", + confirmButtonText: "Đồng ý", + timer: 3000, + timerProgressBar: true, + }).then(() => { + navigate("/"); + }); + } catch (error) { + console.error("Lỗi khi hủy đơn hàng:", error); + + Swal.fire({ + title: "Có lỗi xảy ra", + text: "Không thể gửi yêu cầu hủy đơn hàng. Vui lòng thử lại sau.", + icon: "error", + confirmButtonText: "Về trang chủ", + }).then(() => { + navigate("/"); + }); + } + }; + + cancelOrderAndShowAlert(); + } else { + navigate("/"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orderCode, navigate]); + + return ( + <> + {/* Thẻ style sẽ chèn toàn bộ CSS ở trên vào trang */} + +
+
+

Đang xử lý hủy đơn hàng...

+

Vui lòng chờ trong giây lát.

+
+ + ); +}; + +export default PaymentCancelledPage; diff --git a/FrontEnd/src/components/ShoppingCart/PaymentSuccessPage.jsx b/FrontEnd/src/components/ShoppingCart/PaymentSuccessPage.jsx new file mode 100644 index 0000000..40f338f --- /dev/null +++ b/FrontEnd/src/components/ShoppingCart/PaymentSuccessPage.jsx @@ -0,0 +1,144 @@ +import React, { useEffect, useRef } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import Swal from "sweetalert2"; +import "sweetalert2/dist/sweetalert2.min.css"; + +import apiClient from "../../services/authService"; + +// --- BẮT ĐẦU KHỐI CSS --- +const css = ` +.payment-success-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 80vh; + text-align: center; + color: #555; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +.loader { + border: 8px solid #f3f3f3; /* Light grey */ + border-top: 8px solid #28a745; /* Success color */ + border-radius: 50%; + width: 60px; + height: 60px; + animation: spin 1.5s linear infinite; + margin-bottom: 20px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.payment-success-container h2 { + font-size: 1.5rem; + margin-bottom: 10px; +} + +.payment-success-container p { + font-size: 1rem; +} +`; +// --- KẾT THÚC KHỐI CSS --- + +const PaymentSuccessPage = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const orderCode = searchParams.get("orderCode"); + + // Dùng useRef để tránh useEffect chạy lại không cần thiết + const alertShownRef = useRef(false); + + useEffect(() => { + if (!orderCode) { + Swal.fire({ + title: "Lỗi", + text: "Không tìm thấy mã đơn hàng. Đang chuyển hướng...", + icon: "error", + timer: 2000, + showConfirmButton: false, + }).then(() => navigate("/courses")); + return; + } + + // Nếu alert đã hiện thì không làm gì nữa + if (alertShownRef.current) return; + + // --- BẮT ĐẦU KIỂM TRA TRẠNG THÁI (POLLING) --- + const intervalId = setInterval(async () => { + try { + // Sửa lại đường dẫn API cho đúng + const response = await apiClient.get(`/payment/status/${orderCode}`); + const status = response.data.status; // 'pending', 'completed', 'cancelled' + + if (status === "completed") { + alertShownRef.current = true; // Đánh dấu đã hiện alert + clearInterval(intervalId); // Dừng kiểm tra + + Swal.fire({ + title: "Thanh toán thành công!", + text: "Khóa học đã được thêm vào tài khoản của bạn. Chúc bạn học tập hiệu quả!", + icon: "success", + confirmButtonText: "Xem khóa học của tôi", + timer: 4000, + timerProgressBar: true, + }).then(() => { + navigate("/profile/courses"); // Chuyển hướng đến trang khóa học của bạn + }); + } else if (status === "cancelled" || status === "failed") { + alertShownRef.current = true; + clearInterval(intervalId); + + Swal.fire({ + title: "Giao dịch không thành công", + text: "Giao dịch đã bị hủy hoặc thất bại. Vui lòng thử lại.", + icon: "error", + confirmButtonText: "Về giỏ hàng", + }).then(() => { + navigate("/profile/cart"); + }); + } + // Nếu vẫn là "pending", không làm gì cả, chờ lần kiểm tra tiếp theo + } catch (error) { + alertShownRef.current = true; + clearInterval(intervalId); // Dừng lại khi có lỗi + console.error("Lỗi khi kiểm tra trạng thái:", error); + + Swal.fire({ + title: "Có lỗi xảy ra", + text: "Không thể xác thực thanh toán của bạn lúc này. Vui lòng liên hệ hỗ trợ.", + icon: "error", + confirmButtonText: "Về trang chủ", + }).then(() => { + navigate("/"); + }); + } + }, 2500); // Kiểm tra mỗi 2.5 giây + + // Dọn dẹp interval khi component bị unmount + return () => clearInterval(intervalId); + }, [orderCode, navigate]); + + return ( + <> + +
+
+

Đang xác thực thanh toán...

+

+ Vui lòng chờ trong giây lát. Hệ thống đang cập nhật trạng thái khóa + học của bạn. +

+
+ + ); +}; + +export default PaymentSuccessPage; diff --git a/FrontEnd/src/components/ShoppingCart/QRCodePayment.jsx b/FrontEnd/src/components/ShoppingCart/QRCodePayment.jsx index a324610..3322f7d 100644 --- a/FrontEnd/src/components/ShoppingCart/QRCodePayment.jsx +++ b/FrontEnd/src/components/ShoppingCart/QRCodePayment.jsx @@ -26,7 +26,7 @@ const usePaymentPolling = ({ amount, content, isPolling }) => { const stopPolling = useCallback(() => { if (intervalIdRef.current) { - clearInterval(intervalIdRef.current); + clearTimeout(intervalIdRef.current); intervalIdRef.current = null; } setIsChecking(false); @@ -38,30 +38,29 @@ const usePaymentPolling = ({ amount, content, isPolling }) => { return; } - const checkPayment = async () => { - try { - const recentTransactions = await getRecentBankTransactions(); - const matched = recentTransactions.find((tx) => { - const amountInTx = Number(tx["Giá trị"]); - const descInTx = (tx["Mô tả"] || "").toLowerCase(); - return ( - amountInTx >= amount && descInTx.includes(content.toLowerCase()) - ); - }); + const simulateSuccessfulPayment = () => { + console.log("Simulating a successful payment check..."); + + // 1. Create a fake transaction with the CORRECT property names + const fakeTransaction = { + "Giá trị": amount, + "Mô tả": `Thanh toan don hang ${content} thanh cong`, + // FIX #1: Added the correct property for the date + "Ngày diễn ra": new Date().toISOString(), + // FIX #2: Added the required transaction ID property + "Mã GD": `FAKE_${Date.now()}`, + }; - if (matched) { - hasFoundMatchRef.current = true; - setMatchedTransaction(matched); - stopPolling(); - } - } catch (err) { - console.error("Error polling for transactions:", err); - stopPolling(); - } + console.log("Fake transaction created:", fakeTransaction); + + // 2. Update the state to trigger the success logic + hasFoundMatchRef.current = true; + setMatchedTransaction(fakeTransaction); + stopPolling(); }; - checkPayment(); - intervalIdRef.current = setInterval(checkPayment, 3000); + // 3. Simulate receiving the payment after 5 seconds + intervalIdRef.current = setTimeout(simulateSuccessfulPayment, 5000); return stopPolling; }, [isPolling, amount, content, stopPolling]); diff --git a/FrontEnd/src/routes/mainRoutes.jsx b/FrontEnd/src/routes/mainRoutes.jsx index 0da962c..820b052 100644 --- a/FrontEnd/src/routes/mainRoutes.jsx +++ b/FrontEnd/src/routes/mainRoutes.jsx @@ -9,6 +9,8 @@ import CategoryPage from "../components/Categories/CategoryPage"; import CoursePage from "../components/Categories/CoursePage"; import SingleCourse from "../components/CourseDetails/SingleCourse"; import CheckoutPage from "../components/ShoppingCart/CheckoutPage"; +import PaymentSuccessPage from "../components/ShoppingCart/PaymentSuccessPage"; +import PaymentCancelledPage from "../components/ShoppingCart/PaymentCancelledPage"; import WatchCourse from "../components/WatchCourse/WatchCourse"; import AboutUs from "../components/AboutPage/AboutUs"; import ContactUs from "../components/Contact/ContactUs"; @@ -25,6 +27,8 @@ const mainRoutesContent = ( } /> } /> } /> + } /> + } /> {/* Thêm các route chính khác ở đây */} ); diff --git a/FrontEnd/src/services/paymentService.js b/FrontEnd/src/services/paymentService.js index 5cf371d..81bfa70 100644 --- a/FrontEnd/src/services/paymentService.js +++ b/FrontEnd/src/services/paymentService.js @@ -1,40 +1,49 @@ import apiClient from "./authService"; -const generateGatewayId = () => { - return [...Array(24)] - .map(() => Math.floor(Math.random() * 16).toString(16)) - .join(""); -}; - -const createCustomPaymentId = (bankTransactionId) => { - const idString = String(bankTransactionId); - const requiredLength = 24; - if (idString.length >= requiredLength) { - return idString.substring(0, requiredLength); +/** + * Gọi API backend để tạo link thanh toán PayOS. + * @param {object} paymentData - Dữ liệu thanh toán gồm { description, price, courseIds, cancelUrl? }. + */ +export const createPayOSLink = async (paymentData) => { + try { + const response = await apiClient.post(`/payment/create-link`, paymentData, { + withCredentials: true, // Giữ lại nếu bạn dùng session/cookie để xác thực + }); + return response.data; // Trả về { message, checkoutUrl } + } catch (error) { + throw error.response?.data || new Error("Không thể tạo link thanh toán."); } - const paddingLength = requiredLength - idString.length; - const randomPadding = [...Array(paddingLength)] - .map(() => Math.floor(Math.random() * 16).toString(16)) - .join(""); - return idString + randomPadding; }; /** - * Fetches the 10 most recent bank transactions from the API. + * Kiểm tra trạng thái của một giao dịch từ backend bằng orderCode. + * @param {string} orderCode - Mã đơn hàng. */ -export const getRecentBankTransactions = async () => { +export const checkPaymentStatus = async (orderCode) => { try { - const response = await apiClient.get(`/payment/transactions`); - return response.data?.data; + const response = await apiClient.get(`/payment/status/${orderCode}`); + return response.data; // Trả về { status } } catch (error) { - console.error("Error fetching recent bank transactions:", error); - throw error; + throw ( + error.response?.data || + new Error("Không thể kiểm tra trạng thái đơn hàng.") + ); } }; /** - * Saves a processed bank transaction to your application's database. + * Yêu cầu backend hủy một đơn hàng đang chờ xử lý. + * @param {string} orderCode - Mã đơn hàng. */ +export const cancelPaymentOrder = async (orderCode) => { + try { + const response = await apiClient.put(`/payment/cancel/${orderCode}`); + return response.data; + } catch (error) { + throw error.response?.data || new Error("Không thể hủy đơn hàng."); + } +}; + export const saveTransactionToDB = async ( bankTransaction, currentUser, @@ -75,3 +84,22 @@ export const saveTransactionToDB = async ( throw new Error(message); } }; + +const generateGatewayId = () => { + return [...Array(24)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join(""); +}; + +const createCustomPaymentId = (bankTransactionId) => { + const idString = String(bankTransactionId); + const requiredLength = 24; + if (idString.length >= requiredLength) { + return idString.substring(0, requiredLength); + } + const paddingLength = requiredLength - idString.length; + const randomPadding = [...Array(paddingLength)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join(""); + return idString + randomPadding; +}; From ef4ecf80fe0904dc18513b56ae52a0e74efa0be1 Mon Sep 17 00:00:00 2001 From: Huynh Dinh Thien <136036261+Nomsociuu@users.noreply.github.com> Date: Sun, 19 Oct 2025 02:43:57 +0700 Subject: [PATCH 11/51] Add after payment logic and something else (#120) * [FLN-130][Thien][UI] Implement PayOs payment code [18.10.2025] * [FLN-130][Thien][UI] Add logic after payment [19.10.2025] --- FrontEnd/public/icons/status.png | Bin 0 -> 105657 bytes .../PurchaseHistory/PurchaseHistory.css | 54 +++- .../components/CourseDetails/PricingCard.jsx | 235 ++++++++++++++---- .../PurchaseHistory/PurchaseHistory.jsx | 110 ++++---- .../ShoppingCart/CheckoutComponents.jsx | 13 +- .../components/ShoppingCart/CheckoutPage.jsx | 134 +++++----- .../ShoppingCart/PaymentCancelledPage.jsx | 8 +- .../components/StudentCartPage/CartPage.jsx | 2 +- FrontEnd/src/services/courseService.js | 23 +- 9 files changed, 396 insertions(+), 183 deletions(-) create mode 100644 FrontEnd/public/icons/status.png diff --git a/FrontEnd/public/icons/status.png b/FrontEnd/public/icons/status.png new file mode 100644 index 0000000000000000000000000000000000000000..b31c2835f4847ccab1568c76065493ee140f4796 GIT binary patch literal 105657 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 literal 0 HcmV?d00001 diff --git a/FrontEnd/src/assets/PurchaseHistory/PurchaseHistory.css b/FrontEnd/src/assets/PurchaseHistory/PurchaseHistory.css index 60f843d..50626b8 100644 --- a/FrontEnd/src/assets/PurchaseHistory/PurchaseHistory.css +++ b/FrontEnd/src/assets/PurchaseHistory/PurchaseHistory.css @@ -291,6 +291,58 @@ color: #666; } +/* Sửa lại grid để cột 2 (actions) hẹp hơn cột 1 (list) */ +.flearning-purchase-details-grid { + display: grid; + grid-template-columns: 2fr 1fr; /* 2/3 cho list, 1/3 cho actions */ + gap: 24px; +} + +/* Định dạng cho cột actions mới */ +.flearning-purchase-actions { + padding-top: 10px; +} + +.flearning-actions-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 16px; + color: #333; +} + +/* Nút hành động chung */ +.flearning-action-btn { + width: 100%; + padding: 10px 15px; + font-size: 0.9rem; + font-weight: 600; + border-radius: 5px; + border: 1px solid #ccc; + cursor: pointer; + margin-bottom: 10px; + transition: all 0.2s ease; +} + +/* Nút chính (Tải biên lai) */ +.flearning-action-btn--primary { + background-color: #ff6636; /* Màu cam chính của bạn */ + color: white; + border-color: #ff6636; +} +.flearning-action-btn--primary:hover { + background-color: #e65c30; +} + +/* Nút phụ (Hỗ trợ) */ +.flearning-action-btn--secondary { + background-color: #f8f9fa; /* Màu xám nhạt */ + color: #333; + border-color: #ddd; +} +.flearning-action-btn--secondary:hover { + background-color: #e9ecef; +} + @media (max-width: 768px) { .flearning-purchase-details-grid { grid-template-columns: 1fr; @@ -310,4 +362,4 @@ width: 100%; height: 160px; } -} \ No newline at end of file +} diff --git a/FrontEnd/src/components/CourseDetails/PricingCard.jsx b/FrontEnd/src/components/CourseDetails/PricingCard.jsx index 15abfba..e19a38f 100644 --- a/FrontEnd/src/components/CourseDetails/PricingCard.jsx +++ b/FrontEnd/src/components/CourseDetails/PricingCard.jsx @@ -12,11 +12,13 @@ import { useNavigate, useParams } from "react-router-dom"; import { addToWishlist, getWishlist } from "../../services/wishlistService"; import { toast } from "react-toastify"; import { addToCart } from "../../services/cartService"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Swal from "sweetalert2"; // THÊM: Import service thanh toán mới để sử dụng trong `ActionButtons` import { createPayOSLink } from "../../services/paymentService"; +import { isUserEnrolled } from "../../services/courseService"; +import { getCurrentUserProfile } from "../../services/userService"; const ICON_MAP = { Level: BarChart3, @@ -32,6 +34,11 @@ const formatDiscount = (str) => { return match ? match[1].toUpperCase() + "OFF" : str.toUpperCase(); }; +const formatVND = (price) => { + const number = Number(price) || 0; + return number.toLocaleString("vi-VN"); +}; + const capitalizeFirstLetter = (str) => { const stringValue = String(str || ""); if (!stringValue) return ""; @@ -51,11 +58,11 @@ const PriceSection = ({ currentPrice, originalPrice, discount, timeLeft }) => (
- ${currentPrice?.toFixed(2)} + {formatVND(currentPrice)} VND {originalPrice > currentPrice && ( - ${originalPrice?.toFixed(2)} + {formatVND(originalPrice)} VND )}
@@ -148,7 +155,8 @@ const ActionButtons = ({ course }) => { const dispatch = useDispatch(); const currentUser = useSelector((state) => state.auth.currentUser); - // State loading riêng cho nút "Buy Now" + const [profile, setProfile] = useState(null); + const [isBuying, setIsBuying] = useState(false); const { isLoading: isLoadingCart } = useSelector( (state) => state.cart.addItemToCart @@ -157,7 +165,46 @@ const ActionButtons = ({ course }) => { (state) => state.wishlist.addItemToWishlist ); - const isEnrolledCourse = (id) => currentUser?.enrolledCourses?.includes(id); + const [isEnrolled, setIsEnrolled] = useState(false); + const [isLoadingEnrollment, setIsLoadingEnrollment] = useState(true); + + // useEffect (giữ nguyên, không đổi) + useEffect(() => { + setIsLoadingEnrollment(true); + setIsEnrolled(false); + setProfile(null); + + const checkEnrollment = async () => { + try { + const enrolled = await isUserEnrolled(currentUser._id, courseId); + setIsEnrolled(enrolled); + } catch (error) { + console.error("Failed to check enrollment status", error); + setIsEnrolled(false); + } + }; + + const fetchProfile = async () => { + try { + const response = await getCurrentUserProfile(); + setProfile(response.data); + } catch (error) { + console.error("Failed to fetch user profile", error); + } + }; + + if (currentUser?._id && courseId) { + const fetchAllData = async () => { + setIsLoadingEnrollment(true); + await Promise.all([checkEnrollment(), fetchProfile()]); + setIsLoadingEnrollment(false); + }; + + fetchAllData(); + } else { + setIsLoadingEnrollment(false); + } + }, [currentUser, courseId]); const handleAddToWishList = async () => { if (!currentUser) return navigate("/login"); @@ -180,7 +227,6 @@ const ActionButtons = ({ course }) => { } }; - // Logic "Buy Now" mới, gọi thẳng đến PayOS const handleBuyNow = async () => { if (!currentUser) return navigate("/login"); if (!course) { @@ -192,7 +238,8 @@ const ActionButtons = ({ course }) => { try { const paymentData = { description: `TT khoa hoc ${course._id.slice(-6)}`, - price: 2000, + // price: course.currentPrice, + price: 2000, // <-- Tạm thời đặt 2000 để test PayOS packageType: "COURSE_PURCHASE", courseIds: [course._id], cancelUrl: window.location.href, @@ -207,46 +254,98 @@ const ActionButtons = ({ course }) => { } } catch (error) { console.error("Error creating payment link:", error); - // ... xử lý lỗi setIsBuying(false); } }; + // ====================================================== + // BẮT ĐẦU SỬA TRONG RETURN + // ====================================================== return (
- {!isEnrolledCourse(courseId) ? ( + {isLoadingEnrollment ? ( +
+ +
+ ) : !isEnrolled ? ( <> - + {/* === NÚT ADD TO CART === */} + {profile?.role !== "student" ? ( + // 1. Wrapper (màn bọc) có tooltip khi không phải student + + + + ) : ( + // 2. Nút bình thường cho student + + )} - + {/* === NÚT BUY NOW === */} + {profile?.role !== "student" ? ( + + + + ) : ( + + )} ) : ( + // Nút "Go To Course" (giữ nguyên) + {/* === NÚT WISHLIST === */} + {profile?.role !== "student" ? ( + + + + ) : ( + + )}
- + {/* === NÚT GIFT COURSE === */} + {profile?.role !== "student" ? ( + + + + ) : ( + + )}
diff --git a/FrontEnd/src/components/PurchaseHistory/PurchaseHistory.jsx b/FrontEnd/src/components/PurchaseHistory/PurchaseHistory.jsx index 94a75d9..c40b46c 100644 --- a/FrontEnd/src/components/PurchaseHistory/PurchaseHistory.jsx +++ b/FrontEnd/src/components/PurchaseHistory/PurchaseHistory.jsx @@ -5,6 +5,7 @@ import ProfileSection from "../CourseList/ProfileSection"; import { getPurchaseHistory } from "../../services/profileService"; import "../../assets/PurchaseHistory/PurchaseHistory.css"; +// CourseItem không cần thay đổi const CourseItem = ({ course }) => (
@@ -26,6 +27,7 @@ const CourseItem = ({ course }) => (
); +// SỬA LẠI PURCHASE CARD const PurchaseCard = ({ purchase, isExpanded, onToggle }) => { const formatDate = (dateString) => { const date = new Date(dateString); @@ -51,22 +53,28 @@ const PurchaseCard = ({ purchase, isExpanded, onToggle }) => { {formatDate(purchase.paymentDate)}
+ {/* SỬA 1: HIỂN THỊ TỔNG SỐ COURSE ĐỘNG */} Course - 1 Course + {purchase.totalCourses} Course + {purchase.totalCourses > 1 ? "s" : ""} + + {/* SỬA 2: SỬA LỖI HIỂN THỊ TIỀN TỆ (NẾU CÓ) */} Amount + {/* Giả sử backend trả về 'amount' là VND */} {purchase.amount?.toLocaleString("vi-VN")} VND + { /> {purchase.paymentMethod} + + + Status + {purchase.transaction.status} +
- {isExpanded && purchase.course && ( + {/* SỬA 3: KIỂM TRA MẢNG 'courses' */} + {isExpanded && purchase.courses && purchase.courses.length > 0 && (
+ {/* CỘT 1: Danh sách khóa học (Giữ nguyên) */}
- + {purchase.courses.map((course) => ( + + ))}
-
-
- {formatDate(purchase.paymentDate)} -
-
-
- Course - 1 Course -
-
- Amount - {purchase.amount?.toLocaleString("vi-VN")} VND -
-
- Payment - {purchase.paymentMethod} -
-
- {purchase.transaction && ( -
-
- Transaction - - Transaction ID:{" "} - {purchase.transaction.gatewayTransactionId} - -
- - Status: {purchase.transaction.status} - -
- )} + {/* ======================================= */} + {/* BƯỚC 1: THÊM KHỐI HÀNH ĐỘNG NÀY VÀO */} +
+
Tuỳ chọn giao dịch
+ +
+ {/* ======================================= */}
)} @@ -149,6 +139,7 @@ const PurchaseCard = ({ purchase, isExpanded, onToggle }) => { ); }; +// Component PurchaseHistory không cần thay đổi logic const PurchaseHistory = () => { const location = useLocation(); const [purchases, setPurchases] = useState([]); @@ -166,9 +157,14 @@ const PurchaseHistory = () => { try { setLoading(true); setError(null); + // Gọi service (đã được sửa ở backend) const response = await getPurchaseHistory(page); + console.log("Purchase history response:", response.data.data); + + // Dữ liệu mới đã có 'courses' (mảng) và 'totalCourses' (số) setPurchases(response.data.data); setPagination(response.data.pagination); + if (response.data.data.length > 0) { setExpandedId(response.data.data[0].paymentId); } @@ -182,11 +178,13 @@ const PurchaseHistory = () => { }; useEffect(() => { - fetchPurchases(); + fetchPurchases(1); // Fetch trang đầu tiên khi tải }, []); const handlePageChange = (newPage) => { - fetchPurchases(newPage); + if (newPage >= 1 && newPage <= pagination.totalPages) { + fetchPurchases(newPage); + } }; const handleToggle = (purchaseId) => { @@ -225,7 +223,7 @@ const PurchaseHistory = () => { )}
- {purchases.length > 0 && ( + {purchases.length > 0 && pagination.totalPages > 1 && (
Total: - ${total.toFixed(2)} USD + {formatVND(total)} VND
diff --git a/FrontEnd/src/components/ShoppingCart/PaymentCancelledPage.jsx b/FrontEnd/src/components/ShoppingCart/PaymentCancelledPage.jsx index dfe93d6..737b22a 100644 --- a/FrontEnd/src/components/ShoppingCart/PaymentCancelledPage.jsx +++ b/FrontEnd/src/components/ShoppingCart/PaymentCancelledPage.jsx @@ -69,7 +69,7 @@ const PaymentCancelledPage = () => { timer: 3000, timerProgressBar: true, }).then(() => { - navigate("/"); + navigate("/profile/cart"); }); } catch (error) { console.error("Lỗi khi hủy đơn hàng:", error); @@ -78,16 +78,16 @@ const PaymentCancelledPage = () => { title: "Có lỗi xảy ra", text: "Không thể gửi yêu cầu hủy đơn hàng. Vui lòng thử lại sau.", icon: "error", - confirmButtonText: "Về trang chủ", + confirmButtonText: "Về giỏ hàng", }).then(() => { - navigate("/"); + navigate("/profile/cart"); }); } }; cancelOrderAndShowAlert(); } else { - navigate("/"); + navigate("/profile/cart"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [orderCode, navigate]); diff --git a/FrontEnd/src/components/StudentCartPage/CartPage.jsx b/FrontEnd/src/components/StudentCartPage/CartPage.jsx index 7e18e78..45f822d 100644 --- a/FrontEnd/src/components/StudentCartPage/CartPage.jsx +++ b/FrontEnd/src/components/StudentCartPage/CartPage.jsx @@ -70,7 +70,7 @@ function CartPage() { courseImage: course.thumbnail, courseName: course.title, rating: course.rating, - enrolledCount: course.studentsEnrolled.length, + enrolledCount: course.studentsEnrolled?.length || 0, price: finalPrice, oldPrice: isDiscountValid(course.discountId) ? course.price : null, courseAuthor: course.author || "Admin", diff --git a/FrontEnd/src/services/courseService.js b/FrontEnd/src/services/courseService.js index de08ac9..8d565ec 100644 --- a/FrontEnd/src/services/courseService.js +++ b/FrontEnd/src/services/courseService.js @@ -121,7 +121,10 @@ export const createCourse = async (courseData) => { // Create a new section in a course export const createSection = async (courseId, sectionData) => { try { - const response = await apiClient.post(`/admin/courses/${courseId}/sections`, sectionData); + const response = await apiClient.post( + `/admin/courses/${courseId}/sections`, + sectionData + ); return response.data; } catch (error) { console.error("Error creating section:", error); @@ -167,4 +170,20 @@ export const moveLessonVideo = async (courseId, lessonId, videoUrl) => { "Failed to move lesson video"; throw new Error(message); } -}; \ No newline at end of file +}; + +export const isUserEnrolled = async (userId, courseId) => { + try { + const response = await apiClient.get( + `/courses/is-enrolled?userId=${userId}&courseId=${courseId}` + ); + return response.data.isEnrolled; + } catch (error) { + console.error("Error checking enrollment status:", error); + const message = + error.response?.data?.message || + error.message || + "Failed to check enrollment status"; + throw new Error(message); + } +}; From 024446ff50b41e934f10a53a5d5bf39b2d8dc7ac Mon Sep 17 00:00:00 2001 From: Ruhan Chu Date: Sun, 19 Oct 2025 10:29:14 +0700 Subject: [PATCH 12/51] [FLN-156][Dat][FE] Instructor Profile Edit Page [19.10.2025] (#122) --- .../InstructorProfileEdit.css | 372 +++++++++++++++ .../InstructorProfileEdit.jsx | 432 ++++++++++++++++++ 2 files changed, 804 insertions(+) create mode 100644 FrontEnd/src/assets/InstructorProfile/InstructorProfileEdit.css create mode 100644 FrontEnd/src/pages/InstructorProfile/InstructorProfileEdit.jsx diff --git a/FrontEnd/src/assets/InstructorProfile/InstructorProfileEdit.css b/FrontEnd/src/assets/InstructorProfile/InstructorProfileEdit.css new file mode 100644 index 0000000..99d4159 --- /dev/null +++ b/FrontEnd/src/assets/InstructorProfile/InstructorProfileEdit.css @@ -0,0 +1,372 @@ +/* Instructor Profile Edit Styles */ +.ipe-container { + max-width: 1000px; + margin: 0 auto; + padding: 40px 20px; + background-color: #f5f7fa; + min-height: 100vh; +} + +.ipe-header { + margin-bottom: 40px; +} + +.ipe-header h1 { + font-size: 32px; + font-weight: 700; + color: #1e293b; + margin: 0 0 8px 0; +} + +.ipe-header p { + font-size: 16px; + color: #64748b; + margin: 0; +} + +.ipe-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + color: #64748b; +} + +.ipe-spinner { + width: 40px; + height: 40px; + border: 4px solid #e2e8f0; + border-top-color: #ff6636; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.ipe-form { + background: white; + border-radius: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.ipe-section { + padding: 32px; + border-bottom: 1px solid #e2e8f0; +} + +.ipe-section:last-child { + border-bottom: none; +} + +.ipe-section-title { + font-size: 20px; + font-weight: 600; + color: #1e293b; + margin: 0 0 24px 0; +} + +/* Image Upload */ +.ipe-image-upload { + display: flex; + align-items: flex-start; + gap: 24px; +} + +.ipe-image-preview { + position: relative; + width: 150px; + height: 150px; + border-radius: 50%; + overflow: hidden; + border: 4px solid #e2e8f0; +} + +.ipe-image-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.ipe-image-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + cursor: pointer; + color: white; + gap: 8px; +} + +.ipe-image-preview:hover .ipe-image-overlay { + opacity: 1; +} + +.ipe-image-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ipe-image-text { + font-size: 14px; + color: #64748b; + line-height: 1.6; +} + +.ipe-image-actions { + display: flex; + gap: 12px; +} + +.ipe-btn-upload { + padding: 10px 20px; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); +} + +.ipe-btn-upload:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); +} + +.ipe-btn-upload:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.ipe-btn-cancel { + padding: 10px 20px; + background: #f1f5f9; + color: #64748b; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.ipe-btn-cancel:hover:not(:disabled) { + background: #e2e8f0; + color: #475569; +} + +.ipe-btn-cancel:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Form Grid */ +.ipe-form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.ipe-form-group { + margin-bottom: 20px; +} + +.ipe-form-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: #334155; + margin-bottom: 8px; +} + +.ipe-form-group label svg { + color: #ff6636; +} + +.ipe-form-group input, +.ipe-form-group textarea { + width: 100%; + padding: 12px 16px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 14px; + font-family: inherit; + transition: all 0.3s ease; +} + +.ipe-form-group input:focus, +.ipe-form-group textarea:focus { + outline: none; + border-color: #ff6636; + box-shadow: 0 0 0 3px rgba(255, 102, 54, 0.1); +} + +.ipe-input-disabled { + background-color: #f1f5f9; + color: #64748b; + cursor: not-allowed; +} + +.ipe-form-group small { + display: block; + margin-top: 4px; + font-size: 12px; + color: #94a3b8; +} + +.ipe-form-group textarea { + resize: vertical; + min-height: 80px; +} + +/* Expertise Tags */ +.ipe-expertise-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ipe-expertise-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 20px; + font-size: 13px; + font-weight: 500; +} + +.ipe-expertise-remove { + background: rgba(255, 255, 255, 0.3); + border: none; + color: white; + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + transition: background 0.2s ease; +} + +.ipe-expertise-remove:hover { + background: rgba(255, 255, 255, 0.5); +} + +.ipe-expertise-input { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.ipe-expertise-input input { + flex: 1; +} + +.ipe-btn-add { + padding: 12px 24px; + background: #10b981; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; +} + +.ipe-btn-add:hover { + background: #059669; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); +} + +/* Actions */ +.ipe-actions { + padding: 24px 32px; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.ipe-btn-primary { + padding: 14px 32px; + background: linear-gradient(135deg, #ff6636 0%, #ff8a3c 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(255, 102, 54, 0.3); +} + +.ipe-btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(255, 102, 54, 0.4); +} + +.ipe-btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Responsive */ +@media (max-width: 768px) { + .ipe-container { + padding: 20px 16px; + } + + .ipe-header h1 { + font-size: 24px; + } + + .ipe-section { + padding: 24px 20px; + } + + .ipe-form-grid { + grid-template-columns: 1fr; + } + + .ipe-image-upload { + flex-direction: column; + align-items: center; + text-align: center; + } + + .ipe-actions { + padding: 20px; + flex-direction: column; + } + + .ipe-btn-primary { + width: 100%; + } +} diff --git a/FrontEnd/src/pages/InstructorProfile/InstructorProfileEdit.jsx b/FrontEnd/src/pages/InstructorProfile/InstructorProfileEdit.jsx new file mode 100644 index 0000000..b699e11 --- /dev/null +++ b/FrontEnd/src/pages/InstructorProfile/InstructorProfileEdit.jsx @@ -0,0 +1,432 @@ +import React, { useState, useEffect } from "react"; +import { FaCamera, FaGlobe, FaLinkedin, FaTwitter, FaYoutube, FaFacebook } from "react-icons/fa"; +import { toast } from "react-toastify"; +import { getMyProfile, updateMyProfile } from "../../services/instructorService"; +import "../../assets/InstructorProfile/InstructorProfileEdit.css"; + +const DEFAULT_PROFILE_IMAGE = "/images/defaultImageUser.png"; + +const InstructorProfileEdit = () => { + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + + const [profileData, setProfileData] = useState({ + firstName: "", + lastName: "", + email: "", + phone: "", + bio: "", + headline: "", + website: "", + socialLinks: { + linkedin: "", + twitter: "", + youtube: "", + facebook: "", + }, + expertise: [], + experience: "", + userImage: DEFAULT_PROFILE_IMAGE, + }); + + const [newExpertise, setNewExpertise] = useState(""); + + useEffect(() => { + fetchProfile(); + }, []); + + const fetchProfile = async () => { + try { + setIsLoading(true); + const response = await getMyProfile(); + const data = response.data.data; + + setProfileData({ + firstName: data.userId?.firstName || "", + lastName: data.userId?.lastName || "", + email: data.userId?.email || "", + phone: data.phone || "", + bio: data.bio || "", + headline: data.headline || "", + website: data.website || "", + socialLinks: data.socialLinks || { + linkedin: "", + twitter: "", + youtube: "", + facebook: "", + }, + expertise: data.expertise || [], + experience: data.experience || "", + userImage: data.userId?.userImage || DEFAULT_PROFILE_IMAGE, + }); + } catch (error) { + console.error("Error fetching profile:", error); + toast.error("Failed to load profile"); + } finally { + setIsLoading(false); + } + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + setProfileData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSocialLinkChange = (platform, value) => { + setProfileData((prev) => ({ + ...prev, + socialLinks: { + ...prev.socialLinks, + [platform]: value, + }, + })); + }; + + const handleAddExpertise = () => { + if (newExpertise.trim() && !profileData.expertise.includes(newExpertise.trim())) { + setProfileData((prev) => ({ + ...prev, + expertise: [...prev.expertise, newExpertise.trim()], + })); + setNewExpertise(""); + } + }; + + const handleRemoveExpertise = (index) => { + setProfileData((prev) => ({ + ...prev, + expertise: prev.expertise.filter((_, i) => i !== index), + })); + }; + + const handleImageChange = (e) => { + const file = e.target.files[0]; + if (file) { + if (file.size > 5 * 1024 * 1024) { + toast.error("Image size should be less than 5MB"); + return; + } + + setSelectedFile(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + } + }; + + const handleUploadAvatar = async () => { + if (!selectedFile) { + toast.warning("Please select an image first"); + return; + } + + try { + setIsUploadingAvatar(true); + + const formData = new FormData(); + formData.append("avatar", selectedFile); + + await updateMyProfile(formData); + toast.success("Avatar updated successfully!"); + + // Clear states before reload + setSelectedFile(null); + setImagePreview(null); + + // Reload page to update avatar everywhere (same as ProfileSetting does) + window.location.reload(); + } catch (error) { + console.error("Error uploading avatar:", error); + toast.error(error.response?.data?.message || "Failed to upload avatar"); + } finally { + setIsUploadingAvatar(false); + } + }; + + const handleCancelAvatar = () => { + setSelectedFile(null); + setImagePreview(null); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + try { + setIsSaving(true); + + const formData = new FormData(); + formData.append("bio", profileData.bio); + formData.append("headline", profileData.headline); + formData.append("website", profileData.website); + formData.append("socialLinks", JSON.stringify(profileData.socialLinks)); + + await updateMyProfile(formData); + toast.success("Profile updated successfully!"); + + // Refresh profile data + await fetchProfile(); + } catch (error) { + console.error("Error updating profile:", error); + toast.error(error.response?.data?.message || "Failed to update profile"); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+
+

Loading profile...

+
+ ); + } + + return ( +
+
+

Edit Profile

+

Manage your public instructor profile

+
+ +
+ {/* Profile Picture Section */} +
+

Profile Picture

+
+
+ Profile { + e.target.src = DEFAULT_PROFILE_IMAGE; + }} + /> + + +
+
+

+ Upload a professional photo. JPG, PNG or GIF. Max size 5MB. +

+ {selectedFile && ( +
+ + +
+ )} +
+
+
+ + {/* Basic Information - Read Only */} +
+

Basic Information

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Professional Information - Read Only */} +
+

Professional Information

+
+ +
+ {profileData.expertise.map((exp, index) => ( + + {exp} + + ))} +
+
+
+ +