first commit

This commit is contained in:
2026-04-11 14:08:27 +07:00
parent e86e5d2c46
commit 6b7655aa16
389 changed files with 5387 additions and 60861 deletions

Binary file not shown.

View File

@@ -1,127 +1,123 @@
/**
* CMS Component: Buttons
* Overrides Bootstrap buttons to match CMS design language
*/
.btn {
border-radius: var(--border-radius);
font-weight: var(--font-weight-medium);
padding: 0.5rem 1.25rem;
transition: var(--transition-base);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 42px; /* Standardize height */
border-radius: var(--border-radius-sm);
font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
padding: 0.5rem 1.1rem;
transition: var(--transition-base);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
height: 38px;
letter-spacing: 0.01em;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: var(--font-size-sm);
height: 32px;
padding: 0.3rem 0.75rem;
font-size: 0.8125rem;
height: 30px;
border-radius: var(--border-radius-sm);
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: var(--font-size-lg);
height: 52px;
padding: 0.65rem 1.5rem;
font-size: 1rem;
height: 46px;
}
/* Primary Button */
/* Primary */
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: var(--text-white);
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
.btn-primary:hover, .btn-primary:focus {
background-color: var(--primary-dark);
border-color: var(--primary-dark);
color: var(--text-white);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
background-color: var(--primary-light);
border-color: var(--primary-light);
color: #fff;
box-shadow: 0 4px 12px rgba(10,35,71,0.25);
transform: translateY(-1px);
}
/* Accent */
.btn-accent {
background-color: var(--accent-color);
border-color: var(--accent-color);
color: #fff;
}
.btn-accent:hover {
background-color: var(--accent-light);
border-color: var(--accent-light);
color: #fff;
transform: translateY(-1px);
}
/* Outline Primary */
.btn-outline-primary {
color: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-color);
border-color: var(--primary-color);
background: transparent;
}
.btn-outline-primary:hover {
background-color: var(--primary-color);
color: var(--text-white);
background-color: var(--primary-color);
color: #fff;
box-shadow: 0 2px 8px rgba(10,35,71,0.2);
}
/* White / Icon Button */
/* Outline Secondary */
.btn-outline-secondary {
color: var(--text-muted);
border-color: var(--border-color);
background: #fff;
}
.btn-outline-secondary:hover {
background-color: #f8fafc;
border-color: #cbd5e1;
color: var(--text-main);
}
/* Outline Danger */
.btn-outline-danger {
color: var(--danger-color);
border-color: var(--danger-color);
background: transparent;
}
.btn-outline-danger:hover {
background-color: var(--danger-color);
color: #fff;
}
/* White */
.btn-white {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-main);
background-color: #fff;
border: 1px solid var(--border-color);
color: var(--text-main);
}
.btn-white:hover {
background-color: #f8f9fa;
border-color: #dee2e6;
box-shadow: var(--shadow-sm);
background-color: #f8fafc;
border-color: #cbd5e1;
box-shadow: var(--shadow-sm);
}
/* Special Button Effects (Shine effect from main.ejs) */
.btn-shine {
position: relative;
overflow: hidden;
/* Icon button */
.btn-icon {
width: 34px;
height: 34px;
padding: 0;
border-radius: var(--border-radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-shine::after {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: all 0.5s ease;
}
.btn-shine:hover::after {
left: 100%;
}
/* Action Button Group (Like Topbar screenshot) */
.btn-group-action {
background-color: #fff;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
display: inline-flex;
}
.btn-group-action .btn {
border: none;
border-radius: 0;
padding: 0.5rem 0.85rem;
background: transparent;
height: 38px;
width: 42px;
}
.btn-group-action .btn:not(:last-child) {
border-right: 1px solid var(--border-color);
}
.btn-group-action .btn:hover {
background-color: #f8f9fa;
transform: none;
box-shadow: none;
}
/* Icon alignment inside buttons */
.btn i {
font-size: 0.9em;
}
/* Icon specific colors for actions */
.text-action-add { color: #0d6efd !important; }
.text-action-edit { color: #ffc107 !important; }
.text-action-delete { color: #dc3545 !important; }
.btn i { font-size: 0.85em; }

View File

@@ -1,39 +1,130 @@
/**
* CMS Component: Cards
* Standardizes administrative dashboard cards
*/
.card {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
transition: var(--transition-base);
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
transition: var(--transition-base);
overflow: hidden;
background: var(--bg-card);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card:hover { box-shadow: var(--shadow-md); }
.card-header {
background-color: #fff;
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-3) var(--spacing-4);
font-weight: var(--font-weight-bold);
background-color: #fff;
border-bottom: 1px solid var(--border-color);
padding: 1rem 1.25rem;
font-weight: var(--font-weight-semibold);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.card-header-title {
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
color: var(--text-main);
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-header-title i {
color: var(--accent-color);
font-size: 0.9em;
}
.card-title {
margin-bottom: 0;
font-size: 1.1rem;
color: var(--text-main);
margin-bottom: 0;
font-size: 1rem;
color: var(--text-main);
font-weight: var(--font-weight-semibold);
}
.card-body {
padding: var(--spacing-4);
}
.card-body { padding: 1.25rem; }
.card-footer {
background-color: #fcfcfc;
border-top: 1px solid var(--border-color);
padding: var(--spacing-2) var(--spacing-4);
background-color: #fafbfc;
border-top: 1px solid var(--border-color);
padding: 0.75rem 1.25rem;
}
/* Stat cards — gradient style */
.stat-card {
border-radius: var(--border-radius);
padding: 1.35rem 1.4rem 1.1rem;
position: relative;
overflow: hidden;
transition: var(--transition-base);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 110px;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
}
/* Background ghost icon */
.stat-card::after {
content: attr(data-icon);
font-family: "Font Awesome 6 Free";
font-weight: 900;
position: absolute;
right: -0.5rem;
bottom: -0.75rem;
font-size: 5rem;
opacity: 0.1;
line-height: 1;
pointer-events: none;
}
.stat-card-value {
font-size: 2rem;
font-weight: var(--font-weight-bold);
line-height: 1;
color: #fff;
}
.stat-card-label {
font-size: 0.7rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.07em;
color: rgba(255,255,255,0.75);
margin-top: 0.15rem;
}
.stat-card-icon {
display: none; /* icon is now the ghost bg */
}
.stat-card-body { display: contents; }
/* Color variants */
.stat-card.primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
}
.stat-card.accent {
background: linear-gradient(135deg, #8a6d3b 0%, var(--accent-color) 100%);
}
.stat-card.success {
background: linear-gradient(135deg, #145c38 0%, var(--success-color) 100%);
}
.stat-card.danger {
background: linear-gradient(135deg, #922b21 0%, var(--danger-color) 100%);
}
.stat-card.warning {
background: linear-gradient(135deg, #b45309 0%, var(--warning-color) 100%);
}
.stat-card.info {
background: linear-gradient(135deg, #0c5a72 0%, var(--info-color) 100%);
}

View File

@@ -1,50 +1,85 @@
/**
* CMS Component: Forms
* Standardizes inputs, labels and validation messages
*/
.form-label {
font-weight: var(--font-weight-medium);
margin-bottom: var(--spacing-2);
color: var(--text-main);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
margin-bottom: 0.35rem;
color: var(--text-main);
font-size: var(--font-size-sm);
}
.form-control, .form-select {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
padding: 0.6rem 1rem;
font-size: var(--font-size-base);
transition: var(--transition-base);
border-radius: var(--border-radius-sm);
border: 1px solid var(--border-color);
padding: 0.5rem 0.875rem;
font-size: var(--font-size-sm);
color: var(--text-main);
background-color: #fff;
transition: var(--transition-base);
height: 38px;
}
textarea.form-control { height: auto; }
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(188, 159, 105, 0.15);
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(10,35,71,0.08);
outline: none;
}
.form-control::placeholder { color: #94a3b8; }
.input-group-text {
background-color: #f8f9fa;
border-color: var(--border-color);
color: var(--text-muted);
background-color: #f8fafc;
border-color: var(--border-color);
color: var(--text-muted);
font-size: var(--font-size-sm);
}
/* Form Helper Text */
.form-text {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: var(--spacing-1);
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: 0.3rem;
}
/* Validation Styles */
.invalid-feedback {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
/* Custom Checkbox/Radio */
.form-check-input:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
background-color: var(--primary-color);
border-color: var(--primary-color);
}
/* Section divider in forms */
.form-section {
border-top: 1px solid var(--border-color);
padding-top: 1.25rem;
margin-top: 0.5rem;
}
.form-section-title {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 1rem;
}
/* Search bar */
.search-bar .form-control {
padding-left: 2.5rem;
}
.search-bar .search-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 0.85rem;
pointer-events: none;
}

View File

@@ -1,45 +1,47 @@
/**
* CMS Component: Modals
* Standardizes modal spacing and appearance
*/
.modal-content {
border: none;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
border: none;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.modal-header {
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-4);
background-color: #fff;
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
border-bottom: 1px solid var(--border-color);
padding: 1.1rem 1.5rem;
background-color: #fff;
}
.modal-title {
font-weight: var(--font-weight-bold);
color: var(--text-main);
font-size: 1.15rem;
font-weight: var(--font-weight-semibold);
color: var(--text-main);
font-size: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.modal-body {
padding: var(--spacing-4);
}
.modal-title i { color: var(--accent-color); }
.modal-body { padding: 1.5rem; }
.modal-footer {
border-top: 1px solid var(--border-color);
padding: var(--spacing-3) var(--spacing-4);
gap: var(--spacing-2);
border-top: 1px solid var(--border-color);
padding: 0.875rem 1.5rem;
gap: 0.5rem;
background: #fafbfc;
}
/* Modal sizing standards */
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - var(--bs-modal-margin) * 2);
.modal-backdrop.show { opacity: 0.45; }
/* Confirm modal */
.modal-confirm .modal-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
border-bottom: none;
}
/* Fix for backdrop issue reported in previous conversations */
.modal-backdrop.show {
opacity: 0.5;
}
.modal-confirm .modal-title { color: #fff; }
.modal-confirm .btn-close { filter: invert(1); }

View File

@@ -1,62 +1,112 @@
/**
* CMS Component: Tables
* Standardizes data tables listing
*/
.table {
margin-bottom: 0;
}
.table { margin-bottom: 0; }
.table thead th {
background-color: #f8f9fa;
border-bottom: 2px solid var(--border-color);
color: var(--text-muted);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.025em;
padding: var(--spacing-3) var(--spacing-4);
background-color: #f8fafc;
border-bottom: 1px solid var(--border-color);
border-top: none;
color: var(--text-muted);
font-weight: var(--font-weight-semibold);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.75rem 1rem;
white-space: nowrap;
}
.table tbody td {
padding: var(--spacing-3) var(--spacing-4);
vertical-align: middle;
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 1rem;
vertical-align: middle;
border-bottom: 1px solid var(--border-light);
font-size: var(--font-size-sm);
color: var(--text-main);
}
.table-hover tbody tr:hover {
background-color: rgba(188, 159, 105, 0.05);
}
.table tbody tr:last-child td { border-bottom: none; }
/* Badge in table */
.table-hover tbody tr:hover { background-color: #f8fafc; }
/* Badges */
.badge {
padding: 0.4em 0.6em;
border-radius: var(--border-radius-sm);
font-weight: var(--font-weight-medium);
padding: 0.35em 0.65em;
border-radius: var(--border-radius-sm);
font-weight: var(--font-weight-medium);
font-size: 0.75rem;
letter-spacing: 0.01em;
}
.badge-soft-primary {
background-color: var(--primary-soft);
color: var(--primary-color);
border: 1px solid rgba(10,35,71,0.12);
}
.badge-soft-accent {
background-color: var(--accent-soft);
color: #8a6d3b;
border: 1px solid rgba(188,159,105,0.2);
}
.bg-soft-success {
background-color: var(--success-soft);
color: var(--success-color);
border: 1px solid rgba(40, 167, 69, 0.2);
background-color: var(--success-soft);
color: var(--success-color);
border: 1px solid rgba(26,122,74,0.15);
}
.bg-soft-danger {
background-color: var(--danger-soft);
color: var(--danger-color);
border: 1px solid rgba(220, 53, 69, 0.2);
background-color: var(--danger-soft);
color: var(--danger-color);
border: 1px solid rgba(192,57,43,0.15);
}
.bg-soft-warning {
background-color: var(--warning-soft);
color: var(--warning-color);
border: 1px solid rgba(255, 193, 7, 0.2);
background-color: var(--warning-soft);
color: var(--warning-color);
border: 1px solid rgba(217,119,6,0.15);
}
.bg-soft-info {
background-color: var(--info-soft);
color: var(--info-color);
border: 1px solid rgba(23, 162, 184, 0.2);
background-color: var(--info-soft);
color: var(--info-color);
border: 1px solid rgba(14,116,144,0.15);
}
.bg-soft-secondary {
background-color: #f8f9fa;
color: #6c757d;
border: 1px solid rgba(108, 117, 125, 0.2);
background-color: #f1f5f9;
color: #64748b;
border: 1px solid rgba(100,116,139,0.15);
}
/* Table action buttons */
.table-actions {
display: flex;
gap: 0.35rem;
align-items: center;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3.5rem 1rem;
}
.empty-state-icon {
font-size: 2.5rem;
color: #cbd5e1;
margin-bottom: 1rem;
}
.empty-state h5 {
color: var(--text-muted);
font-weight: var(--font-weight-semibold);
margin-bottom: 0.5rem;
}
.empty-state p {
color: var(--text-muted);
font-size: var(--font-size-sm);
margin-bottom: 1.25rem;
}

View File

@@ -1,101 +1,118 @@
/**
* CMS Global Layout
* Navbar, Footer, and Page structures
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body {
font-family: var(--font-family);
background-color: var(--bg-body);
min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--text-main);
font-family: var(--font-family);
background-color: var(--bg-body);
min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--text-main);
}
main {
flex: 1;
padding: var(--spacing-4) 0;
main { flex: 1; padding: var(--spacing-4) 0; }
/* Page header */
.page-header { margin-bottom: var(--spacing-4); }
.page-content {
background-color: var(--bg-card);
padding: var(--spacing-4);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
}
/* Navbar Customization */
/* Navbar */
.navbar {
background-color: rgba(255, 255, 255, 0.95);
box-shadow: var(--shadow-header);
padding: 0.75rem 0;
transition: var(--transition-base);
background-color: rgba(255,255,255,0.97);
box-shadow: var(--shadow-header);
padding: 0.75rem 0;
transition: var(--transition-base);
}
.navbar-brand {
font-weight: var(--font-weight-bold);
color: var(--primary-color) !important;
font-weight: var(--font-weight-bold);
color: var(--primary-color) !important;
}
.nav-link {
color: var(--text-main);
font-weight: var(--font-weight-medium);
padding: 0.5rem 1rem;
transition: var(--transition-base);
font-size: 0.95rem;
color: var(--text-main);
font-weight: var(--font-weight-medium);
padding: 0.5rem 1rem;
transition: var(--transition-base);
font-size: 0.95rem;
}
.nav-link:hover, .nav-link.active {
color: var(--primary-color) !important;
}
.nav-link:hover, .nav-link.active { color: var(--primary-color) !important; }
/* Page Containers */
.page-header {
margin-bottom: var(--spacing-4);
}
.page-content {
background-color: var(--bg-card);
padding: var(--spacing-4);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
}
/* Footer Customization */
/* Footer */
footer {
background: linear-gradient(90deg, var(--primary-dark), var(--primary-color), var(--primary-light), #d4c4a8);
color: var(--text-white);
margin-top: auto;
padding: var(--spacing-4) 0;
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-color) 60%, var(--primary-light) 100%);
color: var(--text-white);
margin-top: auto;
padding: var(--spacing-4) 0;
}
footer a {
color: rgba(255, 255, 255, 0.8) !important;
text-decoration: none;
transition: var(--transition-base);
color: rgba(255,255,255,0.75) !important;
text-decoration: none;
transition: var(--transition-base);
}
footer a:hover {
color: var(--text-white) !important;
}
footer a:hover { color: var(--text-white) !important; }
/* Utility: Fixed Bottom Actions (for Forms/Modals) */
.fixed-bottom-buttons {
position: fixed;
bottom: 0;
right: 0;
padding: var(--spacing-3);
z-index: 1000;
display: flex;
gap: var(--spacing-2);
}
/* Dropdown Customization */
/* Dropdown */
.dropdown-menu {
border: none;
box-shadow: var(--shadow-lg);
border-radius: var(--border-radius);
margin-top: 0.5rem;
}
.dropdown-item:hover {
background-color: var(--primary-soft);
border: none;
box-shadow: var(--shadow-lg);
border-radius: var(--border-radius);
margin-top: 0.5rem;
}
.dropdown-item:hover { background-color: var(--primary-soft); }
.dropdown-item.active {
background-color: var(--primary-color);
color: var(--text-white);
background-color: var(--primary-color);
color: var(--text-white);
}
/* Page title area */
.page-title-area {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.page-title-area h1 {
font-size: 1.4rem;
font-weight: var(--font-weight-bold);
color: var(--primary-color);
margin: 0;
}
.page-title-area .subtitle {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin: 0;
}
/* Breadcrumb */
.breadcrumb-area {
margin-bottom: 0.25rem;
}
.breadcrumb {
font-size: var(--font-size-xs);
margin: 0;
padding: 0;
background: none;
}
.breadcrumb-item + .breadcrumb-item::before { color: var(--text-muted); }
.breadcrumb-item a { color: var(--accent-color); text-decoration: none; }
.breadcrumb-item.active { color: var(--text-muted); }

View File

@@ -1,47 +1,62 @@
/**
* CMS Design System Variables
* Standardized colors, spacing, and typography
*/
:root {
/* Primary Colors (Gold/Cinnamon) */
/* Primary Colors (Navy) */
--primary-color: #0a2347;
--primary-rgb: 188, 159, 105;
--primary-light: #0a2347;
--primary-dark: #0a2347;
--primary-soft: rgba(188, 159, 105, 0.1);
--primary-rgb: 10, 35, 71;
--primary-light: #1a3a6b;
--primary-dark: #061529;
--primary-soft: rgba(10, 35, 71, 0.07);
/* Accent (Gold) */
--accent-color: #bc9f69;
--accent-light: #d4b98a;
--accent-soft: rgba(188, 159, 105, 0.12);
/* Secondary Colors */
--secondary-color: #6c757d;
--secondary-soft: rgba(108, 117, 125, 0.1);
/* Status Colors */
--success-color: #28a745;
--success-soft: rgba(40, 167, 69, 0.1);
--warning-color: #ffc107;
--warning-soft: rgba(255, 193, 7, 0.1);
--danger-color: #BF3432;
--danger-soft: rgba(220, 53, 69, 0.1);
--info-color: #17a2b8;
--info-soft: rgba(23, 162, 184, 0.1);
--success-color: #1a7a4a;
--success-soft: rgba(26, 122, 74, 0.1);
--warning-color: #d97706;
--warning-soft: rgba(217, 119, 6, 0.1);
--danger-color: #c0392b;
--danger-soft: rgba(192, 57, 43, 0.1);
--info-color: #0e7490;
--info-soft: rgba(14, 116, 144, 0.1);
/* Neutral Colors / Backgrounds */
--bg-body: #f5f7fa;
--bg-body: #f0f2f7;
--bg-card: #ffffff;
--bg-header: #ffffff;
--border-color: #e9ecef;
--text-main: #333333;
--text-muted: #6c757d;
--border-color: #e2e8f0;
--border-light: #f1f5f9;
--text-main: #1e293b;
--text-muted: #64748b;
--text-white: #ffffff;
/* Sidebar */
--sidebar-bg: #0a2347;
--sidebar-text: rgba(255,255,255,0.75);
--sidebar-text-active: #ffffff;
--sidebar-hover-bg: rgba(255,255,255,0.08);
--sidebar-active-bg: rgba(188,159,105,0.18);
--sidebar-active-border: #bc9f69;
--sidebar-section-color: rgba(188,159,105,0.7);
/* Typography */
--font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-size-base: 1rem;
--font-family: "Inter", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-size-base: 0.9375rem;
--font-size-sm: 0.875rem;
--font-size-xs: 0.75rem;
--font-size-lg: 1.25rem;
--font-size-lg: 1.125rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Spacing & Borders */
@@ -51,16 +66,16 @@
--spacing-4: 1.5rem;
--spacing-5: 3rem;
--border-radius: 8px;
--border-radius-sm: 4px;
--border-radius-lg: 12px;
--border-radius: 10px;
--border-radius-sm: 6px;
--border-radius-lg: 14px;
--border-radius-full: 50px;
/* Shadows & Elevation */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-header: 0 2px 10px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 10px 30px rgba(0,0,0,0.1);
--shadow-header: 0 1px 0 rgba(0,0,0,0.06);
/* Transitions */
--transition-base: all 0.2s ease-in-out;

View File

@@ -64,6 +64,21 @@ const AUDIT_ACTIONS = Object.freeze({
// Video Gallery
UPDATE_VIDEO_GALLERY: "UPDATE_VIDEO_GALLERY",
// Degree (legacy)
CREATE_DEGREE: "CREATE_DEGREE",
UPDATE_DEGREE: "UPDATE_DEGREE",
DELETE_DEGREE: "DELETE_DEGREE",
// Qualification
CREATE_QUALIFICATION: "CREATE_QUALIFICATION",
UPDATE_QUALIFICATION: "UPDATE_QUALIFICATION",
DELETE_QUALIFICATION: "DELETE_QUALIFICATION",
// Certificate
CREATE_CERTIFICATE: "CREATE_CERTIFICATE",
UPDATE_CERTIFICATE: "UPDATE_CERTIFICATE",
DELETE_CERTIFICATE: "DELETE_CERTIFICATE",
// Auth / System
LOGIN: "LOGIN",
LOGOUT: "LOGOUT",

View File

@@ -1,251 +0,0 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const AboutUs = require("../models/aboutUs");
const Blog = require("../models/blog");
const jsonHelper = require("../utils/jsonHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/**
* GET /api/about
* Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu)
*/
exports.getAbout = async (req, res) => {
try {
// Force no-cache headers
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
const data = await AboutUs.getSingle();
const rawData = data.toObject();
// === Dynamic Blog News Section ===
const news = rawData.news || {};
let blogs = [];
// Nếu có chọn blog cụ thể
if (news.selectedBlogIds && news.selectedBlogIds.length > 0) {
blogs = await Blog.find({
_id: { $in: news.selectedBlogIds },
status: "published",
}).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => {
return (
news.selectedBlogIds.indexOf(a._id.toString()) -
news.selectedBlogIds.indexOf(b._id.toString())
);
});
}
// Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất
if (blogs.length === 0) {
blogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.limit(3)
.lean();
}
// Map dữ liệu blog sang format mà frontend mong đợi
news.items = blogs.map((blog) => ({
title: blog.title,
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
date:
blog.publishedAt ||
new Date(blog.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
}),
comments: blog.commentsCount || 0,
author: {
name: blog.author || "Admin",
avatar: "/assets/img/home-1/news/client.png", // Default avatar
},
link: `/blog/${blog.slug}`,
thumbnail: blog.featuredImage,
}));
rawData.news = news;
// ===============================
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(rawData, baseUrl);
res.json(processedData);
} catch (error) {
console.error("Error getting about data:", error);
res.status(500).json({
success: false,
error: "Failed to get about data",
});
}
};
/**
* PUT /api/about
* Cập nhật dữ liệu About Us (Dùng cho AJAX từ CMS)
*/
exports.updateAbout = async (req, res) => {
try {
let updateData = req.body;
// Nếu dữ liệu gửi qua trường aboutJson (dạng string JSON)
if (updateData.aboutJson && typeof updateData.aboutJson === "string") {
try {
updateData = JSON.parse(updateData.aboutJson);
} catch (e) {
return res.status(400).json({
success: false,
message: "Invalid JSON in aboutJson",
});
}
}
const doc = await AboutUs.getSingle();
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
// Use .set() for better handling of nested objects/arrays in Mongoose
doc.set(updateData);
await doc.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
// ✅ AUDIT LOGGING - About Us Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "AboutUs",
documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_ABOUT_US,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for About Us update: ${changes.length} changes`,
);
} else {
console.log(" No changes detected for About Us update");
}
// Fetch fresh data for syncing and returning
const finalData = await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
// Update about.json file to keep it in sync
jsonHelper.writeJsonFile("about", finalData);
res.json({
success: true,
message: "About Us updated successfully",
data: finalData,
});
} catch (error) {
console.error("Error updating about data:", error);
res.status(500).json({
success: false,
error: "Failed to update about data: " + error.message,
});
}
};
/**
* Render admin page (Dùng cho Admin UI)
*/
exports.index = async (req, res) => {
try {
const data = await AboutUs.getSingle();
const rawData = data.toObject();
// Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.lean();
const activeTab = req.query.activeTab || "hero";
res.render("admin/aboutUs/index", {
layout: "layouts/main",
title: "About Us Management",
data: rawData,
allBlogs,
activeTab,
user: req.session.user,
currentPath: req.path,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
backendUrl: process.env.BACKEND_URL || "http://localhost:3001",
});
} catch (err) {
console.error("Error in about index:", err);
req.flash("error_msg", "Error loading About Us page");
res.redirect("/admin/dashboard");
}
};
/**
* Update method cho form-based submission (Admin UI - Post fallback)
*/
exports.update = async (req, res) => {
try {
let updateData = req.body;
if (updateData.aboutJson && typeof updateData.aboutJson === "string") {
try {
updateData = JSON.parse(updateData.aboutJson);
} catch (e) {
req.flash("error_msg", "Invalid JSON data");
return res.redirect("/admin/about-us");
}
}
const doc = await AboutUs.getSingle();
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
doc.set(updateData);
await doc.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
// ✅ AUDIT LOGGING - About Us Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "AboutUs",
documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_ABOUT_US,
before: beforeData,
after: afterData,
changes,
req,
});
}
const finalData = await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
jsonHelper.writeJsonFile("about", finalData);
req.flash("success_msg", "About Us updated successfully");
const activeTab = req.query.activeTab || "hero";
res.redirect(`/admin/about-us?activeTab=${activeTab}`);
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", "Error updating About Us: " + err.message);
res.redirect("/admin/about-us");
}
};
// Aliases for compatibility
exports.api = exports.getAbout;
exports.page = exports.getAbout;
exports.updateAboutUs = exports.updateAbout;

File diff suppressed because it is too large Load Diff

View File

@@ -1,450 +0,0 @@
const AppointmentSubmission = require("../models/appointmentSubmission");
const Appointment = require("../models/appointment");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// ==================== CMS ADMIN FUNCTIONS ====================
// Render admin page for appointment management
exports.index = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
// If no data in DB, try to load from JSON file
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
} else {
// Create default appointment
appointment = await Appointment.create({
name: "default",
hero: {
title: "Make Appointment",
backgroundImage: "",
subtitle: "",
heading: "",
description: "",
},
visaOptions: [],
form: {
heading: "Request Appointment",
fields: [],
submitButton: {
text: "Request Appointment",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn",
},
},
});
}
}
const { startDate, endDate } = req.query;
const query = {};
if (startDate || endDate) {
query.createdAt = {};
if (startDate) {
query.createdAt.$gte = new Date(startDate);
}
if (endDate) {
// Set end date to end of day
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
query.createdAt.$lte = end;
}
}
const submissions = await AppointmentSubmission.find(query)
.sort({ createdAt: -1 })
.limit(50);
res.render("admin/appointment/index", {
layout: "layouts/main",
title: "Appointment Management",
data: appointment,
submissions,
startDate,
endDate,
user: req.session.user,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
});
} catch (err) {
console.error("Error loading appointment admin page:", err);
req.flash("error", "Error loading appointment data");
res.redirect("/admin/dashboard");
}
};
// Update appointment data
exports.update = async (req, res) => {
try {
const { hero, visaOptions, form } = req.body;
// Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const visaOptionsData =
typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions;
const formData = typeof form === "string" ? JSON.parse(form) : form;
let appointment = await Appointment.findOne({ name: "default" });
// Capture before state for audit logging
const beforeState = appointment
? JSON.parse(JSON.stringify(appointment.toObject()))
: null;
if (appointment) {
appointment.hero = heroData;
appointment.visaOptions = visaOptionsData;
appointment.form = formData;
await appointment.save();
} else {
appointment = await Appointment.create({
name: "default",
hero: heroData,
visaOptions: visaOptionsData,
form: formData,
});
}
// Capture after state for audit logging
const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
// Generate changes diff
const changes = beforeState ? diffObject(beforeState, afterState) : [];
// Write audit log
await writeAuditLog({
model: "Appointment",
documentId: appointment._id,
action: AUDIT_ACTIONS.UPDATE_APPOINTMENT,
before: beforeState,
after: afterState,
changes,
req,
});
req.flash("success", "Appointment data updated successfully");
res.redirect("/admin/appointment");
} catch (err) {
console.error("Error updating appointment:", err);
req.flash("error", "Error updating appointment data");
res.redirect("/admin/appointment");
}
};
// API to get appointment data
exports.getAppointmentData = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
}
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error getting appointment data:", err);
res.status(500).json({
success: false,
error: "Error loading appointment data",
});
}
};
// Public API to get appointment page data (for frontend)
exports.api = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
}
}
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment data not found",
});
}
res.json({
success: true,
data: {
hero: appointment.hero,
visaOptions: appointment.visaOptions,
form: appointment.form,
},
});
} catch (err) {
console.error("Error getting appointment API data:", err);
res.status(500).json({
success: false,
error: "Error loading appointment data",
});
}
};
// ==================== APPOINTMENT SUBMISSIONS API ====================
// API để submit appointment form (từ frontend)
exports.submitAppointment = async (req, res) => {
try {
const { name, email, phone, address, appointmentDate, message, visaTypes } =
req.body;
// Validation
if (!name || !email) {
return res.status(400).json({
success: false,
error: "Name and email are required",
});
}
// Create new submission
const submission = new AppointmentSubmission({
name: name.trim(),
email: email.trim().toLowerCase(),
phone: phone?.trim() || "",
address: address?.trim() || "",
appointmentDate: appointmentDate?.trim() || "",
message: message?.trim() || "",
visaTypes: Array.isArray(visaTypes) ? visaTypes : [],
ipAddress: req.ip || req.connection?.remoteAddress || "",
userAgent: req.get("User-Agent") || "",
});
await submission.save();
res.status(201).json({
success: true,
message:
"Thank you! Your appointment request has been submitted. We will contact you soon.",
data: {
id: submission._id,
name: submission.name,
email: submission.email,
appointmentDate: submission.appointmentDate,
},
});
} catch (err) {
console.error("Error submitting appointment:", err);
// Handle validation errors
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({
success: false,
error: errors.join(", "),
});
}
res.status(500).json({
success: false,
error: "Error submitting appointment. Please try again later.",
});
}
};
// API để lấy danh sách appointments (cho admin)
exports.getAppointments = async (req, res) => {
try {
const { status, page = 1, limit = 20 } = req.query;
const query = {};
if (
status &&
["pending", "confirmed", "completed", "cancelled"].includes(status)
) {
query.status = status;
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [appointments, total] = await Promise.all([
AppointmentSubmission.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
AppointmentSubmission.countDocuments(query),
]);
res.json({
success: true,
data: appointments,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / parseInt(limit)),
},
});
} catch (err) {
console.error("Error getting appointments:", err);
res.status(500).json({
success: false,
error: "Error loading appointments",
});
}
};
// API để cập nhật status của appointment
exports.updateAppointmentStatus = async (req, res) => {
try {
const { id } = req.params;
const { status, notes } = req.body;
const validStatuses = ["pending", "confirmed", "completed", "cancelled"];
if (!validStatuses.includes(status)) {
return res.status(400).json({
success: false,
error: "Invalid status",
});
}
// Get the appointment before update for audit logging
const beforeAppointment = await AppointmentSubmission.findById(id);
if (!beforeAppointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
const beforeState = JSON.parse(
JSON.stringify(beforeAppointment.toObject()),
);
const updateData = { status };
if (notes !== undefined) updateData.notes = notes;
if (status === "confirmed") updateData.confirmedAt = new Date();
if (status === "completed") updateData.completedAt = new Date();
const appointment = await AppointmentSubmission.findByIdAndUpdate(
id,
updateData,
{ new: true },
);
// Capture after state for audit logging
const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
// Generate changes diff
const changes = diffObject(beforeState, afterState);
// Write audit log
await writeAuditLog({
model: "AppointmentSubmission",
documentId: appointment._id,
action: AUDIT_ACTIONS.UPDATE_APPOINTMENT_STATUS,
before: beforeState,
after: afterState,
changes,
req,
});
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error updating appointment:", err);
res.status(500).json({
success: false,
error: "Error updating appointment",
});
}
};
// API để lấy chi tiết một appointment
exports.getAppointmentById = async (req, res) => {
try {
const { id } = req.params;
const appointment = await AppointmentSubmission.findById(id);
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error getting appointment:", err);
res.status(500).json({
success: false,
error: "Error loading appointment",
});
}
};
// API để xóa appointment
exports.deleteAppointment = async (req, res) => {
try {
const { id } = req.params;
// Get the appointment before deletion for audit logging
const appointment = await AppointmentSubmission.findById(id);
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
const beforeState = JSON.parse(JSON.stringify(appointment.toObject()));
// Delete the appointment
await AppointmentSubmission.findByIdAndDelete(id);
// Write audit log
await writeAuditLog({
model: "AppointmentSubmission",
documentId: appointment._id,
action: AUDIT_ACTIONS.DELETE_APPOINTMENT,
before: beforeState,
after: null,
changes: [],
req,
});
res.json({
success: true,
message: "Appointment deleted successfully",
});
} catch (err) {
console.error("Error deleting appointment:", err);
res.status(500).json({
success: false,
error: "Error deleting appointment",
});
}
};

View File

@@ -54,7 +54,7 @@ exports.index = async (req, res) => {
res.render("admin/audit-log/index", {
title: "Audit Logs",
layout: "layouts/main",
layout: "layouts/admin",
auditLogs,
pagination: {
current: page,
@@ -91,7 +91,7 @@ exports.show = async (req, res) => {
res.render("admin/audit-log/show", {
title: "Audit Log Details",
layout: "layouts/main",
layout: "layouts/admin",
auditLog,
currentPath: req.path,
user: req.session.user,

View File

@@ -1,342 +0,0 @@
const BlogCategory = require('../models/blogCategory');
const slugify = require('slugify');
// -------------------- Admin Controllers --------------------
// Display category management page
exports.index = async (req, res) => {
try {
const categories = await BlogCategory.find()
.sort({ name: 1 })
.lean();
res.render('admin/blog/categories/index', {
layout: 'layouts/main',
title: 'Blog Categories',
categories,
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Category index error:', err);
req.flash('error_msg', 'Error loading categories');
res.redirect('/admin/dashboard');
}
};
// Show create category form
exports.create = async (req, res) => {
try {
res.render('admin/blog/categories/create', {
layout: 'layouts/main',
title: 'Create New Category',
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Category create form error:', err);
req.flash('error_msg', 'Error loading create form');
res.redirect('/admin/blog/categories');
}
};
// Store new category
exports.store = async (req, res) => {
try {
const {
name,
description,
isActive
} = req.body;
// Generate slug
const slug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if slug exists
const existingCategory = await BlogCategory.findOne({ slug });
if (existingCategory) {
req.flash('error_msg', 'A category with this name already exists');
return res.redirect('/admin/blog/categories/create');
}
// Create category data
const categoryData = {
name,
slug,
description,
isActive: isActive === 'on'
};
// Create category
const category = new BlogCategory(categoryData);
await category.save();
req.flash('success_msg', 'Category created successfully');
res.redirect('/admin/blog/categories');
} catch (err) {
console.error('Category store error:', err);
req.flash('error_msg', 'Error creating category');
res.redirect('/admin/blog/categories/create');
}
};
// Show edit category form
exports.edit = async (req, res) => {
try {
const category = await BlogCategory.findById(req.params.id);
if (!category) {
req.flash('error_msg', 'Category not found');
return res.redirect('/admin/blog/categories');
}
res.render('admin/blog/categories/edit', {
layout: 'layouts/main',
title: 'Edit Category',
category,
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Category edit form error:', err);
req.flash('error_msg', 'Error loading category');
res.redirect('/admin/blog/categories');
}
};
// Update category
exports.update = async (req, res) => {
try {
const category = await BlogCategory.findById(req.params.id);
if (!category) {
req.flash('error_msg', 'Category not found');
return res.redirect('/admin/blog/categories');
}
const {
name,
description,
isActive
} = req.body;
// Update category data
category.name = name;
category.description = description;
category.isActive = isActive === 'on';
// Generate new slug if name changed
const newSlug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
if (newSlug !== category.slug) {
const existingCategory = await BlogCategory.findOne({
slug: newSlug,
_id: { $ne: category._id }
});
if (existingCategory) {
req.flash('error_msg', 'A category with this name already exists');
return res.redirect(`/admin/blog/categories/${category._id}/edit`);
}
category.slug = newSlug;
}
await category.save();
req.flash('success_msg', 'Category updated successfully');
res.redirect('/admin/blog/categories');
} catch (err) {
console.error('Category update error:', err);
req.flash('error_msg', 'Error updating category');
res.redirect(`/admin/blog/categories/${req.params.id}/edit`);
}
};
// Delete category
exports.destroy = async (req, res) => {
try {
const category = await BlogCategory.findById(req.params.id);
if (!category) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
req.flash('error_msg', 'Category not found');
return res.redirect('/admin/blog/categories');
}
// Check if category has posts
const Blog = require('../models/blog');
const postCount = await Blog.countDocuments({ category: { $in: [category.name] } });
if (postCount > 0) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(400).json({
success: false,
message: 'Cannot delete category that has blog posts'
});
}
req.flash('error_msg', 'Cannot delete category that has blog posts');
return res.redirect('/admin/blog/categories');
}
await BlogCategory.findByIdAndDelete(req.params.id);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.json({
success: true,
message: 'Category deleted successfully'
});
}
req.flash('success_msg', 'Category deleted successfully');
res.redirect('/admin/blog/categories');
} catch (err) {
console.error('Category delete error:', err);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(500).json({
success: false,
message: 'Error deleting category',
error: err.message || 'Error deleting category'
});
}
req.flash('error_msg', 'Error deleting category');
res.redirect('/admin/blog/categories');
}
};
// -------------------- Public API Controllers --------------------
// Get all active categories
exports.api = async (req, res) => {
try {
const categories = await BlogCategory.getActive();
// Update post counts
for (const category of categories) {
await category.updatePostCount();
}
res.json({
success: true,
message: 'Categories fetched successfully',
data: categories
});
} catch (err) {
console.error('Categories API error:', err);
res.status(500).json({
success: false,
message: 'Error loading categories',
error: err.message || 'Error loading categories'
});
}
};
// Get category by slug
exports.apiShow = async (req, res) => {
try {
const category = await BlogCategory.findOne({
slug: req.params.slug,
isActive: true
}).lean();
if (!category) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
res.json({
success: true,
message: 'Category fetched successfully',
data: category
});
} catch (err) {
console.error('Category show API error:', err);
res.status(500).json({
success: false,
message: 'Error loading category',
error: err.message || 'Error loading category'
});
}
};
// Quick create category (for inline creation in blog form)
exports.quickCreate = async (req, res) => {
try {
const { name, description } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({
success: false,
message: 'Category name is required'
});
}
const categoryName = name.trim();
// Generate slug
const slug = slugify(categoryName, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if category already exists
let category = await BlogCategory.findOne({ slug });
if (category) {
return res.json({
success: true,
message: 'Category already exists',
data: category.toObject()
});
}
// Create new category
category = new BlogCategory({
name: categoryName,
slug,
description: description || '',
isActive: true
});
await category.save();
res.json({
success: true,
message: 'Category created successfully',
data: category.toObject()
});
} catch (err) {
console.error('Quick create category error:', err);
res.status(500).json({
success: false,
message: 'Error creating category',
error: err.message || 'Error creating category'
});
}
};
module.exports = exports;

View File

@@ -1,901 +0,0 @@
const Blog = require("../models/blog");
const BlogCategory = require("../models/blogCategory");
const BlogTag = require("../models/blogTag");
const BlogComment = require("../models/blogComment");
const RecentPost = require("../models/recentPost");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const slugify = require("slugify");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// -------------------- Helper Functions --------------------
// Generate slug from title
const generateSlug = (title) => {
return slugify(title, {
lower: true,
strict: true,
locale: "vi",
});
};
// Update category post counts
const updateCategoryPostCounts = async () => {
const categories = await BlogCategory.find();
for (const category of categories) {
await category.updatePostCount();
}
};
// Update tag post counts
const updateTagPostCounts = async () => {
const tags = await BlogTag.find();
for (const tag of tags) {
await tag.updatePostCount();
}
};
// -------------------- Admin Controllers --------------------
// Display blog management page
exports.index = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Build filter
const filter = {};
if (req.query.status) {
filter.status = req.query.status;
}
if (req.query.category) {
filter.category = req.query.category;
}
if (req.query.search) {
filter.$or = [
{ title: { $regex: req.query.search, $options: "i" } },
{ excerpt: { $regex: req.query.search, $options: "i" } },
];
}
// Get blogs with pagination
const blogs = await Blog.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean();
const totalBlogs = await Blog.countDocuments(filter);
const totalPages = Math.ceil(totalBlogs / limit);
// Get categories for filter
const categories = await BlogCategory.getActive();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render("admin/blog/index", {
layout: "layouts/main",
title: "Blog Management",
blogs,
categories,
frontendUrl,
backendUrl,
getFullImageUrl, // Truyền helper function vào template
pagination: {
current: page,
total: totalPages,
limit,
totalItems: totalBlogs,
},
query: req.query,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("Blog index error:", err);
req.flash("error_msg", "Error loading blogs");
res.redirect("/admin/dashboard");
}
};
// Show create blog form
exports.create = async (req, res) => {
try {
const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render("admin/blog/create", {
layout: "layouts/main",
title: "Create New Blog Post",
categories,
tags,
currentPath: req.path,
user: req.session.user,
frontendUrl,
backendUrl,
getFullImageUrl, // Truyền helper function vào template
});
} catch (err) {
console.error("Blog create form error:", err);
req.flash("error_msg", "Error loading create form");
res.redirect("/admin/blog");
}
};
// Store new blog
exports.store = async (req, res) => {
try {
const {
title,
excerpt,
content,
category,
tags,
status,
isFeatured,
author,
galleryImages,
quote,
contentAfterQuote,
} = req.body;
// Generate slug
const slug = generateSlug(title);
// Check if slug exists
const existingBlog = await Blog.findOne({ slug });
if (existingBlog) {
req.flash("error_msg", "A blog post with this title already exists");
return res.redirect("/admin/blog/create");
}
// Create blog data
const blogData = {
title,
slug,
excerpt,
content,
category: category
? Array.isArray(category)
? category
: [category]
: [], // Array categories
tags: tags ? (Array.isArray(tags) ? tags : [tags]) : [],
status: status || "published",
isFeatured: isFeatured === "on",
author: author || "Admin",
galleryImages: galleryImages
? Array.isArray(galleryImages)
? galleryImages
: [galleryImages]
: [],
quote: quote || "",
contentAfterQuote: contentAfterQuote || "",
};
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) {
blogData.featuredImage = req.body.featuredImageUrl;
}
// Create blog
const blog = new Blog(blogData);
await blog.save();
// AUDIT LOGGING - Blog Created
await writeAuditLog({
model: "Blog",
documentId: blog._id,
action: AUDIT_ACTIONS.CREATE_BLOG,
before: null, // No before state for CREATE
after: JSON.parse(JSON.stringify(blog.toObject())),
changes: [], // No changes for CREATE
req,
});
// Update counts
await updateCategoryPostCounts();
await updateTagPostCounts();
await RecentPost.syncFromBlogs();
req.flash("success_msg", "Blog post created successfully");
res.redirect("/admin/blog");
} catch (err) {
console.error("Blog store error:", err);
req.flash("error_msg", "Error creating blog post");
res.redirect("/admin/blog/create");
}
};
// Show edit blog form
exports.edit = async (req, res) => {
try {
const blog = await Blog.findById(req.params.id);
if (!blog) {
req.flash("error_msg", "Blog post not found");
return res.redirect("/admin/blog");
}
const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive();
// Get all comments for this blog post (including pending, approved, rejected)
const allComments = await BlogComment.find({ postId: blog._id })
.sort({ createdAt: -1 })
.lean();
// Organize comments with replies
const parentComments = allComments.filter((c) => !c.parentId);
const commentsWithReplies = parentComments.map((parent) => {
const replies = allComments.filter(
(c) => c.parentId && c.parentId.toString() === parent._id.toString(),
);
return {
...parent,
replies: replies,
};
});
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render("admin/blog/edit", {
layout: "layouts/main",
title: "Edit Blog Post",
blog,
categories,
tags,
comments: commentsWithReplies,
commentsCount: allComments.length,
currentPath: req.path,
user: req.session.user,
frontendUrl,
backendUrl,
getFullImageUrl, // Truyền helper function vào template
});
} catch (err) {
console.error("Blog edit form error:", err);
req.flash("error_msg", "Error loading blog post");
res.redirect("/admin/blog");
}
};
// Update blog
exports.update = async (req, res) => {
try {
const blog = await Blog.findById(req.params.id);
if (!blog) {
req.flash("error_msg", "Blog post not found");
return res.redirect("/admin/blog");
}
// Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(blog.toObject()));
const {
title,
excerpt,
content,
category,
tags,
status,
isFeatured,
author,
galleryImages,
quote,
contentAfterQuote,
} = req.body;
// Update blog data
blog.title = title;
blog.excerpt = excerpt;
blog.content = content;
blog.category = category
? Array.isArray(category)
? category
: [category]
: []; // Array categories
blog.tags = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
blog.status = status || "published";
blog.isFeatured = isFeatured === "on";
blog.author = author || "Admin";
blog.galleryImages = galleryImages
? Array.isArray(galleryImages)
? galleryImages
: [galleryImages]
: [];
blog.quote = quote || "";
blog.contentAfterQuote = contentAfterQuote || "";
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) {
blog.featuredImage = req.body.featuredImageUrl;
}
// Generate new slug if title changed
const newSlug = generateSlug(title);
if (newSlug !== blog.slug) {
const existingBlog = await Blog.findOne({
slug: newSlug,
_id: { $ne: blog._id },
});
if (existingBlog) {
req.flash("error_msg", "A blog post with this title already exists");
return res.redirect(`/admin/blog/${blog._id}/edit`);
}
blog.slug = newSlug;
}
await blog.save();
// Capture AFTER state
const afterData = JSON.parse(JSON.stringify(blog.toObject()));
// AUDIT LOGGING - Blog Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Blog",
documentId: blog._id,
action: AUDIT_ACTIONS.UPDATE_BLOG,
before: beforeData,
after: afterData,
changes,
req,
});
}
// Update counts
await updateCategoryPostCounts();
await updateTagPostCounts();
await RecentPost.syncFromBlogs();
req.flash("success_msg", "Blog post updated successfully");
res.redirect("/admin/blog");
} catch (err) {
console.error("Blog update error:", err);
req.flash("error_msg", "Error updating blog post");
res.redirect(`/admin/blog/${req.params.id}/edit`);
}
};
// Delete blog
exports.destroy = async (req, res) => {
try {
const blog = await Blog.findById(req.params.id);
if (!blog) {
req.flash("error_msg", "Blog post not found");
return res.redirect("/admin/blog");
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(blog.toObject()));
await Blog.findByIdAndDelete(req.params.id);
// ✅ AUDIT LOGGING - Blog Deleted
await writeAuditLog({
model: "Blog",
documentId: req.params.id,
action: AUDIT_ACTIONS.DELETE_BLOG,
before: beforeData,
after: null, // No after state for DELETE
changes: [],
req,
});
// Update counts
await updateCategoryPostCounts();
await updateTagPostCounts();
await RecentPost.syncFromBlogs();
req.flash("success_msg", "Blog post deleted successfully");
res.redirect("/admin/blog");
} catch (err) {
console.error("Blog delete error:", err);
req.flash("error_msg", "Error deleting blog post");
res.redirect("/admin/blog");
}
};
// -------------------- Public API Controllers --------------------
// Get all published blogs for frontend
exports.api = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Build filter
const filter = { status: "published" };
if (req.query.category) {
filter.category = { $in: [req.query.category] }; // Tìm trong array categories
}
if (req.query.tag) {
filter.tags = { $in: [req.query.tag] }; // Tìm trong array tags
}
if (req.query.search) {
filter.$or = [
{ title: { $regex: req.query.search, $options: "i" } },
{ excerpt: { $regex: req.query.search, $options: "i" } },
];
}
// Get blogs
const blogs = await Blog.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean();
const totalBlogs = await Blog.countDocuments(filter);
// Add base URL to images
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedBlogs = blogs.map((blog) =>
addBaseUrlToImages(blog, baseUrl),
);
res.json({
success: true,
message: "Blogs fetched successfully",
data: {
blogs: processedBlogs,
pagination: {
current: page,
total: Math.ceil(totalBlogs / limit),
limit,
totalItems: totalBlogs,
},
},
});
} catch (err) {
console.error("Blog API error:", err);
res.status(500).json({
success: false,
message: "Error loading blogs",
error: err.message || "Error loading blogs",
});
}
};
// Get single blog by slug
exports.apiShow = async (req, res) => {
try {
const blog = await Blog.findOne({
slug: req.params.slug,
status: "published",
}).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// Get comments for this post (parent comments only)
const parentComments = await BlogComment.getApprovedByPost(blog._id);
// Get replies for each parent comment
const commentsWithReplies = await Promise.all(
parentComments.map(async (parentComment) => {
const replies = await BlogComment.getReplies(parentComment._id);
return {
...parentComment.toObject(),
replies: replies.map((reply) => reply.toObject()),
};
}),
);
// Flatten comments array (parent + replies)
const allComments = commentsWithReplies.flatMap((comment) => [
comment,
...comment.replies,
]);
// Add comments to blog
blog.comments = allComments;
// Keep commentsCount in sync for frontend
blog.commentsCount = allComments.length;
// Add base URL to images
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedBlog = addBaseUrlToImages(blog, baseUrl);
res.json({
success: true,
message: "Blog post fetched successfully",
data: processedBlog,
});
} catch (err) {
console.error("Blog show API error:", err);
res.status(500).json({
success: false,
message: "Error loading blog post",
error: err.message || "Error loading blog post",
});
}
};
// Create a comment (no moderation for now: default approved)
exports.apiCreateComment = async (req, res) => {
try {
const {
authorName,
authorEmail,
authorPhone,
authorAddress,
authorDate,
content,
parentId,
} = req.body || {};
if (!authorName || !String(authorName).trim()) {
return res.status(400).json({
success: false,
message: "authorName is required",
});
}
if (!content || !String(content).trim()) {
return res.status(400).json({
success: false,
message: "content is required",
});
}
const blog = await Blog.findOne({
slug: req.params.slug,
status: "published",
}).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// If replying, ensure parent exists and belongs to same post
let parentObjectId = null;
if (parentId) {
const parent = await BlogComment.findOne({
_id: parentId,
postId: blog._id,
}).lean();
if (!parent) {
return res.status(400).json({
success: false,
message: "Invalid parentId",
});
}
parentObjectId = parentId;
}
const newComment = await BlogComment.create({
postId: blog._id,
authorName: String(authorName).trim(),
...(authorEmail ? { authorEmail: String(authorEmail).trim() } : {}),
...(authorPhone ? { authorPhone: String(authorPhone).trim() } : {}),
...(authorAddress ? { authorAddress: String(authorAddress).trim() } : {}),
...(authorDate ? { authorDate: String(authorDate).trim() } : {}),
content: String(content).trim(),
parentId: parentObjectId,
status: "approved",
});
// Keep counter roughly correct (also counts replies)
await Blog.updateOne({ _id: blog._id }, { $inc: { commentsCount: 1 } });
return res.json({
success: true,
message: "Comment created successfully",
data: newComment.toJSON(),
});
} catch (err) {
console.error("Create comment API error:", err);
return res.status(500).json({
success: false,
message: "Error creating comment",
error: err.message || "Error creating comment",
});
}
};
// Get featured blogs
exports.apiFeatured = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 3;
const blogs = await Blog.getFeatured().limit(limit).lean();
// Add base URL to images
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("port")}`;
const processedBlogs = blogs.map((blog) =>
addBaseUrlToImages(blog, baseUrl),
);
res.json({
success: true,
message: "Featured blogs fetched successfully",
data: processedBlogs,
});
} catch (err) {
console.error("Featured blogs API error:", err);
res.status(500).json({
success: false,
message: "Error loading featured blogs",
error: err.message || "Error loading featured blogs",
});
}
};
// Get recent blogs
exports.apiRecent = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 5;
// Try to get from RecentPost first
let recentPosts = await RecentPost.getRecent(limit);
// If no recent posts, sync from blogs
if (recentPosts.length === 0) {
await RecentPost.syncFromBlogs(limit);
recentPosts = await RecentPost.getRecent(limit);
}
// Add base URL to images
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedPosts = recentPosts.map((post) =>
addBaseUrlToImages(post, baseUrl),
);
res.json({
success: true,
message: "Recent blogs fetched successfully",
data: processedPosts,
});
} catch (err) {
console.error("Recent blogs API error:", err);
res.status(500).json({
success: false,
message: "Error loading recent blogs",
error: err.message || "Error loading recent blogs",
});
}
};
// Get categories of a specific blog post
exports.apiCategories = async (req, res) => {
try {
const mongoose = require("mongoose");
let query;
// Check if it's a valid ObjectId
if (mongoose.Types.ObjectId.isValid(req.params.id)) {
query = { _id: req.params.id };
} else {
query = { slug: req.params.id };
}
query.status = "published";
const blog = await Blog.findOne(query).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// Get category details
const BlogCategory = require("../models/blogCategory");
const categories = await BlogCategory.find({
name: { $in: blog.category },
isActive: true,
}).lean();
res.json({
success: true,
message: "Blog categories fetched successfully",
data: categories,
});
} catch (err) {
console.error("Blog categories API error:", err);
res.status(500).json({
success: false,
message: "Error loading blog categories",
error: err.message || "Error loading blog categories",
});
}
};
// Get tags of a specific blog post
exports.apiTags = async (req, res) => {
try {
const mongoose = require("mongoose");
let query;
// Check if it's a valid ObjectId
if (mongoose.Types.ObjectId.isValid(req.params.id)) {
query = { _id: req.params.id };
} else {
query = { slug: req.params.id };
}
query.status = "published";
const blog = await Blog.findOne(query).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// Get tag details
const BlogTag = require("../models/blogTag");
const tags = await BlogTag.find({
name: { $in: blog.tags },
isActive: true,
}).lean();
res.json({
success: true,
message: "Blog tags fetched successfully",
data: tags,
});
} catch (err) {
console.error("Blog tags API error:", err);
res.status(500).json({
success: false,
message: "Error loading blog tags",
error: err.message || "Error loading blog tags",
});
}
};
// -------------------- Comment Management Controllers --------------------
// Approve a comment
exports.approveComment = async (req, res) => {
try {
const { blogId, commentId } = req.params;
const blog = await Blog.findById(blogId);
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
const comment = await BlogComment.findById(commentId);
if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({
success: false,
message: "Comment not found",
});
}
comment.status = "approved";
await comment.save();
res.json({
success: true,
message: "Comment approved successfully",
});
} catch (err) {
console.error("Approve comment error:", err);
res.status(500).json({
success: false,
message: "Error approving comment",
error: err.message || "Error approving comment",
});
}
};
// Reject a comment
exports.rejectComment = async (req, res) => {
try {
const { blogId, commentId } = req.params;
const blog = await Blog.findById(blogId);
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
const comment = await BlogComment.findById(commentId);
if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({
success: false,
message: "Comment not found",
});
}
comment.status = "rejected";
await comment.save();
res.json({
success: true,
message: "Comment rejected successfully",
});
} catch (err) {
console.error("Reject comment error:", err);
res.status(500).json({
success: false,
message: "Error rejecting comment",
error: err.message || "Error rejecting comment",
});
}
};
// Delete a comment
exports.deleteComment = async (req, res) => {
try {
const { blogId, commentId } = req.params;
const blog = await Blog.findById(blogId);
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
const comment = await BlogComment.findById(commentId);
if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({
success: false,
message: "Comment not found",
});
}
// Delete the comment and all its replies
await BlogComment.deleteMany({
$or: [{ _id: commentId }, { parentId: commentId }],
});
// Update blog comment count
const remainingComments = await BlogComment.countDocuments({
postId: blogId,
});
await Blog.updateOne({ _id: blogId }, { commentsCount: remainingComments });
res.json({
success: true,
message: "Comment deleted successfully",
});
} catch (err) {
console.error("Delete comment error:", err);
res.status(500).json({
success: false,
message: "Error deleting comment",
error: err.message || "Error deleting comment",
});
}
};
module.exports = exports;

View File

@@ -1,358 +0,0 @@
const BlogTag = require('../models/blogTag');
const slugify = require('slugify');
// -------------------- Admin Controllers --------------------
// Display tag management page
exports.index = async (req, res) => {
try {
const tags = await BlogTag.find()
.sort({ name: 1 })
.lean();
res.render('admin/blog/tags/index', {
layout: 'layouts/main',
title: 'Blog Tags',
tags,
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Tag index error:', err);
req.flash('error_msg', 'Error loading tags');
res.redirect('/admin/dashboard');
}
};
// Show create tag form
exports.create = async (req, res) => {
try {
res.render('admin/blog/tags/create', {
layout: 'layouts/main',
title: 'Create New Tag',
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Tag create form error:', err);
req.flash('error_msg', 'Error loading create form');
res.redirect('/admin/blog/tags');
}
};
// Store new tag
exports.store = async (req, res) => {
try {
const {
name,
isActive
} = req.body;
// Generate slug
const slug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if slug exists
const existingTag = await BlogTag.findOne({ slug });
if (existingTag) {
req.flash('error_msg', 'A tag with this name already exists');
return res.redirect('/admin/blog/tags/create');
}
// Create tag data
const tagData = {
name,
slug,
isActive: isActive === 'on'
};
// Create tag
const tag = new BlogTag(tagData);
await tag.save();
req.flash('success_msg', 'Tag created successfully');
res.redirect('/admin/blog/tags');
} catch (err) {
console.error('Tag store error:', err);
req.flash('error_msg', 'Error creating tag');
res.redirect('/admin/blog/tags/create');
}
};
// Show edit tag form
exports.edit = async (req, res) => {
try {
const tag = await BlogTag.findById(req.params.id);
if (!tag) {
req.flash('error_msg', 'Tag not found');
return res.redirect('/admin/blog/tags');
}
res.render('admin/blog/tags/edit', {
layout: 'layouts/main',
title: 'Edit Tag',
tag,
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Tag edit form error:', err);
req.flash('error_msg', 'Error loading tag');
res.redirect('/admin/blog/tags');
}
};
// Update tag
exports.update = async (req, res) => {
try {
const tag = await BlogTag.findById(req.params.id);
if (!tag) {
req.flash('error_msg', 'Tag not found');
return res.redirect('/admin/blog/tags');
}
const {
name,
isActive
} = req.body;
// Update tag data
tag.name = name;
tag.isActive = isActive === 'on';
// Generate new slug if name changed
const newSlug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
if (newSlug !== tag.slug) {
const existingTag = await BlogTag.findOne({
slug: newSlug,
_id: { $ne: tag._id }
});
if (existingTag) {
req.flash('error_msg', 'A tag with this name already exists');
return res.redirect(`/admin/blog/tags/${tag._id}/edit`);
}
tag.slug = newSlug;
}
await tag.save();
req.flash('success_msg', 'Tag updated successfully');
res.redirect('/admin/blog/tags');
} catch (err) {
console.error('Tag update error:', err);
req.flash('error_msg', 'Error updating tag');
res.redirect(`/admin/blog/tags/${req.params.id}/edit`);
}
};
// Delete tag
exports.destroy = async (req, res) => {
try {
const tag = await BlogTag.findById(req.params.id);
if (!tag) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(404).json({
success: false,
message: 'Tag not found'
});
}
req.flash('error_msg', 'Tag not found');
return res.redirect('/admin/blog/tags');
}
// Check if tag has posts
const Blog = require('../models/blog');
const postCount = await Blog.countDocuments({ tags: { $in: [tag.name] } });
if (postCount > 0) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(400).json({
success: false,
message: 'Cannot delete tag that is used in blog posts'
});
}
req.flash('error_msg', 'Cannot delete tag that is used in blog posts');
return res.redirect('/admin/blog/tags');
}
await BlogTag.findByIdAndDelete(req.params.id);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.json({
success: true,
message: 'Tag deleted successfully'
});
}
req.flash('success_msg', 'Tag deleted successfully');
res.redirect('/admin/blog/tags');
} catch (err) {
console.error('Tag delete error:', err);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(500).json({
success: false,
message: 'Error deleting tag',
error: err.message || 'Error deleting tag'
});
}
req.flash('error_msg', 'Error deleting tag');
res.redirect('/admin/blog/tags');
}
};
// -------------------- Public API Controllers --------------------
// Get all active tags
exports.api = async (req, res) => {
try {
const tags = await BlogTag.getActive();
// Update post counts
for (const tag of tags) {
await tag.updatePostCount();
}
res.json({
success: true,
message: 'Tags fetched successfully',
data: tags
});
} catch (err) {
console.error('Tags API error:', err);
res.status(500).json({
success: false,
message: 'Error loading tags',
error: err.message || 'Error loading tags'
});
}
};
// Get popular tags
exports.apiPopular = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const tags = await BlogTag.getPopular(limit);
res.json({
success: true,
message: 'Popular tags fetched successfully',
data: tags
});
} catch (err) {
console.error('Popular tags API error:', err);
res.status(500).json({
success: false,
message: 'Error loading popular tags',
error: err.message || 'Error loading popular tags'
});
}
};
// Get tag by slug
exports.apiShow = async (req, res) => {
try {
const tag = await BlogTag.findOne({
slug: req.params.slug,
isActive: true
}).lean();
if (!tag) {
return res.status(404).json({
success: false,
message: 'Tag not found'
});
}
res.json({
success: true,
message: 'Tag fetched successfully',
data: tag
});
} catch (err) {
console.error('Tag show API error:', err);
res.status(500).json({
success: false,
message: 'Error loading tag',
error: err.message || 'Error loading tag'
});
}
};
// Quick create tag (for inline creation in blog form)
exports.quickCreate = async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({
success: false,
message: 'Tag name is required'
});
}
const tagName = name.trim();
// Generate slug
const slug = slugify(tagName, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if tag already exists
let tag = await BlogTag.findOne({ slug });
if (tag) {
return res.json({
success: true,
message: 'Tag already exists',
data: tag.toObject()
});
}
// Create new tag
tag = new BlogTag({
name: tagName,
slug,
isActive: true
});
await tag.save();
res.json({
success: true,
message: 'Tag created successfully',
data: tag.toObject()
});
} catch (err) {
console.error('Quick create tag error:', err);
res.status(500).json({
success: false,
message: 'Error creating tag',
error: err.message || 'Error creating tag'
});
}
};
module.exports = exports;

View File

@@ -1,549 +0,0 @@
const fs = require('fs');
const path = require('path');
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Booking = require("../models/booking");
// -------------------- Public helpers --------------------
const getBookingData = async () => {
const booking = await Booking.findOne().sort({ updatedAt: -1 });
return booking ? (booking.toObject ? booking.toObject() : booking) : null;
};
// Load static booking JSON from `data/booking.json` (if present)
const loadStaticBooking = () => {
try {
const p = path.join(__dirname, '..', 'data', 'booking.json');
if (!fs.existsSync(p)) return null;
const raw = fs.readFileSync(p, 'utf8');
return JSON.parse(raw);
} catch (e) {
console.error('booking.loadStaticBooking error:', e && e.message);
return null;
}
};
// Normalize booking shape: ensure configuration exists with discounts/vouchers
const normalizeBookingShape = (booking) => {
if (!booking || typeof booking !== 'object') return booking;
const b = JSON.parse(JSON.stringify(booking));
if (!b.configuration || typeof b.configuration !== 'object') {
b.configuration = { currency: 'USD', discounts: [], vouchers: [] };
}
// Ensure configuration.discounts and configuration.vouchers exist
if (!Array.isArray(b.configuration.discounts)) {
b.configuration.discounts = [];
}
if (!Array.isArray(b.configuration.vouchers)) {
b.configuration.vouchers = [];
}
return b;
};
// Deep merge: properties from `overrides` replace / merge into `base`.
const deepMerge = (base, overrides) => {
if (overrides === undefined) return base;
if (base === undefined || base === null) return overrides;
if (Array.isArray(overrides)) return overrides;
if (typeof overrides !== 'object' || overrides === null) return overrides;
const out = Object.assign({}, base);
Object.keys(overrides).forEach((k) => {
if (Array.isArray(overrides[k]) || typeof overrides[k] !== 'object' || overrides[k] === null) {
out[k] = overrides[k];
} else {
out[k] = deepMerge(base[k], overrides[k]);
}
});
return out;
};
// Ensure booking data fields have the expected shapes to avoid runtime errors
const sanitizeBookingData = (raw) => {
const defaults = {
hero: { title: '', backgroundImage: '' },
searchBar: { locationLabel: '', holidaySeasonLabel: '', searchButtonText: '' },
filterPanel: {
title: '',
priceTitle: '',
priceLabel: '',
pricePlaceholder: '',
priceMin: 0,
priceMax: 0,
ageTitle: '',
ageMin: 0,
ageMax: 0,
ageSelectPlaceholder: '',
activitiesTitle: '',
ratingTitle: '',
ratingOptions: [],
resetButtonText: ''
},
programs: [],
holidays: [],
locations: [],
camps: [],
configuration: { currency: 'USD', discounts: [], vouchers: [] },
formSteps: [],
validation: {}
};
if (!raw || typeof raw !== 'object') return defaults;
// Use raw data first, then fill in missing fields with defaults
const safe = Object.assign({}, raw);
// Ensure nested objects/arrays have correct types (use raw data if valid, otherwise defaults)
safe.hero = (safe.hero && typeof safe.hero === 'object') ? safe.hero : defaults.hero;
safe.searchBar = (safe.searchBar && typeof safe.searchBar === 'object') ? safe.searchBar : defaults.searchBar;
safe.filterPanel = (safe.filterPanel && typeof safe.filterPanel === 'object') ? safe.filterPanel : defaults.filterPanel;
if (!Array.isArray(safe.filterPanel.ratingOptions)) safe.filterPanel.ratingOptions = defaults.filterPanel.ratingOptions;
safe.programs = Array.isArray(safe.programs) ? safe.programs : defaults.programs;
safe.holidays = Array.isArray(safe.holidays) ? safe.holidays : defaults.holidays;
safe.locations = Array.isArray(safe.locations) ? safe.locations : defaults.locations;
safe.camps = Array.isArray(safe.camps) ? safe.camps : defaults.camps;
// Ensure configuration has proper structure
if (!safe.configuration || typeof safe.configuration !== 'object') {
safe.configuration = defaults.configuration;
}
if (!Array.isArray(safe.configuration.discounts)) {
safe.configuration.discounts = defaults.configuration.discounts;
}
if (!Array.isArray(safe.configuration.vouchers)) {
safe.configuration.vouchers = defaults.configuration.vouchers;
}
// Ensure formSteps and validation have correct types
safe.formSteps = Array.isArray(safe.formSteps) ? safe.formSteps : defaults.formSteps;
safe.validation = (safe.validation && typeof safe.validation === 'object' && !Array.isArray(safe.validation)) ? safe.validation : defaults.validation;
return safe;
};
// Safe JSON parse with better error handling - handles double-encoded JSON and JS object notation
const safeParse = (value, fieldName = 'unknown') => {
// If already an object or array, return as-is
if (typeof value === 'object' && value !== null) {
return value;
}
// If string, try to parse
if (typeof value === 'string') {
try {
let cleaned = value.trim();
// Check if it looks like JavaScript object notation (has single quotes or unquoted keys)
if (cleaned.includes("'") || /\{\s*\w+:/.test(cleaned)) {
console.warn(`safeParse: Converting JS notation to JSON for "${fieldName}"`);
// Aggressive conversion approach
cleaned = cleaned
.replace(/'/g, '"') // Replace ALL single quotes with double quotes
.replace(/\r?\n|\r/g, ' ') // Remove all newlines
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas before } or ]
.replace(/([{,]\s*)(\w+):/g, '$1"$2":'); // Quote unquoted object keys
}
// Try parsing
let parsed = JSON.parse(cleaned);
// If result is still a string, try parsing again (double-encoded)
if (typeof parsed === 'string') {
console.warn(`safeParse: Double-encoded JSON detected for "${fieldName}"`);
parsed = JSON.parse(parsed);
}
return parsed;
} catch (e) {
console.error(`safeParse: Failed to parse field "${fieldName}"`, {
error: e.message,
valuePreview: value.substring(0, 200)
});
throw new Error(`Invalid JSON format for field: ${fieldName}. Error: ${e.message}`);
}
}
// For other types, return empty array or object
console.warn(`safeParse: Unexpected type for field "${fieldName}": ${typeof value}`);
return Array.isArray(value) ? [] : {};
};
// Validate booking data structure
const validateBookingData = (data) => {
const errors = [];
// Check required fields
if (!data.hero || typeof data.hero !== 'object') {
errors.push('Hero data is required and must be an object');
}
if (!data.searchBar || typeof data.searchBar !== 'object') {
errors.push('SearchBar data is required and must be an object');
}
if (!data.filterPanel || typeof data.filterPanel !== 'object') {
errors.push('FilterPanel data is required and must be an object');
}
// Validate arrays
if (data.programs && !Array.isArray(data.programs)) {
errors.push('Programs must be an array');
}
if (data.holidays && !Array.isArray(data.holidays)) {
errors.push('Holidays must be an array');
}
if (data.locations && !Array.isArray(data.locations)) {
errors.push('Locations must be an array');
}
if (data.camps && !Array.isArray(data.camps)) {
errors.push('Camps must be an array');
}
// Validate configuration structure
if (data.configuration) {
if (typeof data.configuration !== 'object') {
errors.push('Configuration must be an object');
} else {
if (data.configuration.discounts && !Array.isArray(data.configuration.discounts)) {
errors.push('Configuration.discounts must be an array');
}
if (data.configuration.vouchers && !Array.isArray(data.configuration.vouchers)) {
errors.push('Configuration.vouchers must be an array');
}
}
}
// Validate formSteps and validation structure if provided
if (data.formSteps && !Array.isArray(data.formSteps)) {
errors.push('formSteps must be an array');
}
if (data.validation && (typeof data.validation !== 'object' || Array.isArray(data.validation))) {
errors.push('validation must be an object');
}
return {
isValid: errors.length === 0,
errors
};
};
// -------------------- Public endpoints --------------------
// Public endpoint: return Booking JSON
exports.page = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
// Build final payload according to BOOKING_MODE env var
const finalBooking = getFinalBooking(normStatic, normDb);
if (!finalBooking) {
return res.status(404).json({
error: "No booking data found",
message: "Please configure booking data in admin panel"
});
}
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(finalBooking, baseUrl);
return res.json(processed);
} catch (err) {
console.error("booking.page error:", err);
return res.status(500).json({
error: "Error loading booking data",
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
};
// API endpoint to return booking JSON
exports.api = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
const finalBooking = getFinalBooking(normStatic, normDb);
if (!finalBooking) {
return res.status(404).json({
error: "No booking data found",
message: "Please configure booking data in admin panel"
});
}
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(finalBooking, baseUrl);
return res.json(processed);
} catch (err) {
console.error("booking.api error:", err);
return res.status(500).json({
error: "Error loading booking data",
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
};
// -------------------- Admin endpoints --------------------
// Display Booking management page
exports.index = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Merge static booking with DB data (use same merge logic as public endpoints)
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
const mergedData = getFinalBooking(normStatic, normDb);
// Normalize again after merge to ensure discounts/vouchers are synced to top-level
const data = normalizeBookingShape(mergedData);
// Sanitize data to ensure nested fields are objects/arrays (fixes malformed DB entries)
const safeData = sanitizeBookingData(data);
res.render("admin/booking/index", {
layout: "layouts/main",
title: "Booking Management",
data: safeData,
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("booking.index error:", err);
req.flash("error_msg", "Error loading booking page");
res.redirect("/admin/dashboard");
}
};
// Update booking data
exports.update = async (req, res) => {
try {
const { id } = req.params;
// ADD THIS DEBUG LOG
console.log('=== RAW REQUEST BODY ===');
console.log('Discounts type:', typeof req.body.discounts);
console.log('Discounts first 500 chars:', req.body.discounts?.substring(0, 500));
console.log('Vouchers type:', typeof req.body.vouchers);
console.log('Vouchers first 500 chars:', req.body.vouchers?.substring(0, 500));
console.log('========================');
const {
hero,
searchBar,
filterPanel,
programs,
holidays,
locations,
camps,
discounts,
vouchers,
formSteps,
validation: validationRaw
} = req.body;
// Parse JSON strings
const errors = [];
let updateData = {};
try {
console.log('Raw discounts from req.body:', typeof discounts, discounts);
console.log('Raw vouchers from req.body:', typeof vouchers, vouchers);
const parsedDiscounts = safeParse(discounts, 'discounts');
const parsedVouchers = safeParse(vouchers, 'vouchers');
console.log('Parsed discounts:', typeof parsedDiscounts, Array.isArray(parsedDiscounts), parsedDiscounts);
console.log('Parsed vouchers:', typeof parsedVouchers, Array.isArray(parsedVouchers), parsedVouchers);
updateData = {
hero: safeParse(hero, 'hero'),
searchBar: safeParse(searchBar, 'searchBar'),
filterPanel: safeParse(filterPanel, 'filterPanel'),
programs: safeParse(programs, 'programs'),
holidays: safeParse(holidays, 'holidays'),
locations: safeParse(locations, 'locations'),
camps: safeParse(camps, 'camps'),
formSteps: safeParse(formSteps, 'formSteps'),
validation: safeParse(validationRaw, 'validation'),
configuration: {
currency: 'USD',
discounts: parsedDiscounts,
vouchers: parsedVouchers
}
};
} catch (parseError) {
console.error('booking.update: Parse error', parseError);
req.flash("error_msg", `Data processing error: ${parseError.message}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
// Validate data structure
const validation = validateBookingData(updateData);
if (!validation.isValid) {
console.error('booking.update: Validation failed', validation.errors);
req.flash("error_msg", `Validation failed: ${validation.errors[0]}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
console.log('Final updateData keys:', Object.keys(updateData));
console.log('updateData.discounts:', updateData.discounts);
console.log('updateData.configuration:', updateData.configuration);
// CRITICAL: Remove any top-level discounts/vouchers to prevent schema conflicts
// These should ONLY exist in configuration object
delete updateData.discounts;
delete updateData.vouchers;
// Update or create booking document
let result;
try {
if (id && id !== 'undefined') {
result = await Booking.findByIdAndUpdate(
id,
{
...updateData,
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
},
{
new: true,
runValidators: false, // TẮT validator để tránh lỗi cast
strict: false // TẮT strict mode
}
);
if (!result) {
req.flash("error_msg", "Booking document not found");
return req.session.save(() => res.redirect("/admin/booking"));
}
} else {
// Upsert: update existing or create new
result = await Booking.findOneAndUpdate(
{},
{
...updateData,
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
},
{
upsert: true,
new: true,
runValidators: false, // TẮT validator
strict: false // TẮT strict mode
}
);
}
req.flash("success_msg", "Booking data updated successfully");
return req.session.save(() => res.redirect("/admin/booking"));
} catch (dbError) {
console.error("booking.update: Database error", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
} catch (err) {
console.error("booking.update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
};
// Booking selection mode: 'merge' (default) = static base, DB overrides;
// 'static' = use `data/booking.json` only; 'db' = use DB only.
const getFinalBooking = (staticBooking, dbBooking) => {
const mode = (process.env.BOOKING_MODE || 'merge').toLowerCase();
if (mode === 'static') return staticBooking || dbBooking || null;
if (mode === 'db') return dbBooking || staticBooking || null;
// default: merge static (base) with DB overrides
// If both static and db present, attempt to map DB primitive lists (e.g. ["915"]) to
// full objects from the static file (e.g. {id:"915", name:..., type:...}).
const mapDbPrimitivesToObjects = (db, stat) => {
if (!db || !stat) return db;
const dbCfg = db.configuration || {};
const statCfg = stat.configuration || {};
console.log('DB discounts/vouchers:', db.discounts, db.vouchers);
console.log('DB config:', dbCfg.discounts, dbCfg.vouchers);
console.log('Static config:', statCfg.discounts, statCfg.vouchers);
// Handle legacy: if top-level discounts/vouchers exist as strings, migrate to configuration
if (Array.isArray(db.discounts) && db.discounts.length > 0 && (!dbCfg.discounts || dbCfg.discounts.length === 0)) {
const statDiscountById = {};
if (statCfg.discounts) {
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
}
if (typeof db.discounts[0] === 'string') {
dbCfg.discounts = db.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
} else {
dbCfg.discounts = db.discounts;
}
}
if (Array.isArray(db.vouchers) && db.vouchers.length > 0 && (!dbCfg.vouchers || dbCfg.vouchers.length === 0)) {
const statVouchByCode = {};
if (statCfg.vouchers) {
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
}
if (typeof db.vouchers[0] === 'string') {
dbCfg.vouchers = db.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
} else {
dbCfg.vouchers = db.vouchers;
}
}
// If DB configuration still empty, use static data
if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length === 0 && statCfg.discounts && statCfg.discounts.length > 0) {
dbCfg.discounts = statCfg.discounts;
} else if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length > 0 && typeof dbCfg.discounts[0] === 'string') {
// Map string IDs to full objects from static
const statDiscountById = {};
if (statCfg.discounts) {
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
}
dbCfg.discounts = dbCfg.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
}
if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length === 0 && statCfg.vouchers && statCfg.vouchers.length > 0) {
dbCfg.vouchers = statCfg.vouchers;
} else if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length > 0 && typeof dbCfg.vouchers[0] === 'string') {
// Map string codes to full objects from static
const statVouchByCode = {};
if (statCfg.vouchers) {
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
}
dbCfg.vouchers = dbCfg.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
}
return Object.assign({}, db, { configuration: dbCfg });
};
const mappedDb = mapDbPrimitivesToObjects(dbBooking, staticBooking);
const merged = staticBooking ? deepMerge(staticBooking, mappedDb || {}) : (mappedDb || null);
// Clean up: remove top-level discounts/vouchers after migrating to configuration
if (merged) {
delete merged.discounts;
delete merged.vouchers;
}
return merged;
};

View File

@@ -1,558 +0,0 @@
const BookingSubmission = require('../models/bookingSubmission');
const Activity = require('../models/activity');
// API endpoint để tạo booking submission mới
exports.submitBooking = async (req, res) => {
try {
const {
activityId,
sessionId,
parentFirstName,
parentLastName,
email,
phone,
address,
city,
country,
postalCode,
participantFirstName,
participantLastName,
participantBirthDate,
participantGender,
numberOfParticipants,
medicalConditions,
dietaryRestrictions,
specialRequests,
emergencyContact,
emergencyPhone,
agreeTerms,
agreeNewsletter
} = req.body;
// Validate required fields
if (!activityId || !sessionId || !parentFirstName || !parentLastName ||
!email || !phone || !address || !city || !country || !postalCode ||
!participantFirstName || !participantLastName || !participantBirthDate ||
!participantGender || !emergencyContact || !emergencyPhone || !agreeTerms) {
return res.status(400).json({
error: 'Missing required fields',
message: 'Please fill in all required fields'
});
}
// Verify activity exists
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({
error: 'Activity not found',
message: 'The selected activity does not exist'
});
}
// Verify session exists and is active
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
if (!session) {
return res.status(404).json({
error: 'Session not found',
message: 'The selected session does not exist'
});
}
if (!session.isActive) {
return res.status(400).json({
error: 'Session not available',
message: 'The selected session is no longer available for booking'
});
}
// Check availability based on participant gender
const currentBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender,
status: { $in: ['pending', 'confirmed'] }
});
const availableSpots = participantGender === 'male'
? session.totalMaleSpots - session.bookedMaleSpots
: session.totalFemaleSpots - session.bookedFemaleSpots;
if (currentBookings >= availableSpots) {
return res.status(400).json({
error: 'Session full',
message: `No more spots available for ${participantGender} participants in this session`
});
}
// Calculate total amount based on activity price and number of participants
const totalAmount = (activity.price || 0) * (parseInt(numberOfParticipants) || 1);
// Create booking submission
const bookingSubmission = new BookingSubmission({
activityId,
sessionId,
parentFirstName: parentFirstName.trim(),
parentLastName: parentLastName.trim(),
email: email.toLowerCase().trim(),
phone: phone.trim(),
address: address.trim(),
city: city.trim(),
country: country.trim(),
postalCode: postalCode.trim(),
participantFirstName: participantFirstName.trim(),
participantLastName: participantLastName.trim(),
participantBirthDate: new Date(participantBirthDate),
participantGender,
numberOfParticipants: parseInt(numberOfParticipants) || 1,
medicalConditions: (medicalConditions || '').trim(),
dietaryRestrictions: dietaryRestrictions || 'none',
specialRequests: (specialRequests || '').trim(),
emergencyContact: emergencyContact.trim(),
emergencyPhone: emergencyPhone.trim(),
agreeTerms: Boolean(agreeTerms),
agreeNewsletter: Boolean(agreeNewsletter),
totalAmount,
status: 'pending',
paymentStatus: 'pending'
});
await bookingSubmission.save();
// Update session booked spots
const updateField = participantGender === 'male' ? 'bookingSessions.$.bookedMaleSpots' : 'bookingSessions.$.bookedFemaleSpots';
await Activity.updateOne(
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
{ $inc: { [updateField]: 1 } }
);
// Populate activity info for response
await bookingSubmission.populate('activityId', 'name price');
return res.status(201).json({
success: true,
message: 'Booking submitted successfully',
booking: {
id: bookingSubmission._id,
activityName: bookingSubmission.activityId.name,
sessionId: bookingSubmission.sessionId,
participantName: `${bookingSubmission.participantFirstName} ${bookingSubmission.participantLastName}`,
totalAmount: bookingSubmission.totalAmount,
status: bookingSubmission.status
}
});
} catch (error) {
console.error('submitBooking error:', error);
// Handle validation errors
if (error.name === 'ValidationError') {
const validationErrors = Object.values(error.errors).map(err => err.message);
return res.status(400).json({
error: 'Validation failed',
message: validationErrors.join(', ')
});
}
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while processing your booking. Please try again.'
});
}
};
// API endpoint để lấy thông tin session availability
exports.getSessionAvailability = async (req, res) => {
try {
const { activityId, sessionId } = req.params;
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({ error: 'Activity not found' });
}
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Get current booking counts
const maleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender: 'male',
status: { $in: ['pending', 'confirmed'] }
});
const femaleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender: 'female',
status: { $in: ['pending', 'confirmed'] }
});
return res.json({
sessionId,
isActive: session.isActive,
startDate: session.startDate,
endDate: session.endDate,
overnightStays: session.overnightStays,
price: session.price || activity.price,
availability: {
male: {
total: session.totalMaleSpots,
booked: maleBookings,
available: Math.max(0, session.totalMaleSpots - maleBookings)
},
female: {
total: session.totalFemaleSpots,
booked: femaleBookings,
available: Math.max(0, session.totalFemaleSpots - femaleBookings)
}
}
});
} catch (error) {
console.error('getSessionAvailability error:', error);
return res.status(500).json({ error: 'Error loading session availability' });
}
};
// API endpoint để lấy tất cả sessions có sẵn cho một activity
exports.getAvailableSessions = async (req, res) => {
try {
const { activityId } = req.params;
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({ error: 'Activity not found' });
}
const sessions = activity.bookingSessions || [];
const availableSessions = [];
for (const session of sessions) {
if (!session.isActive) continue;
// Get current booking counts
const maleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId: session.sessionId,
participantGender: 'male',
status: { $in: ['pending', 'confirmed'] }
});
const femaleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId: session.sessionId,
participantGender: 'female',
status: { $in: ['pending', 'confirmed'] }
});
const maleAvailable = Math.max(0, session.totalMaleSpots - maleBookings);
const femaleAvailable = Math.max(0, session.totalFemaleSpots - femaleBookings);
// Only include sessions that have available spots
if (maleAvailable > 0 || femaleAvailable > 0) {
availableSessions.push({
sessionId: session.sessionId,
startDate: session.startDate,
endDate: session.endDate,
overnightStays: session.overnightStays,
price: session.price || activity.price,
availability: {
male: {
total: session.totalMaleSpots,
booked: maleBookings,
available: maleAvailable
},
female: {
total: session.totalFemaleSpots,
booked: femaleBookings,
available: femaleAvailable
}
}
});
}
}
return res.json({
activityId,
activityName: activity.name,
sessions: availableSessions
});
} catch (error) {
console.error('getAvailableSessions error:', error);
return res.status(500).json({ error: 'Error loading available sessions' });
}
};
// API endpoint để cập nhật booking submission
exports.updateBookingSubmission = async (req, res) => {
try {
const { bookingId } = req.params;
const updateData = req.body;
// Find the booking
let booking = await BookingSubmission.findById(bookingId);
// If not found as a separate document, try to find it as an embedded booking in Activity.bookingSessions
let activityContaining = null;
let sessionIndex = -1;
let bookingIndex = -1;
if (!booking) {
activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
if (!activityContaining) {
return res.status(404).json({
error: 'Booking not found',
message: 'The booking submission does not exist'
});
}
// locate the exact session and booking positions
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
const bl = activityContaining.bookingSessions[si].bookingList || [];
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
if (bi !== -1) {
sessionIndex = si;
bookingIndex = bi;
break;
}
}
if (sessionIndex === -1 || bookingIndex === -1) {
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
}
booking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
}
// Define allowed fields to update
const allowedUpdates = [
'status',
'paymentStatus',
'paidAmount',
'totalAmount',
'adminNotes',
'emergencyContact',
'emergencyPhone',
'medicalConditions',
'dietaryRestrictions',
'specialRequests'
];
// Build update object with only allowed fields
const updateFields = {};
for (const field of allowedUpdates) {
if (updateData[field] !== undefined) {
updateFields[field] = updateData[field];
}
}
if (Object.keys(updateFields).length === 0) {
return res.status(400).json({
error: 'No valid fields to update',
message: 'Please provide at least one valid field to update'
});
}
// If booking is a separate document, update the BookingSubmission collection
if (activityContaining === null) {
const updatedBooking = await BookingSubmission.findByIdAndUpdate(
bookingId,
updateFields,
{ new: true, runValidators: true }
).populate('activityId', 'name price');
return res.json({
success: true,
message: 'Booking updated successfully',
booking: updatedBooking
});
}
// Otherwise update the embedded booking in the Activity document
const currentBooking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
// Handle status updates and spot adjustments
const newStatus = updateData.status || updateData.bookingStatus;
const currentStatus = currentBooking.status || currentBooking.bookingStatus;
// Apply allowed updates to the embedded booking
const allowedEmbeddedUpdates = [
'status', 'bookingStatus', 'paymentStatus', 'paidAmount', 'totalAmount', 'adminNotes',
'emergencyContact', 'emergencyPhone', 'medicalConditions', 'dietaryRestrictions', 'specialRequests'
];
for (const field of allowedEmbeddedUpdates) {
if (updateData[field] !== undefined) {
if (field === 'status') {
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].status = updateData.status;
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].bookingStatus = updateData.status;
} else {
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex][field] = updateData[field];
}
}
}
// If status change affects spots, adjust counts
if (newStatus && newStatus !== currentStatus) {
const numberOfParticipants = currentBooking.numberOfParticipants || 1;
const participantGender = currentBooking.participantGender;
// If booking is being cancelled, free up spots
if (newStatus === 'cancelled' && currentStatus !== 'cancelled') {
if (participantGender === 'male') {
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
} else if (participantGender === 'female') {
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
}
}
// If restoring from cancelled, ensure capacity then book spots
if (currentStatus === 'cancelled' && newStatus !== 'cancelled') {
if (participantGender === 'male') {
const totalMale = activityContaining.bookingSessions[sessionIndex].totalMaleSpots;
const currentMale = activityContaining.bookingSessions[sessionIndex].bookedMaleSpots;
if (currentMale + numberOfParticipants > totalMale) {
return res.status(400).json({ error: "Not enough male spots available to restore this booking" });
}
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
} else if (participantGender === 'female') {
const totalFemale = activityContaining.bookingSessions[sessionIndex].totalFemaleSpots;
const currentFemale = activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots;
if (currentFemale + numberOfParticipants > totalFemale) {
return res.status(400).json({ error: "Not enough female spots available to restore this booking" });
}
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
}
}
}
await activityContaining.save();
return res.json({
success: true,
message: 'Embedded booking updated successfully',
booking: activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex]
});
} catch (error) {
console.error('updateBookingSubmission error:', error);
// Handle validation errors
if (error.name === 'ValidationError') {
const validationErrors = Object.values(error.errors).map(err => err.message);
return res.status(400).json({
error: 'Validation failed',
message: validationErrors.join(', ')
});
}
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while updating the booking'
});
}
};
// API endpoint để xóa booking submission
exports.deleteBookingSubmission = async (req, res) => {
try {
const { bookingId } = req.params;
// Find and delete the booking
let booking = await BookingSubmission.findById(bookingId);
// If not found in separate collection, try to delete embedded booking in Activity
if (!booking) {
const activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
if (!activityContaining) {
return res.status(404).json({
error: 'Booking not found',
message: 'The booking submission does not exist'
});
}
// locate session and booking
let sessionIndex = -1;
let bookingIndex = -1;
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
const bl = activityContaining.bookingSessions[si].bookingList || [];
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
if (bi !== -1) {
sessionIndex = si;
bookingIndex = bi;
break;
}
}
if (sessionIndex === -1 || bookingIndex === -1) {
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
}
const bookingToDelete = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
// Free up spots if booking is not cancelled
if ((bookingToDelete.bookingStatus || bookingToDelete.status) !== 'cancelled') {
const numberOfParticipants = bookingToDelete.numberOfParticipants || 1;
const participantGender = bookingToDelete.participantGender;
if (participantGender === 'male') {
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
} else if (participantGender === 'female') {
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
}
}
// Remove booking and save
activityContaining.bookingSessions[sessionIndex].bookingList.splice(bookingIndex, 1);
await activityContaining.save();
return res.json({
success: true,
message: 'Embedded booking deleted successfully',
booking: {
id: bookingId,
participantName: `${bookingToDelete.participantFirstName} ${bookingToDelete.participantLastName}`,
email: bookingToDelete.email
}
});
}
// Store info for session spot adjustment
const { activityId, sessionId, participantGender, numberOfParticipants } = booking;
// Delete the booking
await BookingSubmission.findByIdAndDelete(bookingId);
// Update session booked spots (decrease the count)
if (booking.status !== 'cancelled') {
const updateField = participantGender === 'male'
? 'bookingSessions.$.bookedMaleSpots'
: 'bookingSessions.$.bookedFemaleSpots';
await Activity.updateOne(
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
{ $inc: { [updateField]: -numberOfParticipants } }
);
}
return res.json({
success: true,
message: 'Booking deleted successfully',
booking: {
id: bookingId,
participantName: `${booking.participantFirstName} ${booking.participantLastName}`,
email: booking.email
}
});
} catch (error) {
console.error('deleteBookingSubmission error:', error);
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while deleting the booking'
});
}
};

View File

@@ -0,0 +1,161 @@
const path = require('path');
const Certificate = require('../models/certificate');
const Department = require('../models/department');
const Level = require('../models/level');
const writeAuditLog = require('../audit/writeAuditLog');
const AUDIT_ACTIONS = require('../constants/auditAction');
function normalizePath(filePath) {
if (!filePath) return undefined;
return path.basename(filePath.replace(/\\/g, '/'));
}
// GET /admin/certificate
exports.index = async (req, res) => {
try {
const { search, status } = req.query;
const filter = {};
if (search) {
filter.$or = [
{ certification_number: { $regex: search, $options: 'i' } },
{ student_name: { $regex: search, $options: 'i' } }
];
}
if (status) filter.status = status;
const [certificates, departments, levels] = await Promise.all([
Certificate.find(filter).populate('department level').sort({ createdAt: -1 }),
Department.find(), Level.find()
]);
res.render('admin/certificate/index', {
certificates, departments, levels, query: req.query,
user: req.session.user, layout: 'layouts/admin', title: 'Certificates'
});
} catch (err) {
console.error(err);
req.flash('error', 'Error loading certificates');
res.redirect('/admin/dashboard');
}
};
// GET /admin/certificate/create
exports.createForm = async (req, res) => {
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/certificate/create', {
departments, levels, user: req.session.user,
layout: 'layouts/admin', title: 'Create Certificate'
});
} catch (err) {
req.flash('error', 'Error'); res.redirect('/admin/certificate');
}
};
// POST /admin/certificate/create
exports.create = async (req, res) => {
try {
const data = { ...req.body };
const imgPath = req.files?.certificate_image?.[0]?.path;
if (imgPath) data.certificate_image = normalizePath(imgPath);
const cert = new Certificate(data);
await cert.save();
await writeAuditLog({ model: 'Certificate', documentId: cert._id, action: AUDIT_ACTIONS.CREATE_CERTIFICATE, before: null, after: cert.toObject(), req });
req.flash('success', 'Certificate created');
res.redirect('/admin/certificate');
} catch (err) {
console.error(err);
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/certificate/create', {
error: err.message, formData: req.body, departments, levels,
user: req.session.user, layout: 'layouts/admin', title: 'Create Certificate'
});
} catch { req.flash('error', err.message); res.redirect('/admin/certificate'); }
}
};
// GET /admin/certificate/:id/edit
exports.editForm = async (req, res) => {
try {
const cert = await Certificate.findById(req.params.id).populate('department level');
if (!cert) { req.flash('error', 'Not found'); return res.redirect('/admin/certificate'); }
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/certificate/edit', {
cert, departments, levels, user: req.session.user,
layout: 'layouts/admin', title: 'Edit Certificate'
});
} catch (err) {
req.flash('error', 'Error'); res.redirect('/admin/certificate');
}
};
// POST /admin/certificate/:id/edit
exports.update = async (req, res) => {
try {
const cert = await Certificate.findById(req.params.id);
if (!cert) { req.flash('error', 'Not found'); return res.redirect('/admin/certificate'); }
const before = cert.toObject();
const fields = ['certification_number','student_name','program_name','department','level',
'issued_date','status','passport_number','address'];
fields.forEach(f => { if (req.body[f] !== undefined) cert[f] = req.body[f]; });
const imgPath = req.files?.certificate_image?.[0]?.path;
if (imgPath) cert.certificate_image = normalizePath(imgPath);
await cert.save();
await writeAuditLog({ model: 'Certificate', documentId: cert._id, action: AUDIT_ACTIONS.UPDATE_CERTIFICATE, before, after: cert.toObject(), req });
req.flash('success', 'Certificate updated');
res.redirect('/admin/certificate');
} catch (err) {
req.flash('error', err.message); res.redirect('back');
}
};
// POST /admin/certificate/:id/delete
exports.destroy = async (req, res) => {
try {
const cert = await Certificate.findById(req.params.id);
if (!cert) { req.flash('error', 'Not found'); return res.redirect('/admin/certificate'); }
await writeAuditLog({ model: 'Certificate', documentId: cert._id, action: AUDIT_ACTIONS.DELETE_CERTIFICATE, before: cert.toObject(), after: null, req });
await cert.deleteOne();
req.flash('success', 'Certificate deleted');
res.redirect('/admin/certificate');
} catch (err) {
req.flash('error', 'Error deleting'); res.redirect('/admin/certificate');
}
};
// GET /api/verify-certificate/:cert_id?api_key=xxx
exports.apiVerify = async (req, res) => {
try {
const cert = await Certificate.findOne({
certification_number: { $regex: new RegExp('^' + req.params.cert_id + '$', 'i') }
}).populate('department level');
if (!cert) return res.status(404).json({ error: 'Certificate not found' });
if (cert.status === 'revoked') return res.status(404).json({ error: 'Certificate has been revoked' });
const baseUrl = `${req.protocol}://${req.get('host')}`;
const buildUrl = (f) => f ? [`${baseUrl}/secure-files/${path.basename(f)}?api_key=${req.query.api_key}`] : undefined;
const response = {
full_name: cert.student_name,
certification_title: cert.program_name,
certificate_id: cert.certification_number,
};
if (cert.passport_number) response.passport_number = cert.passport_number;
if (cert.address) response.address = cert.address;
const imgs = buildUrl(cert.certificate_image);
if (imgs) response.certificate_image = imgs;
return res.json(response);
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error' });
}
};

View File

@@ -1,388 +0,0 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Contact = require("../models/contact");
const ContactSubmission = require("../models/contactSubmission");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Get contact data from MongoDB
const getContactData = async () => {
const contact = await Contact.findOne({ name: "default" });
if (!contact) {
return null;
}
return contact.toObject();
};
// API to get contact data
exports.api = async (req, res) => {
try {
const contact = await getContactData();
if (!contact) {
return res.status(404).json({ error: "Contact data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(contact, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading contact data" });
}
};
// API để lấy toàn bộ contact data
exports.getContactData = async (req, res) => {
try {
const contactData = await getContactData();
if (!contactData) {
return res.status(404).json({ error: "Contact data not found" });
}
res.json(contactData);
} catch (error) {
console.error("Error getting contact data:", error);
res.status(500).json({ error: "Error loading contact data" });
}
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = (await getContactData()) || {
hero: {
title: "Contact Us",
backgroundImage: "",
overlayColor: "rgba(0, 0, 0, 0)",
sectionClass: "",
titleClass: "",
enableScrollspy: false,
backgroundPosition: "center",
},
contactCards: [],
map: {
coordinates: { lat: 0, lng: 0 },
zoom: 15,
location: "",
markerTitle: "",
embedUrl: "",
tileLayer: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "",
maxZoom: 18,
minZoom: 0,
},
},
form: {
sectionLabel: "",
heading: "",
description: "",
fields: [],
submitButton: {
text: "Send Message",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn style-2",
},
},
};
const { startDate, endDate } = req.query;
const query = {};
if (startDate || endDate) {
query.createdAt = {};
if (startDate) {
query.createdAt.$gte = new Date(startDate);
}
if (endDate) {
// Set end date to end of day
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
query.createdAt.$lte = end;
}
}
const submissions = await ContactSubmission.find(query)
.sort({ createdAt: -1 })
.limit(50);
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/contact/index", {
title: "Contact Management",
layout: "layouts/main",
data,
submissions,
startDate,
endDate,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in contact index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu contact
exports.update = async (req, res) => {
try {
const { hero, contactCards, map, form } = req.body;
// Parse JSON strings nếu cần
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
const heroData = parseJson(hero);
const contactCardsData = parseJson(contactCards);
const mapData = parseJson(map);
const formData = parseJson(form);
// Tìm hoặc tạo contact
let contact = await Contact.findOne({ name: "default" });
// ✅ Capture BEFORE state
const beforeData = contact
? JSON.parse(JSON.stringify(contact.toObject()))
: {};
if (!contact) {
// Tạo mới với default values
contact = new Contact({
name: "default",
hero: heroData || {
title: "Contact Us",
backgroundImage: "",
overlayColor: "rgba(0, 0, 0, 0)",
sectionClass: "",
titleClass: "",
enableScrollspy: false,
backgroundPosition: "center",
},
contactCards: (contactCardsData || []).map((card) => ({
...card,
iconType: card.iconType || "",
iconSource:
card.iconSource ||
(card.iconType && card.iconType.startsWith("/uploads/")
? "image"
: "fontawesome"),
})),
map: mapData || {
coordinates: { lat: 0, lng: 0 },
zoom: 15,
location: "",
markerTitle: "",
embedUrl: "",
tileLayer: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "",
maxZoom: 18,
minZoom: 0,
},
},
form: formData || {
sectionLabel: "",
heading: "",
description: "",
fields: [],
submitButton: {
text: "Send Message",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn style-2",
},
},
});
} else {
// Cập nhật dữ liệu
if (heroData) contact.hero = heroData;
if (contactCardsData && Array.isArray(contactCardsData)) {
// Đảm bảo mỗi card có iconType và iconSource
contact.contactCards = contactCardsData.map((card) => ({
...card,
iconType: card.iconType || "",
iconSource:
card.iconSource ||
(card.iconType && card.iconType.startsWith("/uploads/")
? "image"
: "fontawesome"),
}));
}
if (mapData) contact.map = mapData;
if (formData) contact.form = formData;
}
await contact.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(contact.toObject()));
// ✅ AUDIT LOGGING - Contact Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Contact",
documentId: contact._id,
action: AUDIT_ACTIONS.UPDATE_CONTACT,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Contact updated successfully");
res.redirect("/admin/contact");
} catch (err) {
console.error("Error updating contact:", err);
req.flash("error_msg", err.message || "Error updating contact");
res.redirect("/admin/contact");
}
};
// API để submit contact form (từ frontend)
exports.submitForm = async (req, res) => {
try {
const { name, email, phone, address, date, message } = req.body;
// Validation
if (!name || !email) {
return res.status(400).json({
success: false,
error: "Name and email are required",
});
}
// Create new submission
const submission = new ContactSubmission({
name: name.trim(),
email: email.trim().toLowerCase(),
phone: phone?.trim() || "",
address: address?.trim() || "",
date: date?.trim() || "",
message: message?.trim() || "",
ipAddress: req.ip || req.connection?.remoteAddress || "",
userAgent: req.get("User-Agent") || "",
});
await submission.save();
res.status(201).json({
success: true,
message: "Thank you for contacting us! We will get back to you soon.",
data: {
id: submission._id,
name: submission.name,
email: submission.email,
},
});
} catch (err) {
console.error("Error submitting contact form:", err);
// Handle validation errors
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({
success: false,
error: errors.join(", "),
});
}
res.status(500).json({
success: false,
error: "Error submitting form. Please try again later.",
});
}
};
// API để lấy danh sách submissions (cho admin)
exports.getSubmissions = async (req, res) => {
try {
const { status, page = 1, limit = 20 } = req.query;
const query = {};
if (status && ["pending", "read", "replied", "archived"].includes(status)) {
query.status = status;
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [submissions, total] = await Promise.all([
ContactSubmission.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
ContactSubmission.countDocuments(query),
]);
res.json({
success: true,
data: submissions,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / parseInt(limit)),
},
});
} catch (err) {
console.error("Error getting submissions:", err);
res.status(500).json({
success: false,
error: "Error loading submissions",
});
}
};
// API để cập nhật status của submission
exports.updateSubmissionStatus = async (req, res) => {
try {
const { id } = req.params;
const { status, notes } = req.body;
const validStatuses = ["pending", "read", "replied", "archived"];
if (!validStatuses.includes(status)) {
return res.status(400).json({
success: false,
error: "Invalid status",
});
}
const updateData = { status };
if (notes !== undefined) updateData.notes = notes;
if (status === "replied") updateData.repliedAt = new Date();
const submission = await ContactSubmission.findByIdAndUpdate(
id,
updateData,
{ new: true },
);
if (!submission) {
return res.status(404).json({
success: false,
error: "Submission not found",
});
}
res.json({
success: true,
data: submission,
});
} catch (err) {
console.error("Error updating submission:", err);
res.status(500).json({
success: false,
error: "Error updating submission",
});
}
};

View File

@@ -1,17 +1,54 @@
const { readJsonFile } = require('../utils/jsonHelper');
const Qualification = require('../models/qualification');
const Certificate = require('../models/certificate');
// Hiển thị dashboard
exports.getDashboard = async (req, res) => {
try {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [
qualificationCount,
certificationCount,
activeQual,
revokedQual,
activeCert,
revokedCert,
recentQual,
recentCert,
recentQualifications,
recentCertificates
] = await Promise.all([
Qualification.countDocuments(),
Certificate.countDocuments(),
Qualification.countDocuments({ status: 'active' }),
Qualification.countDocuments({ status: 'revoked' }),
Certificate.countDocuments({ status: 'active' }),
Certificate.countDocuments({ status: 'revoked' }),
Qualification.countDocuments({ createdAt: { $gte: thirtyDaysAgo } }),
Certificate.countDocuments({ createdAt: { $gte: thirtyDaysAgo } }),
Qualification.find().sort({ createdAt: -1 }).limit(5).populate('department level'),
Certificate.find().sort({ createdAt: -1 }).limit(5).populate('department level')
]);
res.render('admin/dashboard', {
title: 'Dashboard',
user: req.session.user
qualificationCount,
certificationCount,
total: qualificationCount + certificationCount,
activeCount: activeQual + activeCert,
revokedCount: revokedQual + revokedCert,
recentCount: recentQual + recentCert,
recentQualifications,
recentCertificates,
user: req.session.user,
layout: 'layouts/admin',
title: 'Dashboard'
});
} catch (err) {
console.error(err);
res.render('admin/dashboard', {
title: 'Dashboard',
user: req.session.user
qualificationCount: 0, certificationCount: 0,
total: 0, activeCount: 0, revokedCount: 0, recentCount: 0,
recentQualifications: [], recentCertificates: [],
user: req.session.user, layout: 'layouts/admin', title: 'Dashboard'
});
}
};

View File

@@ -0,0 +1,261 @@
const path = require('path');
const Degree = require('../models/degree');
const Department = require('../models/department');
const Level = require('../models/level');
const writeAuditLog = require('../audit/writeAuditLog');
const AUDIT_ACTIONS = require('../constants/auditAction');
// Helper: store only filename, served via /secure-files/ route
function normalizePath(filePath) {
if (!filePath) return undefined;
return path.basename(filePath.replace(/\\/g, '/'));
}
// GET /admin/degree
exports.index = async (req, res) => {
try {
const { search, type, department, level, status } = req.query;
const filter = {};
if (search) {
filter.$or = [
{ qualification_number: { $regex: search, $options: 'i' } },
{ certification_number: { $regex: search, $options: 'i' } },
{ student_name: { $regex: search, $options: 'i' } }
];
}
if (type) filter.type = type;
if (department) filter.department = department;
if (level) filter.level = level;
if (status) filter.status = status;
const [degrees, departments, levels] = await Promise.all([
Degree.find(filter).populate('department level').sort({ createdAt: -1 }),
Department.find(),
Level.find()
]);
res.render('admin/degree/index', {
degrees, departments, levels,
query: req.query,
user: req.session.user,
layout: 'layouts/admin',
title: 'Quản lý Văn bằng'
});
} catch (err) {
console.error('degreeController.index error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tải danh sách văn bằng');
res.redirect('/admin/dashboard');
}
};
// GET /admin/degree/create
exports.createForm = async (req, res) => {
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/degree/create', {
departments, levels,
user: req.session.user,
layout: 'layouts/admin',
title: 'Tạo Văn bằng mới'
});
} catch (err) {
console.error('degreeController.createForm error:', err);
req.flash('error', 'Đã xảy ra lỗi');
res.redirect('/admin/degree');
}
};
// POST /admin/degree/create
exports.create = async (req, res) => {
try {
const degreeData = { ...req.body };
const degreeImagePath = req.files?.degree_image?.[0]?.path;
const certificateImagePath = req.files?.certificate_image?.[0]?.path;
if (degreeImagePath) degreeData.degree_image = normalizePath(degreeImagePath);
if (certificateImagePath) degreeData.certificate_image = normalizePath(certificateImagePath);
const degree = new Degree(degreeData);
await degree.save();
await writeAuditLog({
model: 'Degree', documentId: degree._id,
action: AUDIT_ACTIONS.CREATE_DEGREE,
before: null, after: degree.toObject(), req
});
req.flash('success', 'Degree created');
res.redirect('/admin/degree');
} catch (err) {
console.error('degreeController.create error:', err);
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/degree/create', {
error: err.message, formData: req.body,
departments, levels,
user: req.session.user,
layout: 'layouts/admin',
title: 'Tạo Văn bằng mới'
});
} catch (renderErr) {
req.flash('error', err.message);
res.redirect('/admin/degree');
}
}
};
// GET /admin/degree/:id/edit
exports.editForm = async (req, res) => {
try {
const degree = await Degree.findById(req.params.id).populate('department level');
if (!degree) {
req.flash('error', 'Degree not found');
return res.redirect('/admin/degree');
}
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/degree/edit', {
degree, departments, levels,
user: req.session.user,
layout: 'layouts/admin',
title: 'Chỉnh sửa Văn bằng'
});
} catch (err) {
console.error('degreeController.editForm error:', err);
req.flash('error', 'Đã xảy ra lỗi');
res.redirect('/admin/degree');
}
};
// POST /admin/degree/:id/edit
exports.update = async (req, res) => {
try {
const degree = await Degree.findById(req.params.id);
if (!degree) {
req.flash('error', 'Degree not found');
return res.redirect('/admin/degree');
}
const beforeData = degree.toObject();
const fields = [
'qualification_number', 'certification_number', 'student_name', 'program_name',
'type', 'department', 'level', 'issued_date', 'status',
'passport_number', 'address', 'topic_name', 'topic_short_desc'
];
fields.forEach(field => { if (req.body[field] !== undefined) degree[field] = req.body[field]; });
const degreeImagePath = req.files?.degree_image?.[0]?.path;
const certificateImagePath = req.files?.certificate_image?.[0]?.path;
if (degreeImagePath) degree.degree_image = normalizePath(degreeImagePath);
if (certificateImagePath) degree.certificate_image = normalizePath(certificateImagePath);
await degree.save();
await writeAuditLog({
model: 'Degree', documentId: degree._id,
action: AUDIT_ACTIONS.UPDATE_DEGREE,
before: beforeData, after: degree.toObject(), req
});
req.flash('success', 'Degree updated');
res.redirect('/admin/degree');
} catch (err) {
console.error('degreeController.update error:', err);
req.flash('error', err.message);
res.redirect('back');
}
};
// POST /admin/degree/:id/delete
exports.destroy = async (req, res) => {
try {
const degree = await Degree.findById(req.params.id);
if (!degree) {
req.flash('error', 'Degree not found');
return res.redirect('/admin/degree');
}
await writeAuditLog({
model: 'Degree', documentId: degree._id,
action: AUDIT_ACTIONS.DELETE_DEGREE,
before: degree.toObject(), after: null, req
});
await degree.deleteOne();
req.flash('success', 'Degree deleted');
res.redirect('/admin/degree');
} catch (err) {
console.error('degreeController.destroy error:', err);
req.flash('error', 'Đã xảy ra lỗi khi xóa văn bằng');
res.redirect('/admin/degree');
}
};
// ─── Public API ───────────────────────────────────────────────────────────────
function buildSecureUrl(req, filename) {
if (!filename) return undefined;
const baseUrl = `${req.protocol}://${req.get('host')}`;
const name = path.basename(filename);
return `${baseUrl}/secure-files/${name}?api_key=${req.query.api_key}`;
}
// GET /api/verify-degree/:degree_id?api_key=xxx
// Lookup by qualification_number — returns degree fields + topic_name if PhD
exports.apiGetByQualification = async (req, res) => {
try {
const degree = await Degree.findOne({
qualification_number: { $regex: new RegExp('^' + req.params.degree_id + '$', 'i') }
}).populate('department level');
if (!degree) return res.status(404).json({ error: 'Degree not found' });
if (degree.status === 'revoked') return res.status(404).json({ error: 'Degree has been revoked' });
const imageUrl = buildSecureUrl(req, degree.degree_image);
const response = {
full_name: degree.student_name,
program_name: degree.program_name,
degree_id: degree.qualification_number,
};
if (degree.passport_number) response.passport_number = degree.passport_number;
if (degree.address) response.address = degree.address;
if (imageUrl) response.degree_image = [imageUrl];
// topic_name present → PhD view; absent → MBA/Master view
if (degree.topic_name) {
response.topic_name = degree.topic_name;
if (degree.topic_short_desc) response.topic_short_desc = degree.topic_short_desc;
}
return res.json(response);
} catch (err) {
console.error('apiGetByQualification error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
};
// GET /api/verify-certificate/:cert_id?api_key=xxx
// Lookup by certification_number — returns certificate fields (no topic_name)
exports.apiGetByCertification = async (req, res) => {
try {
const degree = await Degree.findOne({
certification_number: { $regex: new RegExp('^' + req.params.cert_id + '$', 'i') }
}).populate('department level');
if (!degree) return res.status(404).json({ error: 'Certificate not found' });
if (degree.status === 'revoked') return res.status(404).json({ error: 'Certificate has been revoked' });
const imageUrl = buildSecureUrl(req, degree.certificate_image);
const response = {
full_name: degree.student_name,
certification_title: degree.program_name,
certificate_id: degree.certification_number,
};
if (degree.passport_number) response.passport_number = degree.passport_number;
if (degree.address) response.address = degree.address;
if (imageUrl) response.certificate_image = [imageUrl];
return res.json(response);
} catch (err) {
console.error('apiGetByCertification error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
};

View File

@@ -0,0 +1,79 @@
const Department = require('../models/department');
const Degree = require('../models/degree');
// GET /admin/department
exports.index = async (req, res) => {
try {
const departments = await Department.find();
res.render('admin/department/index', {
departments,
user: req.session.user,
layout: 'layouts/admin',
title: 'Quản lý Khoa/Bộ môn'
});
} catch (err) {
console.error('departmentController.index error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tải danh sách khoa/bộ môn');
res.redirect('/admin/dashboard');
}
};
// POST /admin/department/create
exports.create = async (req, res) => {
try {
const { name } = req.body;
const slug = name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
const existing = await Department.findOne({ slug });
if (existing) {
req.flash('error', 'Department already exists');
return res.redirect('back');
}
await Department.create({ name, slug });
req.flash('success', 'Department created');
res.redirect('/admin/department');
} catch (err) {
console.error('departmentController.create error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tạo khoa/bộ môn');
res.redirect('back');
}
};
// POST /admin/department/:id/edit
exports.update = async (req, res) => {
try {
const { id } = req.params;
const { name } = req.body;
const slug = name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
await Department.findByIdAndUpdate(id, { name, slug });
req.flash('success', 'Department updated');
res.redirect('/admin/department');
} catch (err) {
console.error('departmentController.update error:', err);
req.flash('error', 'Đã xảy ra lỗi khi cập nhật khoa/bộ môn');
res.redirect('back');
}
};
// POST /admin/department/:id/delete
exports.destroy = async (req, res) => {
try {
const { id } = req.params;
const count = await Degree.countDocuments({ department: id });
if (count > 0) {
req.flash('error', 'Cannot delete: Department is referenced by existing degrees');
return res.redirect('back');
}
await Department.findByIdAndDelete(id);
req.flash('success', 'Department deleted');
res.redirect('/admin/department');
} catch (err) {
console.error('departmentController.destroy error:', err);
req.flash('error', 'Đã xảy ra lỗi khi xóa khoa/bộ môn');
res.redirect('back');
}
};

View File

@@ -1,154 +0,0 @@
const Home = require("../models/home");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Helper to get FAQ data from Home model
const getFaqData = async () => {
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.faq) {
return {
heading: "",
subheading: "",
description: "",
items: [],
ctaButton: { label: "", href: "" },
};
}
return home.faq.toObject ? home.faq.toObject() : home.faq;
};
// API to get FAQ data for frontend
exports.api = async (req, res) => {
try {
const faqData = await getFaqData();
return res.json(faqData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading FAQ data" });
}
};
// Method for legacy route compatibility or internal use
exports.getFAQData = async (req, res) => {
return exports.api(req, res);
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = await getFaqData();
// Ensure default structure if data is partial
const safeData = {
heading: data.heading || "",
subheading: data.subheading || "",
description: data.description || "",
ctaButton: data.ctaButton || { label: "", href: "" },
items: data.items || [],
};
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/home/faq/index", {
title: "FAQ Section Management",
layout: "layouts/main",
data: safeData,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in FAQ index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Update FAQ data
exports.update = async (req, res) => {
try {
const { heading, subheading, description, ctaLabel, ctaHref, items } =
req.body;
let parsedItems = [];
if (items) {
try {
parsedItems = typeof items === "string" ? JSON.parse(items) : items;
} catch (e) {
console.error("Error parsing items JSON:", e);
parsedItems = [];
}
}
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
}
// ✅ Capture BEFORE state
const beforeData = home.faq
? JSON.parse(
JSON.stringify(home.faq.toObject ? home.faq.toObject() : home.faq),
)
: {};
const updatedFaqData = {
heading: heading || "",
subheading: subheading || "",
description: description || "",
ctaButton: {
label: ctaLabel || "",
href: ctaHref || "",
},
items: parsedItems.map((item) => ({
question: item.question || "",
answer: item.answer || "",
})),
};
home.faq = updatedFaqData;
await home.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedFaqData));
// ✅ AUDIT LOGGING - FAQ Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: home._id,
action: AUDIT_ACTIONS.UPDATE_FAQ,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "FAQ section updated successfully");
res.redirect("/admin/home/faq");
} catch (err) {
console.error("Error updating FAQ:", err);
req.flash("error_msg", err.message || "Error updating FAQ");
res.redirect("/admin/home/faq");
}
};
// Placeholder methods to prevent route crashes if routes are not cleaned up immediately
exports.addFAQ = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQItem = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQItem = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.addFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.reorderFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateSidebarNav = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });

View File

@@ -1,169 +0,0 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Footer = require("../models/footer");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// GET /api/footer - Public API cho website và CMS load dữ liệu
exports.getFooter = async (req, res) => {
try {
const footer = await Footer.getSingle();
const processedData = addBaseUrlToImages(footer.toObject());
res.json(processedData);
} catch (error) {
console.error("Error getting footer:", error);
res.status(500).json({
error: "Failed to get footer data",
});
}
};
// PUT /api/admin/footer - Update toàn bộ footer cho CMS
exports.updateFooter = async (req, res) => {
try {
let updateData = req.body;
console.log("=== FOOTER UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
// Nếu có footerJson, parse nó (tương tự Header logic)
if (updateData.footerJson && typeof updateData.footerJson === "string") {
try {
const parsedData = JSON.parse(updateData.footerJson);
console.log("✓ Parsed footerJson successfully:", parsedData);
updateData = parsedData;
} catch (e) {
console.error("✗ Error parsing footerJson:", e.message);
return res.status(400).json({
success: false,
message: "Invalid JSON in footerJson: " + e.message,
});
}
}
// Lấy footer hiện tại hoặc tạo mới (giống Header logic)
let footer = await Footer.findOne();
if (!footer) {
console.log("No existing footer found, creating new one");
footer = new Footer(updateData);
await footer.save();
console.log("✓ Footer created:", footer._id);
} else {
console.log("✓ Found existing footer:", footer._id);
// Merge với dữ liệu cũ thay vì overwrite (giống Header)
Object.assign(footer, updateData);
await footer.save();
console.log("✓ Footer updated successfully");
}
const processedData = addBaseUrlToImages(footer.toObject());
console.log("Updated footer data:", JSON.stringify(processedData, null, 2));
res.json({
success: true,
message: "Footer updated successfully",
data: processedData,
});
} catch (error) {
console.error("✗ Error updating footer:", error);
res.status(500).json({
success: false,
error: "Failed to update footer: " + error.message,
});
}
};
// Render admin view (giữ lại cho UI hiện tại)
exports.index = async (req, res) => {
try {
const data = await Footer.getSingle();
const processedData = addBaseUrlToImages(data.toObject());
res.render("admin/footer/index", {
title: "Footer Management",
data: processedData,
});
} catch (error) {
console.error("Error in footer index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Update method cho form hiện tại (giống Header pattern)
exports.update = async (req, res) => {
try {
let updateData = req.body;
console.log("=== FOOTER FORM UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
// Nếu có footerJson, parse nó (giống Header logic)
if (updateData.footerJson && typeof updateData.footerJson === "string") {
try {
const parsedData = JSON.parse(updateData.footerJson);
console.log("✓ Parsed footerJson successfully:", parsedData);
updateData = parsedData;
} catch (e) {
console.error("✗ Error parsing footerJson:", e.message);
req.flash("error_msg", "Invalid JSON in footerJson: " + e.message);
return res.redirect("/admin/footer");
}
}
// Lấy footer hiện tại hoặc tạo mới (giống Header)
let footer = await Footer.findOne();
// ✅ Capture BEFORE state
const beforeData = footer
? JSON.parse(JSON.stringify(footer.toObject()))
: {};
if (!footer) {
console.log("No existing footer found, creating new one");
footer = new Footer(updateData);
await footer.save();
console.log("✓ Footer created:", footer._id);
req.flash("success_msg", "Footer created successfully");
} else {
console.log("✓ Found existing footer:", footer._id);
// Merge với dữ liệu cũ (giống Header)
Object.assign(footer, updateData);
await footer.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(footer.toObject()));
// ✅ AUDIT LOGGING - Footer Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Footer",
documentId: footer._id,
action: AUDIT_ACTIONS.UPDATE_FOOTER,
before: beforeData,
after: afterData,
changes,
req,
});
}
console.log("✓ Footer updated successfully");
req.flash("success_msg", "Footer updated successfully");
}
const activeTab = req.body.activeTab || "about";
res.redirect(`/admin/footer?activeTab=${activeTab}`);
} catch (err) {
console.error("✗ Error updating footer:", err);
req.flash("error_msg", err.message || "Error updating footer");
res.redirect("/admin/footer");
}
};
// Legacy API endpoints (giữ lại cho tương thích)
exports.api = exports.getFooter;
exports.getFooterData = exports.getFooter;

View File

@@ -1,44 +0,0 @@
const fs = require('fs').promises;
const path = require('path');
const formController = {
// Display form management page
index: async (req, res) => {
try {
res.render('admin/form/index', {
layout: 'layouts/admin',
title: 'Quản lý Form',
user: req.session.user,
});
} catch (error) {
console.error('Error loading form management page:', error);
res.status(500).render('error', {
message: 'Lỗi khi tải trang quản lý form',
error: error
});
}
},
// Update default form settings
updateDefaultForm: async (req, res) => {
try {
const formData = req.body;
// Here you would typically save form configuration to database or file
// For now, just return success response
res.json({
success: true,
message: 'Cập nhật form thành công'
});
} catch (error) {
console.error('Error updating form:', error);
res.status(500).json({
success: false,
message: 'Lỗi khi cập nhật form'
});
}
}
};
module.exports = formController;

View File

@@ -1,437 +0,0 @@
const Header = require("../models/header");
const HeaderMenu = require("../models/headerMenu");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/**
* Helper function to build a tree structure (Mirroring logic in headerMenuController)
*/
const buildTree = (items, parentId = null) => {
const branch = [];
const children = items.filter(
(item) =>
String(item.parentId) === String(parentId) ||
(item.parentId === null && parentId === null),
);
for (const child of children) {
const item = child.toObject ? child.toObject() : { ...child };
const subChildren = buildTree(items, item._id);
item.children = subChildren.length > 0 ? subChildren : [];
branch.push(item);
}
return branch.sort((a, b) => a.order - b.order);
};
// Admin: Render header management page
exports.index = async (req, res) => {
try {
const header = await Header.findOne().sort({ order: 1 });
// Prepare data for view
const data = header
? {
topbar: {
contactInfo: {
phone: header.top?.phone || "",
email: header.top?.email || "",
location: header.top?.location || "",
},
socialLinks: header.top?.socialLinks || [],
},
logo: header.logo?.light || "",
}
: {
topbar: {
contactInfo: {
phone: "",
email: "",
location: "",
},
socialLinks: [],
},
logo: "",
};
const activeTab = req.query.tab || "topbar";
// Always fetch menu items to ensure they are available even if the user
// switches tabs client-side
const items = await HeaderMenu.find().sort({ order: 1 });
const menuData = {
flat: items,
tree: buildTree(items),
};
res.render("admin/header/index", {
layout: "layouts/main",
title: "Header Management",
user: req.session.user || null,
data: data,
activeTab: activeTab,
menuData: menuData,
});
} catch (error) {
console.error("Error loading header management:", error);
res.status(500).render("page/error", {
title: "Error",
message: "Failed to load header management page",
});
}
};
// Admin: Get all headers (API)
exports.getAll = async (req, res) => {
try {
const headers = await Header.find().sort({ order: 1 });
res.json({
success: true,
data: headers,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Admin: Get single header
exports.show = async (req, res) => {
try {
const header = await Header.findById(req.params.id);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Admin: Create header
exports.store = async (req, res) => {
try {
const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body;
const header = new Header({
top,
offcanvas,
menu,
logo,
ctaButton,
status: status || "active",
order: order || 1,
});
await header.save();
res.status(201).json({
success: true,
message: "Header created successfully",
data: header,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Admin: Update header
exports.update = async (req, res) => {
try {
let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } =
req.body;
console.log("=== UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
console.log("topbarJson type:", typeof topbarJson);
console.log("topbarJson value:", topbarJson);
// Nếu có topbarJson, parse nó
if (topbarJson && typeof topbarJson === "string") {
try {
const parsedData = JSON.parse(topbarJson);
console.log("✓ Parsed topbarJson successfully:", parsedData);
// Chuyển đổi từ topbarData sang top format
top = {
phone: parsedData.contactInfo?.phone || "",
email: parsedData.contactInfo?.email || "",
location: parsedData.contactInfo?.location || "",
socialLinks: parsedData.socialLinks || [],
};
if (logo) {
updateData.logo = logoData;
}
console.log(
"Preparing to update header with data:",
JSON.stringify(updateData, null, 2),
);
const updatedHeader = await Header.findByIdAndUpdate(
headerId,
updateData,
{ new: true, runValidators: true },
);
if (!updatedHeader) {
console.error("✗ Header not found with ID:", headerId);
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header updated successfully",
data: updatedHeader,
});
} catch (error) {
console.error("✗ Error updating header:", error);
res.status(400).json({
success: false,
message: error.message,
});
}
}
// Nếu không có id, tìm header đầu tiên hoặc tạo mới
let headerId = req.params.id;
if (!headerId) {
// Tìm header đầu tiên
let header = await Header.findOne().sort({ order: 1 });
if (!header) {
console.log("No existing header found, creating new one");
// Tạo header mới nếu chưa có
header = new Header({
top,
offcanvas,
menu,
logo: logo ? { light: logo } : {},
ctaButton,
status: status || "active",
order: order || 1,
});
await header.save();
console.log("✓ Header created:", header._id);
return res.json({
success: true,
message: "Header created successfully",
data: header,
});
}
headerId = header._id;
console.log("✓ Found existing header:", headerId);
}
// Chuẩn bị dữ liệu logo - merge với dữ liệu cũ
let logoData = {};
if (logo) {
// Nếu có logo mới, lấy dữ liệu cũ và update light
const existingHeader = await Header.findById(headerId);
logoData = {
light: logo,
dark: existingHeader?.logo?.dark || "",
alt: existingHeader?.logo?.alt || "",
};
}
const updateData = {
top,
offcanvas,
menu,
ctaButton,
status,
order,
};
if (logo) {
updateData.logo = logoData;
}
console.log(
"Preparing to update header with data:",
JSON.stringify(updateData, null, 2),
);
// ✅ Capture BEFORE state
const beforeHeader = await Header.findById(headerId);
const beforeData = beforeHeader
? JSON.parse(JSON.stringify(beforeHeader.toObject()))
: {};
const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, {
new: true,
runValidators: true,
});
if (!updatedHeader) {
console.error("✗ Header not found with ID:", headerId);
return res.status(404).json({
success: false,
message: "Header not found",
});
}
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedHeader.toObject()));
// ✅ AUDIT LOGGING - Header Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Header",
documentId: updatedHeader._id,
action: AUDIT_ACTIONS.UPDATE_HEADER,
before: beforeData,
after: afterData,
changes,
req,
});
}
console.log("✓ Header updated successfully:", updatedHeader._id);
console.log("Updated header data:", JSON.stringify(updatedHeader, null, 2));
res.json({
success: true,
message: "Header updated successfully",
data: updatedHeader,
});
} catch (error) {
console.error("✗ Error updating header:", error);
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Admin: Update status
exports.updateStatus = async (req, res) => {
try {
const { status } = req.body;
if (!["active", "inactive"].includes(status)) {
return res.status(400).json({
success: false,
message: "Invalid status",
});
}
const header = await Header.findByIdAndUpdate(
req.params.id,
{ status },
{ new: true },
);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header status updated",
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Admin: Delete header
exports.destroy = async (req, res) => {
try {
const header = await Header.findByIdAndDelete(req.params.id);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header deleted successfully",
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Public API: Get active header
exports.api = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({
order: 1,
});
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
res.json({
success: true,
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Public API: Get menu tree structure
exports.getMenuTreeAPI = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({
order: 1,
});
if (!header || !header.menu) {
return res.status(404).json({
success: false,
message: "No active menu found",
});
}
res.json({
success: true,
data: header.menu,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};

View File

@@ -1,205 +0,0 @@
const HeaderMenu = require("../models/headerMenu");
const slugify = require("slugify");
/**
* Helper: Build tree structure from flat array
*/
const buildMenuTree = (items, parentId = null, isPublic = false) => {
const branch = [];
const children = items.filter(
(item) => String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null),
);
for (const child of children) {
const item = child.toObject ? child.toObject() : { ...child };
// Clean data for public API if requested
let cleanItem = item;
if (isPublic) {
cleanItem = {
id: item._id,
title: item.title,
url: item.url,
type: item.type,
};
}
const subChildren = buildMenuTree(items, item._id, isPublic);
cleanItem.children = subChildren.length > 0 ? subChildren : [];
branch.push(cleanItem);
}
return branch.sort((a, b) => a.order - b.order);
};
/**
* Helper: Recursive delete children
*/
const deleteRecursive = async (parentId) => {
const children = await HeaderMenu.find({ parentId });
for (const child of children) {
await deleteRecursive(child._id);
await HeaderMenu.findByIdAndDelete(child._id);
}
};
// 1. Render Menu Tab logic
exports.index = async (req, res) => {
try {
const items = await HeaderMenu.find().sort({ order: 1 });
const menuTree = buildMenuTree(items);
return { menuTree, flatItems: items };
} catch (error) {
console.error("Error fetching menu items:", error);
throw error;
}
};
// 2. Create Menu Item
exports.store = async (req, res) => {
try {
console.log("=== BACKEND: store hit ===");
console.log("Body:", req.body);
const { title, url, parentId, order, status, type } = req.body;
const slug = slugify(title, { lower: true, strict: true });
const newItem = new HeaderMenu({
title,
slug,
url,
parentId: parentId || null,
order: order || 0,
status: status || "active",
type: type || "internal",
});
const savedItem = await newItem.save();
console.log("=== MENU CREATED ===", savedItem);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.json({ success: true, message: "Menu item created successfully", data: savedItem });
}
req.flash("success_msg", "Menu item created successfully");
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error("=== CREATE MENU ERROR ===", error);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(400).json({ success: false, message: error.message });
}
req.flash("error_msg", "Failed to create menu item: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 3. Update Menu Item
exports.update = async (req, res) => {
try {
const { id } = req.params;
console.log("=== BACKEND: update hit ===", { id });
console.log("Body:", req.body);
const { title, url, parentId, order, status, type } = req.body;
const updateData = {
url,
parentId: parentId || null,
order,
status,
type,
};
if (title) {
updateData.title = title;
updateData.slug = slugify(title, { lower: true, strict: true });
}
const updated = await HeaderMenu.findByIdAndUpdate(id, updateData, { new: true });
if (!updated) {
console.log("=== UPDATE MENU NOT FOUND ===", id);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(404).json({ success: false, message: "Menu item not found" });
}
req.flash("error_msg", "Menu item not found");
} else {
console.log("=== MENU UPDATED ===", updated);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.json({ success: true, message: "Menu item updated successfully", data: updated });
}
req.flash("success_msg", "Menu item updated successfully");
}
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error("=== UPDATE MENU ERROR ===", error);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(400).json({ success: false, message: error.message });
}
req.flash("error_msg", "Update failed: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 4. Delete Menu Item (Cascade delete children)
exports.destroy = async (req, res) => {
try {
const { id } = req.body;
const menuId = id || req.params.id;
console.log("=== BACKEND: destroy hit ===", { menuId, body: req.body });
await deleteRecursive(menuId);
await HeaderMenu.findByIdAndDelete(menuId);
console.log("=== MENU DELETED ===", menuId);
req.flash("success_msg", "Menu item and its sub-menu deleted successfully");
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error("=== DELETE MENU ERROR ===", error);
req.flash("error_msg", "Delete failed: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 5. Reorder Menu
exports.reorder = async (req, res) => {
try {
const { items } = req.body; // Array of { id, order, parentId }
if (items && Array.isArray(items)) {
const bulkOps = items.map((item) => ({
updateOne: {
filter: { _id: item.id },
update: { order: item.order, parentId: item.parentId || null },
},
}));
await HeaderMenu.bulkWrite(bulkOps);
return res.json({ success: true, message: "Reordered successfully" });
}
res.status(400).json({ success: false, message: "Invalid data" });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};
// Public API: Get active menu as clean tree
exports.api = async (req, res) => {
try {
const items = await HeaderMenu.find({ status: "active" }).sort({ order: 1 });
const tree = buildMenuTree(items, null, true);
res.json({ success: true, data: tree });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};

View File

@@ -1,249 +0,0 @@
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const Home = require("../models/home");
const Blog = require("../models/blog");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Các hàm hỗ trợ
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
const getDefaultHomeData = () => ({
hero: {
backgroundImage: "",
slides: [],
title: "",
subtitle: "",
description: "",
heroImage: "",
videoUrl: "",
primaryButton: {},
secondaryButton: {},
},
whyChooseUs: {
heading: "",
subheading: "",
description: "",
highlightWord: "",
mainImage: "",
secondaryImage: "",
items: [],
features: [],
ctaButton: {},
},
visaSolutions: { heading: "", subheading: "", items: [] },
visaCountries: {
heading: "",
subheading: "",
description: "",
countries: [],
ctaButton: {},
},
testimonials: {
heading: "",
subheading: "",
videoUrl: "",
videoThumbnail: "",
items: [],
},
videoGallery: { heading: "", videoUrl: "", thumbnail: "" },
faq: {
heading: "",
subheading: "",
description: "",
ctaButton: {},
items: [],
},
achievements: { heading: "", subheading: "", items: [] },
partners: { visaConsultancy: { items: [] }, brands: { items: [] } },
blogPreview: {
heading: "Latest Insights & Updates",
subheading: "Visa Tips & Guides",
ctaButton: { label: "View All Articles", href: "/blog" },
items: [],
selectedBlogIds: [], // Array of manually selected blog IDs
},
});
// Admin: Xem trang quản lý
exports.index = async (req, res) => {
try {
let data = await getHomeData();
const defaults = getDefaultHomeData();
// Merge dữ liệu mặc định cho tất cả các phần
const sections = Object.keys(defaults);
sections.forEach((s) => {
data[s] = data[s] || defaults[s];
});
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
// Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.lean();
return res.render("admin/home/index", {
layout: "layouts/main",
title: "Home Management",
data,
allBlogs,
frontendUrl,
backendUrl,
getFullImageUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("Home index error:", err);
req.flash("error_msg", "Error loading home data");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
};
// Admin: Cập nhật dữ liệu (tập trung vào achievements, partners; các phần khác giữ nguyên nếu không có dữ liệu mới)
exports.update = async (req, res) => {
try {
const sections = [
"hero",
"whyChooseUs",
"visaSolutions",
"visaCountries",
"testimonials",
"videoGallery",
"faq",
"achievements",
"partners",
"blogPreview",
];
let doc = await getHomeDoc();
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
if (!doc) {
doc = new Home({});
}
let hasChanges = false;
const updatedSections = [];
for (const section of sections) {
if (req.body[section]) {
try {
const payload = JSON.parse(req.body[section]);
// Gán trực tiếp vào doc, Mongoose sẽ tự check schema
doc[section] = payload;
doc.markModified(section);
hasChanges = true;
updatedSections.push(section);
} catch (e) {
console.error(`Invalid JSON for ${section}:`, e);
}
}
}
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return req.session.save(() => res.redirect("/admin/home"));
}
await doc.save();
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
// ✅ AUDIT LOGGING - Home Update
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_HOME,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Home page configuration has been updated!");
return req.session.save(() => res.redirect("/admin/home"));
} catch (err) {
console.error("Home update error:", err);
req.flash("error_msg", `Update error: ${err.message}`);
return req.session.save(() => res.redirect("/admin/home"));
}
};
// Public API// API lấy danh sách blog cho CMS
exports.apiGetBlogs = async (req, res) => {
try {
const blogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.select("title slug featuredImage author publishedAt")
.lean();
res.json(blogs);
} catch (err) {
res.status(500).json({ error: err.message });
}
};
exports.api = async (req, res) => {
try {
let data = await getHomeData();
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// === Xử lý Blog Preview động ===
const blogPreview = data.blogPreview || {};
let blogs = [];
// Nếu có chọn blog cụ thể
if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) {
blogs = await Blog.find({
_id: { $in: blogPreview.selectedBlogIds },
status: "published",
}).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => {
return (
blogPreview.selectedBlogIds.indexOf(a._id.toString()) -
blogPreview.selectedBlogIds.indexOf(b._id.toString())
);
});
}
// Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất (hoặc bù vào)
if (blogs.length === 0) {
blogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.limit(3)
.lean();
}
// Map dữ liệu blog sang format mà frontend mong đợi
blogPreview.items = blogs.map((blog) => ({
title: blog.title,
excerpt: blog.excerpt,
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
date: blog.publishedAt || blog.createdAt,
author: {
name: blog.author || "Admin",
avatar: "", // Frontend đang tự xử lý hoặc dùng logo hệ thống
},
comments: blog.commentsCount || 0,
link: `/blog/${blog.slug}`,
thumbnail: blog.featuredImage,
}));
data.blogPreview = blogPreview;
// ===============================
const processed = addBaseUrlToImages(data, baseUrl);
return res.json(processed);
} catch (err) {
console.error("Home API error:", err);
return res.status(500).json({ error: "Error loading home data" });
}
};

View File

@@ -1,539 +0,0 @@
const Insurance = require("../models/insurance");
const { addBaseUrlToImages } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// API để lấy insurance data (cho frontend)
exports.api = async (req, res) => {
try {
const language = req.query.lang || "en";
// Sử dụng getDefault để đảm bảo luôn có data
const insurance = await Insurance.getDefault(language);
// Trả về data với cấu trúc mới
const insuranceData = insurance.toObject();
// Sử dụng helper để thêm base URL vào đường dẫn ảnh
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
// Trả về trực tiếp hero, page, content (không wrap trong object)
res.json({
hero: processedData.hero,
page: processedData.page,
content: processedData.content,
});
} catch (error) {
console.error("API Error:", error);
res.status(500).json({
success: false,
error: "Error loading insurance data",
message: error.message,
});
}
};
// API để lấy toàn bộ insurance data (cho admin)
exports.getInsuranceData = async (req, res) => {
try {
const language = req.query.lang || "en";
const insurance = await Insurance.findOne({
name: "default",
language: language,
});
if (!insurance) {
return res.status(404).json({
success: false,
error: "Insurance data not found",
});
}
const insuranceData = insurance.toObject();
// Thêm base URL vào đường dẫn ảnh
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
res.json({
success: true,
data: processedData,
});
} catch (error) {
console.error("Error getting insurance data:", error);
res.status(500).json({
success: false,
error: "Error loading insurance data",
});
}
};
// API để lấy data theo ngôn ngữ
exports.getByLanguage = async (req, res) => {
try {
const language = req.params.lang || "en";
const insurance = await Insurance.findOne({
name: "default",
language: language,
});
if (!insurance) {
return res.status(404).json({
success: false,
error: "Insurance data not found",
});
}
const insuranceData = insurance.toObject();
// Thêm base URL vào đường dẫn ảnh
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
res.json({
success: true,
data: {
hero: processedData.hero,
page: processedData.page,
content: processedData.content,
},
});
} catch (error) {
console.error("Error getting insurance by language:", error);
res.status(500).json({
success: false,
error: "Error loading insurance data",
});
}
};
// Render admin view
exports.index = async (req, res) => {
try {
// Luôn đảm bảo có default data
const insurance = await Insurance.getDefault("en");
const data = insurance.toObject();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/insurance/index", {
title: "Insurance Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in insurance index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Seed data từ JSON file (cấu trúc mới)
exports.seed = async (req, res) => {
try {
const fs = require("fs").promises;
const path = require("path");
// Đọc file JSON
const jsonPath = path.join(__dirname, "../data/insurance.json");
const jsonData = JSON.parse(await fs.readFile(jsonPath, "utf8"));
console.log("Seeding insurance from JSON...");
// Migrate từ cấu trúc cũ sang mới
const insurance = await Insurance.migrateFromJson(jsonData, "en");
res.json({
success: true,
message: "Insurance data seeded successfully",
data: {
id: insurance._id,
hero: insurance.hero,
page: insurance.page,
content: insurance.content,
},
});
} catch (error) {
console.error("Error seeding insurance:", error);
res.status(500).json({
success: false,
error: error.message || "Error seeding insurance data",
});
}
};
// API preview cho admin (tạo HTML preview)
exports.preview = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
return data;
};
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
const contentData = parseJson(content) || {};
// Thêm base URL vào đường dẫn ảnh cho preview
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
// Render preview HTML
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${pageData.title || "Insurance Preview"}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { font-family: Arial, sans-serif; }
.hero-section {
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
url('${processedHeroData.backgroundImage || ""}');
background-size: cover;
background-position: center;
color: white;
padding: 100px 20px;
text-align: center;
}
.page-header {
padding: 40px 20px;
background: #f8f9fa;
}
.content-section {
padding: 40px 20px;
}
.content-item {
margin-bottom: 20px;
}
</style>
</head>
<body>
<!-- Hero Section -->
<div class="hero-section">
<h1>${heroData.title || "Insurance"}</h1>
<p>${heroData.subtitle || ""}</p>
</div>
<!-- Page Header -->
<div class="page-header">
<div class="container">
<h2>${pageData.title || "Insurance Information"}</h2>
${pageData.divider !== false ? "<hr>" : ""}
</div>
</div>
<!-- Content Section -->
<div class="content-section">
<div class="container">
${renderContentItems(contentData.content || [])}
</div>
</div>
</body>
</html>
`;
res.send(html);
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
}
};
// Helper function để render content items
function renderContentItems(contentItems) {
if (!Array.isArray(contentItems) || contentItems.length === 0) {
return "<p>No content available.</p>";
}
return contentItems
.map((item) => {
switch (item.type) {
case "header":
return `<h${item.level || 2} class="content-item">${item.text}</h${item.level || 2}>`;
case "paragraph":
return `<p class="content-item">${item.text}</p>`;
case "section":
return `
<div class="content-item">
<h3>${item.title}</h3>
<p>${item.content}</p>
</div>
`;
case "list":
const listItems = (item.items || [])
.map((li) => `<li>${li}</li>`)
.join("");
return `<ul class="content-item">${listItems}</ul>`;
case "note":
return `<div class="alert alert-info content-item">${item.text}</div>`;
case "embed":
if (item.source === "youtube") {
return `
<div class="content-item">
<iframe width="${item.width || 560}" height="${item.height || 315}"
src="${item.url || item.embed}"
frameborder="0" allowfullscreen></iframe>
${item.caption ? `<p class="text-muted mt-2">${item.caption}</p>` : ""}
</div>
`;
}
return "";
default:
return "";
}
})
.join("");
}
// API để tạo insurance mới (cho các ngôn ngữ khác)
exports.create = async (req, res) => {
try {
const { hero, page, content, language } = req.body;
if (!language) {
return res.status(400).json({
success: false,
error: "Language is required",
});
}
// Kiểm tra đã tồn tại chưa
const existing = await Insurance.findOne({
name: "default",
language: language,
});
if (existing) {
return res.status(400).json({
success: false,
error: "Insurance already exists for this language",
});
}
// Parse JSON nếu cần
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
return data;
};
const insurance = new Insurance({
name: "default",
language: language,
hero: parseJson(hero) || {},
page: parseJson(page) || {},
content: parseJson(content) || {},
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false,
});
await insurance.save();
res.json({
success: true,
message: "Insurance created successfully for language: " + language,
data: insurance,
});
} catch (error) {
console.error("Error creating insurance:", error);
res.status(500).json({
success: false,
error: error.message || "Error creating insurance",
});
}
};
// Cập nhật dữ liệu insurance (CẬP NHẬT CẤU TRÚC MỚI)
exports.update = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
return data;
};
// Parse all data với cấu trúc mới
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
const contentData = parseJson(content) || {};
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
function extractYouTubeId(url) {
const regex =
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
}
if (contentData && Array.isArray(contentData.content)) {
contentData.content.forEach((item) => {
if (item.type === "embed" && item.source === "youtube") {
if (item.url && item.url.includes("watch?v=")) {
const videoId = extractYouTubeId(item.url);
if (videoId) {
item.url = `https://www.youtube.com/embed/${videoId}`;
item.videoId = videoId;
}
}
if (item.embed && item.embed.includes("watch?v=")) {
const videoId = extractYouTubeId(item.embed);
if (videoId) {
item.embed = `https://www.youtube.com/embed/${videoId}`;
item.videoId = videoId;
}
}
}
});
}
// Tìm hoặc tạo insurance
let insurance = await Insurance.findOne({
name: "default",
language: "en",
});
// ✅ Capture BEFORE state
const beforeData = insurance
? JSON.parse(
JSON.stringify(insurance.toObject ? insurance.toObject() : insurance),
)
: {};
if (!insurance) {
insurance = new Insurance({
name: "default",
language: "en",
hero: heroData,
page: pageData,
content: contentData,
version: "2.0.0",
isActive: true,
});
} else {
insurance.hero = heroData;
insurance.page = pageData;
insurance.content = contentData;
insurance.version = "2.0.0";
}
await insurance.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(insurance.toObject ? insurance.toObject() : insurance),
);
// ✅ AUDIT LOGGING - Insurance Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Insurance",
documentId: insurance._id,
action: AUDIT_ACTIONS.UPDATE_INSURANCE,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Insurance updated successfully");
res.redirect("/admin/insurance");
} catch (err) {
console.error("Error updating insurance:", err);
req.flash("error_msg", err.message || "Error updating insurance");
res.redirect("/admin/insurance");
}
};
// API để xóa insurance (theo ngôn ngữ)
exports.delete = async (req, res) => {
try {
const language = req.params.lang;
if (!language) {
return res.status(400).json({
success: false,
error: "Language parameter is required",
});
}
// Không cho phép xóa tiếng Anh mặc định
if (language === "en") {
return res.status(400).json({
success: false,
error: "Cannot delete default English insurance data",
});
}
const result = await Insurance.deleteOne({
name: "default",
language: language,
});
if (result.deletedCount === 0) {
return res.status(404).json({
success: false,
error: "Insurance not found for this language",
});
}
res.json({
success: true,
message: "Insurance deleted successfully for language: " + language,
});
} catch (error) {
console.error("Error deleting insurance:", error);
res.status(500).json({
success: false,
error: error.message || "Error deleting insurance",
});
}
};

View File

@@ -0,0 +1,76 @@
const Level = require('../models/level');
const Degree = require('../models/degree');
// GET /admin/level
exports.index = async (req, res) => {
try {
const levels = await Level.find();
res.render('admin/level/index', {
levels,
user: req.session.user,
layout: 'layouts/admin',
title: 'Quản lý Cấp độ'
});
} catch (err) {
console.error('levelController.index error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tải danh sách cấp độ');
res.redirect('/admin/dashboard');
}
};
// POST /admin/level/create
exports.create = async (req, res) => {
try {
const { type } = req.body;
if (!type) {
req.flash('error', 'Type is required');
return res.redirect('back');
}
await Level.create({ type });
req.flash('success', 'Level created');
res.redirect('/admin/level');
} catch (err) {
console.error('levelController.create error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tạo cấp độ');
res.redirect('back');
}
};
// POST /admin/level/:id/edit
exports.update = async (req, res) => {
try {
const { id } = req.params;
const { type } = req.body;
await Level.findByIdAndUpdate(id, { type });
req.flash('success', 'Level updated');
res.redirect('/admin/level');
} catch (err) {
console.error('levelController.update error:', err);
req.flash('error', 'Đã xảy ra lỗi khi cập nhật cấp độ');
res.redirect('back');
}
};
// POST /admin/level/:id/delete
exports.destroy = async (req, res) => {
try {
const { id } = req.params;
const count = await Degree.countDocuments({ level: id });
if (count > 0) {
req.flash('error', 'Cannot delete: Level is referenced by existing degrees');
return res.redirect('back');
}
await Level.findByIdAndDelete(id);
req.flash('success', 'Level deleted');
res.redirect('/admin/level');
} catch (err) {
console.error('levelController.destroy error:', err);
req.flash('error', 'Đã xảy ra lỗi khi xóa cấp độ');
res.redirect('back');
}
};

View File

@@ -1,228 +0,0 @@
const { readJsonFile, writeJsonFile } = require('../utils/jsonHelper');
const slugify = require('slugify');
// Hiển thị tất cả các trang
exports.getAllPages = async (req, res) => {
try {
const content = readJsonFile('content');
const pages = content.pages || [];
res.render('admin/pages/index', {
title: 'Quản lý trang',
pages
});
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading page list');
res.redirect('/admin/dashboard');
}
};
// Hiển thị form tạo trang mới
exports.getAddPage = (req, res) => {
res.render('admin/pages/add', {
title: 'Thêm trang mới'
});
};
// Xử lý tạo trang mới
exports.addPage = async (req, res) => {
try {
const { title, content } = req.body;
// Kiểm tra dữ liệu
if (!title || !content) {
req.flash('error_msg', 'Please fill in all required fields');
return res.redirect('/admin/pages/add');
}
// Tạo slug từ tiêu đề
const slug = slugify(title, {
lower: true,
strict: true,
locale: 'vi'
});
// Lấy dữ liệu hiện tại
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
// Kiểm tra slug đã tồn tại chưa
const existingPage = pages.find(page => page.slug === slug);
if (existingPage) {
req.flash('error_msg', 'This title is already in use. Please choose a different title.');
return res.redirect('/admin/pages/add');
}
// Tạo trang mới
const newPage = {
id: Date.now().toString(), // Sử dụng timestamp làm ID
title,
slug,
content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// Thêm trang mới vào danh sách
pages.push(newPage);
contentData.pages = pages;
// Lưu lại dữ liệu
writeJsonFile('content', contentData);
req.flash('success_msg', 'New page created successfully');
res.redirect('/admin/pages');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error creating new page');
res.redirect('/admin/pages/add');
}
};
// Hiển thị form chỉnh sửa trang
exports.getEditPage = async (req, res) => {
try {
const pageId = req.params.id;
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
const page = pages.find(p => p.id === pageId);
if (!page) {
req.flash('error_msg', 'Page not found');
return res.redirect('/admin/pages');
}
res.render('admin/pages/edit', {
title: 'Chỉnh sửa trang',
page
});
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading page');
res.redirect('/admin/pages');
}
};
// Xử lý chỉnh sửa trang
exports.updatePage = async (req, res) => {
try {
const pageId = req.params.id;
const { title, content } = req.body;
// Kiểm tra dữ liệu
if (!title || !content) {
req.flash('error_msg', 'Please fill in all required fields');
return res.redirect(`/admin/pages/edit/${pageId}`);
}
// Lấy dữ liệu hiện tại
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
// Tìm trang cần cập nhật
const pageIndex = pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) {
req.flash('error_msg', 'Page not found');
return res.redirect('/admin/pages');
}
const page = pages[pageIndex];
// Kiểm tra nếu tiêu đề thay đổi thì cập nhật slug
let newSlug = page.slug;
if (page.title !== title) {
newSlug = slugify(title, {
lower: true,
strict: true,
locale: 'vi'
});
// Kiểm tra slug đã tồn tại chưa (trừ trang hiện tại)
const existingPage = pages.find(p => p.slug === newSlug && p.id !== pageId);
if (existingPage) {
req.flash('error_msg', 'This title is already in use. Please choose a different title.');
return res.redirect(`/admin/pages/edit/${pageId}`);
}
}
// Cập nhật thông tin trang
pages[pageIndex] = {
...page,
title,
slug: newSlug,
content,
updatedAt: new Date().toISOString()
};
// Lưu lại dữ liệu
contentData.pages = pages;
writeJsonFile('content', contentData);
req.flash('success_msg', 'Page updated successfully');
res.redirect('/admin/pages');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error updating page');
res.redirect(`/admin/pages/edit/${req.params.id}`);
}
};
// Xử lý xóa trang
exports.deletePage = async (req, res) => {
try {
const pageId = req.params.id;
// Lấy dữ liệu hiện tại
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
// Lọc bỏ trang cần xóa
contentData.pages = pages.filter(p => p.id !== pageId);
// Lưu lại dữ liệu
writeJsonFile('content', contentData);
req.flash('success_msg', 'Page deleted successfully');
res.redirect('/admin/pages');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error deleting page');
res.redirect('/admin/pages');
}
};
// Hiển thị trang theo slug
exports.getPageBySlug = async (req, res) => {
try {
const { slug } = req.params;
// Lấy dữ liệu từ content.json
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
// Tìm trang theo slug
const page = pages.find(p => p.slug === slug);
if (!page) {
return res.status(404).render('page/not-found', {
title: 'Page Not Found',
message: 'The page you are looking for does not exist or has been deleted.'
});
}
// Hiển thị trang
res.render('page/view', {
title: page.title,
page
});
} catch (err) {
console.error(err);
res.status(500).render('page/error', {
title: 'Error',
message: 'An error occurred while loading the page. Please try again later.'
});
}
};

View File

@@ -1,229 +0,0 @@
const Pricing = require("../models/pricing");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// ==================== CMS ADMIN FUNCTIONS ====================
// Render admin page for pricing management
exports.index = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
// If no data in DB, try to load from JSON file
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
} else {
// Create default pricing
pricing = await Pricing.create({
name: "default",
hero: {
title: "Pricing Plan",
backgroundImage: "/assets/img/inner-page/breadcrumb.jpg",
shapeImage: "/assets/img/inner-page/shape.png",
breadcrumb: [
{ text: "Home", link: "/" },
{ text: "Pricing Plan", link: "" },
],
},
pricingSection: {
subtitle: "pricing plan",
heading: "Flexible Plans to Suit Every Traveler",
description:
"Choose the plan that fits your visa needs and enjoy expert guidance every step of the way.",
},
plans: {
monthly: [],
yearly: [],
},
testimonials: {
subtitle: "What Our Clients Say",
heading: "Immigration Success Stories",
buttonText: "View All Review",
buttonLink: "/contact",
buttonIcon: "fa-solid fa-arrow-right",
image: "",
items: [],
},
});
}
}
res.render("admin/pricing/index", {
layout: "layouts/main",
title: "Pricing Management",
data: pricing,
user: req.session.user,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
});
} catch (err) {
console.error("Error loading pricing admin page:", err);
req.flash("error", "Error loading pricing data");
res.redirect("/admin/dashboard");
}
};
// Update pricing data
exports.update = async (req, res) => {
try {
const { hero, pricingSection, plans, testimonials } = req.body;
// Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const pricingSectionData =
typeof pricingSection === "string"
? JSON.parse(pricingSection)
: pricingSection;
const plansData = typeof plans === "string" ? JSON.parse(plans) : plans;
const testimonialsData =
typeof testimonials === "string"
? JSON.parse(testimonials)
: testimonials;
let pricing = await Pricing.findOne({ name: "default" });
// ✅ Capture BEFORE state
const beforeData = pricing
? JSON.parse(JSON.stringify(pricing.toObject()))
: {};
if (pricing) {
pricing.hero = heroData;
pricing.pricingSection = pricingSectionData;
pricing.plans = plansData;
pricing.testimonials = testimonialsData;
await pricing.save();
} else {
pricing = await Pricing.create({
name: "default",
hero: heroData,
pricingSection: pricingSectionData,
plans: plansData,
testimonials: testimonialsData,
});
}
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(pricing.toObject()));
// ✅ AUDIT LOGGING - Pricing Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Pricing",
documentId: pricing._id,
action: AUDIT_ACTIONS.UPDATE_PRICING,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success", "Pricing data updated successfully");
res.redirect("/admin/pricing");
} catch (err) {
console.error("Error updating pricing:", err);
req.flash("error", "Error updating pricing data");
res.redirect("/admin/pricing");
}
};
// API to get pricing data (admin)
exports.getPricingData = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
}
}
res.json({
success: true,
data: pricing,
});
} catch (err) {
console.error("Error getting pricing data:", err);
res.status(500).json({
success: false,
error: "Error loading pricing data",
});
}
};
// Public API to get pricing page data (for frontend)
exports.api = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
}
}
if (!pricing) {
return res.status(404).json({
success: false,
error: "Pricing data not found",
});
}
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
const getFullUrl = (path) => {
if (!path || path.startsWith("http")) return path;
return `${backendUrl}${path.startsWith("/") ? "" : "/"}${path}`;
};
// Convert to plain object to modify properties safely
const pricingData = pricing.toObject ? pricing.toObject() : pricing;
if (pricingData.hero) {
pricingData.hero.backgroundImage = getFullUrl(
pricingData.hero.backgroundImage,
);
pricingData.hero.shapeImage = getFullUrl(pricingData.hero.shapeImage);
}
if (pricingData.testimonials) {
pricingData.testimonials.image = getFullUrl(
pricingData.testimonials.image,
);
}
res.json({
success: true,
data: {
hero: pricingData.hero,
pricingSection: pricingData.pricingSection,
plans: pricingData.plans,
testimonials: pricingData.testimonials,
},
});
} catch (err) {
console.error("Error getting pricing API data:", err);
res.status(500).json({
success: false,
error: "Error loading pricing data",
});
}
};

View File

@@ -0,0 +1,165 @@
const path = require('path');
const Qualification = require('../models/qualification');
const Department = require('../models/department');
const Level = require('../models/level');
const writeAuditLog = require('../audit/writeAuditLog');
const AUDIT_ACTIONS = require('../constants/auditAction');
function normalizePath(filePath) {
if (!filePath) return undefined;
return path.basename(filePath.replace(/\\/g, '/'));
}
// GET /admin/qualification
exports.index = async (req, res) => {
try {
const { search, status } = req.query;
const filter = {};
if (search) {
filter.$or = [
{ qualification_number: { $regex: search, $options: 'i' } },
{ student_name: { $regex: search, $options: 'i' } }
];
}
if (status) filter.status = status;
const [qualifications, departments, levels] = await Promise.all([
Qualification.find(filter).populate('department level').sort({ createdAt: -1 }),
Department.find(), Level.find()
]);
res.render('admin/qualification/index', {
qualifications, departments, levels, query: req.query,
user: req.session.user, layout: 'layouts/admin', title: 'Qualifications'
});
} catch (err) {
console.error(err);
req.flash('error', 'Error loading qualifications');
res.redirect('/admin/dashboard');
}
};
// GET /admin/qualification/create
exports.createForm = async (req, res) => {
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/qualification/create', {
departments, levels, user: req.session.user,
layout: 'layouts/admin', title: 'Create Qualification'
});
} catch (err) {
req.flash('error', 'Error'); res.redirect('/admin/qualification');
}
};
// POST /admin/qualification/create
exports.create = async (req, res) => {
try {
const data = { ...req.body };
const imgPath = req.files?.degree_image?.[0]?.path;
if (imgPath) data.degree_image = normalizePath(imgPath);
const qual = new Qualification(data);
await qual.save();
await writeAuditLog({ model: 'Qualification', documentId: qual._id, action: AUDIT_ACTIONS.CREATE_QUALIFICATION, before: null, after: qual.toObject(), req });
req.flash('success', 'Qualification created');
res.redirect('/admin/qualification');
} catch (err) {
console.error(err);
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/qualification/create', {
error: err.message, formData: req.body, departments, levels,
user: req.session.user, layout: 'layouts/admin', title: 'Create Qualification'
});
} catch { req.flash('error', err.message); res.redirect('/admin/qualification'); }
}
};
// GET /admin/qualification/:id/edit
exports.editForm = async (req, res) => {
try {
const qual = await Qualification.findById(req.params.id).populate('department level');
if (!qual) { req.flash('error', 'Not found'); return res.redirect('/admin/qualification'); }
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/qualification/edit', {
qual, departments, levels, user: req.session.user,
layout: 'layouts/admin', title: 'Edit Qualification'
});
} catch (err) {
req.flash('error', 'Error'); res.redirect('/admin/qualification');
}
};
// POST /admin/qualification/:id/edit
exports.update = async (req, res) => {
try {
const qual = await Qualification.findById(req.params.id);
if (!qual) { req.flash('error', 'Not found'); return res.redirect('/admin/qualification'); }
const before = qual.toObject();
const fields = ['qualification_number','student_name','program_name','department','level',
'issued_date','status','passport_number','address','topic_name','topic_short_desc'];
fields.forEach(f => { if (req.body[f] !== undefined) qual[f] = req.body[f]; });
const imgPath = req.files?.degree_image?.[0]?.path;
if (imgPath) qual.degree_image = normalizePath(imgPath);
await qual.save();
await writeAuditLog({ model: 'Qualification', documentId: qual._id, action: AUDIT_ACTIONS.UPDATE_QUALIFICATION, before, after: qual.toObject(), req });
req.flash('success', 'Qualification updated');
res.redirect('/admin/qualification');
} catch (err) {
req.flash('error', err.message); res.redirect('back');
}
};
// POST /admin/qualification/:id/delete
exports.destroy = async (req, res) => {
try {
const qual = await Qualification.findById(req.params.id);
if (!qual) { req.flash('error', 'Not found'); return res.redirect('/admin/qualification'); }
await writeAuditLog({ model: 'Qualification', documentId: qual._id, action: AUDIT_ACTIONS.DELETE_QUALIFICATION, before: qual.toObject(), after: null, req });
await qual.deleteOne();
req.flash('success', 'Qualification deleted');
res.redirect('/admin/qualification');
} catch (err) {
req.flash('error', 'Error deleting'); res.redirect('/admin/qualification');
}
};
// GET /api/verify-degree/:degree_id?api_key=xxx
exports.apiVerify = async (req, res) => {
try {
const qual = await Qualification.findOne({
qualification_number: { $regex: new RegExp('^' + req.params.degree_id + '$', 'i') }
}).populate('department level');
if (!qual) return res.status(404).json({ error: 'Degree not found' });
if (qual.status === 'revoked') return res.status(404).json({ error: 'Degree has been revoked' });
const baseUrl = `${req.protocol}://${req.get('host')}`;
const buildUrl = (f) => f ? [`${baseUrl}/secure-files/${path.basename(f)}?api_key=${req.query.api_key}`] : undefined;
const response = {
full_name: qual.student_name,
program_name: qual.program_name,
degree_id: qual.qualification_number,
};
if (qual.passport_number) response.passport_number = qual.passport_number;
if (qual.address) response.address = qual.address;
const imgs = buildUrl(qual.degree_image);
if (imgs) response.degree_image = imgs;
if (qual.topic_name) {
response.topic_name = qual.topic_name;
if (qual.topic_short_desc) response.topic_short_desc = qual.topic_short_desc;
}
return res.json(response);
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error' });
}
};

View File

@@ -1,197 +0,0 @@
const Safety = require("../models/safety");
const { addBaseUrlToImages } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Lấy dữ liệu Safety từ MongoDB
const getSafetyData = async () => {
const safety = await Safety.findOne().sort({ updatedAt: -1 });
if (!safety) {
return null;
}
return safety.toObject();
};
// API endpoint cho frontend
exports.api = async (req, res) => {
try {
const safety = await getSafetyData();
if (!safety) {
return res.status(404).json({ error: "Safety data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(safety, baseUrl);
res.json(processedData);
} catch (err) {
console.error("Safety API error:", err);
res.status(500).json({ error: "Error loading safety data" });
}
};
// Hiển thị danh sách Safety cho admin
exports.index = async (req, res) => {
try {
const items = await Safety.find().sort({ updatedAt: -1 }).limit(10);
// Lấy bản ghi mới nhất hoặc object rỗng nếu chưa có dữ liệu
const latest = items && items.length > 0 ? items[0] : null;
const data = latest
? latest.toObject
? latest.toObject()
: latest
: {
hero: { title: "", banner: "" },
approach: {},
approachImgs: [],
approachStats: [],
approachFeatures: [],
approachCards: [],
philosophy: {},
philosophyCards: [],
security: {},
securityCards: [],
};
res.render("admin/safety/index", {
layout: "layouts/main",
title: "Safety Management",
items,
data,
frontendUrl:
process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading Safety data");
res.redirect("/admin/dashboard");
}
};
// Hiển thị form tạo mới Safety
exports.createForm = async (req, res) => {
try {
res.render("admin/safety/create", {
layout: "layouts/main",
title: "Create Safety",
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading create form");
res.redirect("/admin/safety");
}
};
// Tạo mới Safety
exports.create = async (req, res) => {
try {
const safetyData = req.body; // Tùy chỉnh parse nếu cần
const newSafety = new Safety(safetyData);
await newSafety.save();
req.flash("success_msg", "Safety created successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Create error:", err);
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety/create");
}
};
// Cập nhật Safety
exports.update = async (req, res) => {
try {
const { hero, approach, philosophy, security } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
const heroData = parseJson(hero);
const approachData = parseJson(approach);
const philosophyData = parseJson(philosophy);
const securityData = parseJson(security);
// Tìm hoặc tạo safety record
const items = await Safety.find().sort({ updatedAt: -1 }).limit(1);
let safety = items && items.length > 0 ? items[0] : null;
// ✅ Capture BEFORE state
const beforeData = safety
? JSON.parse(JSON.stringify(safety.toObject ? safety.toObject() : safety))
: {};
if (!safety) {
// Tạo mới
safety = new Safety({
hero: heroData || { title: "", banner: "" },
approach: approachData || {},
philosophy: philosophyData || {},
security: securityData || {},
});
} else {
// Cập nhật
if (heroData) safety.hero = heroData;
if (approachData) safety.approach = approachData;
if (philosophyData) safety.philosophy = philosophyData;
if (securityData) safety.security = securityData;
}
await safety.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(safety.toObject ? safety.toObject() : safety),
);
// ✅ AUDIT LOGGING - Safety Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Safety",
documentId: safety._id,
action: AUDIT_ACTIONS.UPDATE_SAFETY,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Safety updated successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety");
}
};
// Xóa Safety
exports.delete = async (req, res) => {
try {
const safety = await Safety.findById(req.params.id);
if (!safety) {
req.flash("error_msg", "Safety record not found");
return res.redirect("/admin/safety");
}
await Safety.findByIdAndDelete(req.params.id);
req.flash("success_msg", "Safety record deleted successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Delete error:", err);
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety");
}
};

View File

@@ -1,396 +0,0 @@
const { getServiceData } = require("../services/service.service");
const Service = require("../models/service");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const slugify = require("slugify");
// Admin page - Service list
exports.index = async (req, res) => {
try {
const data = await getServiceData();
console.log(data.services.items.image);
res.render("admin/service/index", {
title: "Service Management",
data,
layout: "layouts/main",
getFullImageUrl, // Truyền helper function vào view
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service data");
res.redirect("/admin/dashboard");
}
};
// Admin page - Service edit
exports.edit = async (req, res) => {
try {
const { slug } = req.params;
const data = await getServiceData();
const service = data.services?.items?.find((item) => item.slug === slug);
if (!service) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
res.render("admin/service/edit", {
title: `Edit Service - ${service.name}`,
service,
layout: "layouts/main",
getFullImageUrl,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service for editing");
res.redirect("/admin/service");
}
};
// Update single service
exports.updateService = async (req, res) => {
try {
const { slug } = req.params;
const currentData = await getServiceData();
const serviceIndex = currentData.services?.items?.findIndex(
(item) => item.slug === slug,
);
if (serviceIndex === -1) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
const oldItem = JSON.parse(
JSON.stringify(currentData.services.items[serviceIndex]),
);
// Update service data
const updatedData = { ...currentData.toObject?.() };
updatedData.services.items[serviceIndex] = {
...updatedData.services.items[serviceIndex],
name: req.body.name,
slug: req.body.slug,
description: req.body.description,
image: req.body.image,
layout: req.body.layout,
};
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
const newItem = updatedData.services.items[serviceIndex];
const changes = diffObject(oldItem, newItem);
console.log("USER:", req.session?.user || req.user || "No user found");
await writeAuditLog({
model: "Service",
documentId: currentData._id,
action: AUDIT_ACTIONS.UPDATE_SERVICE,
before: oldItem,
after: newItem,
changes,
req,
});
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// Admin page - Service details
exports.details = async (req, res) => {
try {
const { slug } = req.params;
const data = await getServiceData();
const service = data.services?.items?.find((item) => item.slug === slug);
if (!service) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
res.render("admin/service/details", {
title: `Service Details - ${service.name}`,
service,
layout: "layouts/main",
getFullImageUrl, // Truyền helper function vào view
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service details");
res.redirect("/admin/service");
}
};
// Update service list
exports.update = async (req, res) => {
try {
const currentData = await getServiceData();
const sections = [
"pageTitle",
"services",
"destinations",
"visas",
"reviews",
];
let updatedData = { ...currentData.toObject?.() };
let hasChanges = false;
sections.forEach((section) => {
if (!req.body[section]) return;
const newData = JSON.parse(req.body[section]);
if (JSON.stringify(newData) !== JSON.stringify(currentData[section])) {
updatedData[section] = newData;
hasChanges = true;
}
});
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return res.redirect("/admin/service");
}
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// Update service details
exports.updateDetails = async (req, res) => {
try {
const { slug } = req.params;
const currentData = await getServiceData();
const serviceIndex = currentData.services?.items?.findIndex(
(item) => item.slug === slug,
);
if (serviceIndex === -1) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
const beforeDetails = JSON.parse(
JSON.stringify(currentData.services.items[serviceIndex].details || {}),
);
// Parse features and FAQ from JSON strings
const features = req.body.features ? JSON.parse(req.body.features) : [];
const faq = req.body.faq ? JSON.parse(req.body.faq) : [];
// Update service details
const updatedData = { ...currentData.toObject?.() };
const updatedDetails = {
title: req.body.title,
description: req.body.description,
mainImage: req.body.mainImage,
overviewTitle: req.body.overviewTitle,
overviewDescription: req.body.overviewDescription,
additionalDescription: req.body.additionalDescription,
keyFeaturesTitle: req.body.keyFeaturesTitle,
keyFeaturesImage: req.body.keyFeaturesImage,
features,
faqTitle: req.body.faqTitle,
faqImage: req.body.faqImage,
faq,
};
updatedData.services.items[serviceIndex].details = updatedDetails;
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
const changes = diffObject(beforeDetails, updatedDetails);
if (changes.length > 0) {
await writeAuditLog({
model: "Service",
documentId: currentData._id,
action: AUDIT_ACTIONS.UPDATE_SERVICE_DETAILS,
before: beforeDetails,
after: updatedDetails,
changes,
req,
});
}
req.flash("success_msg", "Service details updated successfully");
res.redirect(`/admin/service/${slug}/details`);
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// API endpoint
exports.api = async (req, res) => {
try {
const serviceData = await getServiceData();
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(serviceData, baseUrl);
res.json(processedData);
} catch (err) {
res.status(500).json({ error: "Error loading service data" });
}
};
/**
* Get service details by slug - API endpoint
*/
exports.getServiceBySlug = async (req, res) => {
try {
const { slug } = req.params;
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc) {
return res.status(404).json({
success: false,
message: "Service data not found",
});
}
// Find service by slug
const service = serviceDoc.services?.items?.find(
(item) => item.slug === slug,
);
if (!service) {
return res.status(404).json({
success: false,
message: `Service with slug '${slug}' not found`,
});
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// Return service details in the expected format
const responseData = {
pageTitle: serviceDoc.pageTitle,
breadcrumb: {
...serviceDoc.breadcrumb,
title: "Service Details",
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: service.name, href: `/services/${slug}` },
],
},
serviceDetails: {
content: service.details,
keyFeatures: {
title: service.details.keyFeaturesTitle || "Key Features",
sideImage: service.details.keyFeaturesImage || "img/default.jpg",
items: service.details.features || [],
},
faq: {
title: service.details.faqTitle || "Frequently Asked Questions",
sideImage: service.details.faqImage || "img/default.jpg",
items: service.details.faq || [],
},
},
};
const processedData = addBaseUrlToImages(responseData, baseUrl);
res.json(processedData);
} catch (error) {
console.error("Error fetching service by slug:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
/**
* Generate slug from text - API endpoint
*/
exports.generateSlug = async (req, res) => {
try {
const { text } = req.body;
if (!text || typeof text !== "string") {
return res.status(400).json({
success: false,
message: "Text is required",
});
}
// Generate slug using slugify library with Vietnamese support
const slug = slugify(text, {
lower: true,
strict: true,
locale: "vi",
});
res.json({
success: true,
slug: slug,
});
} catch (error) {
console.error("Error generating slug:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
/**
* Get all service slugs - API endpoint
*/
exports.getServiceSlugs = async (req, res) => {
try {
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc?.services?.items) {
return res.json({
success: true,
slugs: [],
});
}
const slugs = serviceDoc.services.items.map((item) => ({
slug: item.slug,
name: item.name,
id: item.id,
}));
res.json({
success: true,
slugs,
});
} catch (error) {
console.error("Error fetching service slugs:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};

View File

@@ -1,56 +0,0 @@
const { readJsonFile, writeJsonFile } = require('../utils/jsonHelper');
// Hiển thị cài đặt
exports.getSettings = async (req, res) => {
try {
// Lấy cài đặt từ file content.json
const content = readJsonFile('content');
const settings = content.settings || {
siteName: 'CMS-SIMS',
description: 'Hệ thống quản lý nội dung đơn giản'
};
res.render('admin/settings', {
title: 'Cài đặt hệ thống',
settings
});
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading settings');
res.redirect('/admin/dashboard');
}
};
// Cập nhật cài đặt
exports.updateSettings = async (req, res) => {
try {
const { siteName, description } = req.body;
// Kiểm tra dữ liệu
if (!siteName) {
req.flash('error_msg', 'Website name cannot be empty');
return res.redirect('/admin/settings');
}
// Lấy dữ liệu hiện tại
const content = readJsonFile('content');
// Cập nhật thông tin
content.settings = {
...content.settings,
siteName,
description,
updatedAt: new Date().toISOString()
};
// Lưu lại dữ liệu
writeJsonFile('content', content);
req.flash('success_msg', 'Settings updated successfully');
res.redirect('/admin/settings');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error updating settings');
res.redirect('/admin/settings');
}
};

View File

@@ -1,321 +0,0 @@
const Header = require("../models/header");
// Get all social links
exports.index = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
res.json({
success: true,
data: header.top?.socialLinks || [],
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Get single social link by platform
exports.show = async (req, res) => {
try {
const { platform } = req.params;
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
const socialLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (!socialLink) {
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
res.json({
success: true,
data: socialLink,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Create social link
exports.store = async (req, res) => {
try {
let { platform, url, icon } = req.body;
// Convert platform to lowercase
platform = platform.toLowerCase().trim();
url = url.trim();
icon = icon ? icon.trim() : null;
console.log("Creating social link:", { platform, url, icon });
// Validate required fields
if (!platform || !url) {
console.log("Validation failed: platform or url missing");
return res.status(400).json({
success: false,
message: "Platform and URL are required",
});
}
// Validate platform is in enum
const validPlatforms = ["linkedin", "twitter", "instagram", "youtube", "facebook"];
if (!validPlatforms.includes(platform)) {
console.log("Invalid platform:", platform);
return res.status(400).json({
success: false,
message: `Invalid platform. Must be one of: ${validPlatforms.join(", ")}`,
});
}
// Find header
let header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
console.log("No active header found");
return res.status(404).json({
success: false,
message: "No active header found",
});
}
console.log("Found header:", header._id);
// Check if platform already exists
const existingLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (existingLink) {
console.log("Platform already exists:", platform);
return res.status(400).json({
success: false,
message: `Social link for ${platform} already exists`,
});
}
// Add new social link
if (!header.top) {
header.top = {};
}
if (!header.top.socialLinks) {
header.top.socialLinks = [];
}
// Calculate next order number
const maxOrder =
header.top.socialLinks.length > 0 ? Math.max(...header.top.socialLinks.map((link) => link.order || 0)) : 0;
header.top.socialLinks.push({
platform,
url,
icon: icon || `fa-brands fa-${platform}`,
order: maxOrder + 1,
});
console.log("Saving header with new social link");
await header.save();
console.log("Social link created successfully");
res.status(201).json({
success: true,
message: "Social link created successfully",
data: header.top.socialLinks[header.top.socialLinks.length - 1],
});
} catch (error) {
console.error("Error creating social link:", error);
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Update social link
exports.update = async (req, res) => {
try {
let { platform } = req.params;
let { url, icon } = req.body;
// Convert to lowercase
platform = platform.toLowerCase().trim();
url = url.trim();
icon = icon ? icon.trim() : null;
// Validate required fields
if (!url) {
return res.status(400).json({
success: false,
message: "URL is required",
});
}
// Find header
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Find and update social link
const socialLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (!socialLink) {
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
socialLink.url = url;
if (icon) {
socialLink.icon = icon;
}
await header.save();
res.json({
success: true,
message: "Social link updated successfully",
data: socialLink,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Delete social link
exports.destroy = async (req, res) => {
try {
let { platform } = req.params;
// Convert to lowercase
platform = platform.toLowerCase().trim();
console.log("Deleting social link:", platform);
// Find header
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
console.log("No active header found");
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Find and remove social link
const index = header.top?.socialLinks?.findIndex((link) => link.platform === platform);
if (index === -1 || index === undefined) {
console.log("Social link not found:", platform);
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
const deletedLink = header.top.socialLinks.splice(index, 1);
console.log("Saving header after delete");
await header.save();
console.log("Social link deleted successfully");
res.json({
success: true,
message: "Social link deleted successfully",
data: deletedLink[0],
});
} catch (error) {
console.error("Error deleting social link:", error);
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Bulk update social links (used for reordering and batch updates)
exports.reorder = async (req, res) => {
try {
const { socialLinks } = req.body;
if (!Array.isArray(socialLinks)) {
return res.status(400).json({
success: false,
message: "socialLinks must be an array",
});
}
// Find header
let header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Validate all social links
for (const link of socialLinks) {
if (!link.platform || !link.url) {
return res.status(400).json({
success: false,
message: "Each social link must have platform and url",
});
}
}
// Update social links with order field
if (!header.top) {
header.top = {};
}
header.top.socialLinks = socialLinks.map((link, index) => ({
platform: link.platform,
url: link.url,
icon: link.icon || `fa-brands fa-${link.platform}`,
order: link.order || index + 1, // Use provided order or calculate from index
}));
await header.save();
res.json({
success: true,
message: "Social links updated successfully",
data: header.top.socialLinks,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};

View File

@@ -1,574 +0,0 @@
// controllers/termsController.js
const Terms = require("../models/terms");
const { addBaseUrlToImages } = require("../utils/imageHelper"); // Import helper
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// API để lấy terms data (cho frontend)
exports.api = async (req, res) => {
try {
const language = req.query.lang || "en";
// Sử dụng getDefault để đảm bảo luôn có data
const terms = await Terms.getDefault(language);
// Trả về data với cấu trúc mới
const termsData = terms.toObject();
// Sử dụng helper để thêm base URL vào đường dẫn ảnh
// Truyền baseUrl từ request hoặc từ environment
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: {
hero: processedData.hero,
page: processedData.page,
content: processedData.content,
},
});
} catch (error) {
console.error("API Error:", error);
res.status(500).json({
success: false,
error: "Error loading terms data",
message: error.message,
});
}
};
// API để lấy toàn bộ terms data (cho admin)
exports.getTermsData = async (req, res) => {
try {
const language = req.query.lang || "en";
const terms = await Terms.findOne({ name: "default", language: language });
if (!terms) {
return res.status(404).json({
success: false,
error: "Terms data not found",
});
}
const termsData = terms.toObject();
// Thêm base URL vào đường dẫn ảnh
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: processedData,
});
} catch (error) {
console.error("Error getting terms data:", error);
res.status(500).json({
success: false,
error: "Error loading terms data",
});
}
};
// API để lấy data theo ngôn ngữ
exports.getByLanguage = async (req, res) => {
try {
const language = req.params.lang || "en";
const terms = await Terms.findOne({ name: "default", language: language });
if (!terms) {
return res.status(404).json({
success: false,
error: "Terms data not found for language: " + language,
});
}
const termsData = terms.toObject();
// Thêm base URL vào đường dẫn ảnh
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: {
hero: processedData.hero,
page: processedData.page,
content: processedData.content,
},
});
} catch (error) {
console.error("Error getting terms by language:", error);
res.status(500).json({
success: false,
error: "Error loading terms data",
});
}
};
// Render admin view (không cần thêm baseUrl ở đây vì dùng trong CMS)
exports.index = async (req, res) => {
try {
// Luôn đảm bảo có default data
const terms = await Terms.getDefault("en");
const data = terms.toObject();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/terms/index", {
title: "Terms & Conditions Management",
layout: "layouts/main",
data, // Không cần addBaseUrlToImages cho admin view
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in terms index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu terms (CẬP NHẬT CẤU TRÚC MỚI)
exports.update = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
return data;
};
// Parse all data với cấu trúc mới
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
const contentData = parseJson(content) || {};
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
function extractYouTubeId(url) {
if (!url || typeof url !== "string") return null;
// common YouTube URL patterns
const m = url.match(
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/,
);
return m ? m[1] : null;
}
// Trong exports.update
if (contentData && Array.isArray(contentData.content)) {
contentData.content = contentData.content.map((item) => {
if (item && item.type === "embed") {
let embedUrl = item.embed || item.url || item.source || "";
// Luôn chuyển đổi sang embed URL nếu là watch URL
if (embedUrl.includes("youtube.com/watch")) {
const videoId = extractYouTubeId(embedUrl);
if (videoId) {
item.embed = `https://www.youtube.com/embed/${videoId}`;
item.videoId = videoId;
}
}
// Đảm bảo có videoId
else if (embedUrl && !item.videoId) {
const videoId = extractYouTubeId(embedUrl);
if (videoId) {
item.videoId = videoId;
}
}
}
return item;
});
}
// Tìm hoặc tạo terms
let terms = await Terms.findOne({ name: "default", language: "en" });
// ✅ Capture BEFORE state
const beforeData = terms
? JSON.parse(JSON.stringify(terms.toObject ? terms.toObject() : terms))
: {};
if (!terms) {
// Tạo mới với cấu trúc mới
terms = new Terms({
name: "default",
language: "en",
hero: heroData,
page: pageData,
content: contentData,
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false,
});
} else {
// Update existing với cấu trúc mới
terms.hero = heroData;
terms.page = pageData;
terms.content = contentData;
terms.version = "2.0.0";
terms.migratedFromOldStructure = false;
terms.updatedAt = new Date();
}
await terms.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(terms.toObject ? terms.toObject() : terms),
);
// ✅ AUDIT LOGGING - Terms Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Terms",
documentId: terms._id,
action: AUDIT_ACTIONS.UPDATE_TERMS,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Terms & Conditions updated successfully");
res.redirect("/admin/terms-conditions");
} catch (err) {
console.error("Error updating terms:", err);
req.flash("error_msg", err.message || "Error updating terms");
res.redirect("/admin/terms-conditions");
}
};
// Seed data từ JSON file mới (cấu trúc mới)
exports.seed = async (req, res) => {
try {
const fs = require("fs").promises;
const path = require("path");
// Đọc file JSON
const jsonPath = path.join(__dirname, "../data/terms-conditions.json");
const jsonData = JSON.parse(await fs.readFile(jsonPath, "utf8"));
console.log("Seeding from JSON...");
console.log("JSON structure keys:", Object.keys(jsonData));
// Kiểm tra cấu trúc JSON
let terms;
if (jsonData.hero && jsonData.page && jsonData.content) {
// Cấu trúc mới
console.log("Using new structure (hero, page, content)");
terms = await Terms.migrateFromNewJson(jsonData, "en");
} else if (jsonData.hero && jsonData.termsHeader && jsonData.sections) {
// Cấu trúc cũ
console.log("Using old structure, converting to new...");
terms = await Terms.migrateFromJson(jsonData, "en");
} else {
throw new Error("Unknown JSON structure");
}
res.json({
success: true,
message: "Terms data seeded successfully",
data: {
id: terms._id,
hero: terms.hero,
page: terms.page,
content: terms.content,
},
});
} catch (error) {
console.error("Error seeding terms:", error);
res.status(500).json({
success: false,
error: error.message || "Error seeding terms data",
});
}
};
// API preview cho admin (tạo HTML preview)
exports.preview = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
return data;
};
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
const contentData = parseJson(content) || {};
// Thêm base URL vào đường dẫn ảnh cho preview
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
// Render preview HTML
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${pageData.title || "Terms & Conditions Preview"}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { font-family: Arial, sans-serif; }
.hero-section {
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
url('${processedHeroData.backgroundImage || ""}');
background-size: cover;
background-position: center;
color: white;
padding: 100px 20px;
text-align: center;
}
.page-header {
padding: 40px 20px;
background: #f8f9fa;
}
.content-section {
padding: 40px 20px;
}
.content-item {
margin-bottom: 20px;
}
</style>
</head>
<body>
<!-- Hero Section -->
<div class="hero-section">
<h1>${heroData.title || "Terms & Conditions"}</h1>
</div>
<!-- Page Header -->
<div class="page-header">
<div class="container">
<h2>${pageData.title || "Terms & Conditions"}</h2>
${pageData.divider !== false ? "<hr>" : ""}
</div>
</div>
<!-- Content Section -->
<div class="content-section">
<div class="container">
${renderContentItems(contentData.content || [])}
</div>
</div>
</body>
</html>
`;
res.send(html);
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
}
};
// Helper function để render content items
function renderContentItems(contentItems) {
if (!Array.isArray(contentItems) || contentItems.length === 0) {
return "<p>No content available.</p>";
}
return contentItems
.map((item) => {
switch (item.type) {
case "paragraph":
return `<div class="content-item"><p>${item.text || ""}</p></div>`;
case "section":
let html = `<div class="content-item">`;
html += `<h3>${item.title || ""}</h3>`;
html += `<p>${item.content || ""}</p>`;
if (item.subsections && item.subsections.length > 0) {
item.subsections.forEach((subsection) => {
if (subsection.type === "cancellation_table") {
html += `<h4>${subsection.title || ""}</h4>`;
if (subsection.items && subsection.items.length > 0) {
html += "<ul>";
subsection.items.forEach((listItem) => {
html += `<li>${listItem}</li>`;
});
html += "</ul>";
}
} else if (subsection.type === "cancellation_section") {
html += `<h4>${subsection.title || ""}</h4>`;
if (subsection.items && subsection.items.length > 0) {
html += "<ul>";
subsection.items.forEach((listItem) => {
html += `<li>${listItem}</li>`;
});
html += "</ul>";
}
} else if (subsection.type === "note") {
html += `<div class="alert alert-info">${subsection.text || ""}</div>`;
}
});
}
html += `</div>`;
return html;
case "note":
return `<div class="content-item alert alert-info">${item.text || ""}</div>`;
case "embed":
// Support several embed shapes: { embed }, { url }, { source }, { videoId }
const embedSrc =
item.embed ||
item.url ||
item.source ||
(item.videoId
? `https://www.youtube.com/embed/${item.videoId}`
: "");
if (!embedSrc) return `<div class="content-item">Invalid embed</div>`;
return `<div class="content-item embed-item" style="margin-bottom:20px;">
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;">
<iframe src="${embedSrc}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen loading="lazy" referrerpolicy="no-referrer"></iframe>
</div>
</div>`;
default:
return `<div class="content-item">Unknown content type: ${item.type}</div>`;
}
})
.join("");
}
// API để tạo terms mới (cho các ngôn ngữ khác)
exports.create = async (req, res) => {
try {
const { hero, page, content, language } = req.body;
if (!language) {
return res.status(400).json({
success: false,
error: "Language is required",
});
}
// Kiểm tra đã tồn tại chưa
const existing = await Terms.findOne({
name: "default",
language: language,
});
if (existing) {
return res.status(400).json({
success: false,
error: "Terms already exists for language: " + language,
});
}
// Parse JSON nếu cần
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
return data;
};
const terms = new Terms({
name: "default",
language: language,
hero: parseJson(hero) || {},
page: parseJson(page) || {},
content: parseJson(content) || {},
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false,
});
await terms.save();
res.json({
success: true,
message: "Terms created successfully for language: " + language,
data: terms,
});
} catch (error) {
console.error("Error creating terms:", error);
res.status(500).json({
success: false,
error: error.message || "Error creating terms",
});
}
};
// API để xóa terms (theo ngôn ngữ)
exports.delete = async (req, res) => {
try {
const language = req.params.lang;
if (!language) {
return res.status(400).json({
success: false,
error: "Language is required",
});
}
// Không cho phép xóa tiếng Anh mặc định
if (language === "en") {
return res.status(400).json({
success: false,
error: "Cannot delete default English terms",
});
}
const result = await Terms.deleteOne({
name: "default",
language: language,
});
if (result.deletedCount === 0) {
return res.status(404).json({
success: false,
error: "Terms not found for language: " + language,
});
}
res.json({
success: true,
message: "Terms deleted successfully for language: " + language,
});
} catch (error) {
console.error("Error deleting terms:", error);
res.status(500).json({
success: false,
error: error.message || "Error deleting terms",
});
}
};

View File

@@ -1,138 +0,0 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Home = require("../models/home");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Get testimonial data from Home model
const getTestimonialData = async () => {
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.testimonials) {
return null;
}
return home.testimonials.toObject
? home.testimonials.toObject()
: home.testimonials;
};
// API to get testimonial data
exports.api = async (req, res) => {
try {
const testimonial = await getTestimonialData();
if (!testimonial) {
return res.status(404).json({ error: "Testimonial data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(testimonial, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading testimonial data" });
}
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = (await getTestimonialData()) || {
heading: "Student Reviews & Testimonials",
subheading: "What Our Students Say",
videoUrl: "",
videoThumbnail: "",
items: [],
};
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/home/testimonial/index", {
title: "Testimonials Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in testimonial index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu testimonial (chỉ update phần testimonials của Home)
exports.update = async (req, res) => {
try {
const { heading, subheading, videoUrl, videoThumbnail, items } = req.body;
// Parse JSON strings nếu cần
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
const itemsData = parseJson(items);
// Tìm hoặc tạo Home document
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
}
// ✅ Capture BEFORE state
const beforeData = home.testimonials
? JSON.parse(
JSON.stringify(
home.testimonials.toObject
? home.testimonials.toObject()
: home.testimonials,
),
)
: {};
const updatedTestimonialData = {
heading: heading || "Student Reviews & Testimonials",
subheading: subheading || "What Our Students Say",
videoUrl: videoUrl || "",
videoThumbnail: videoThumbnail || "",
items: itemsData || [],
};
// Cập nhật chỉ phần testimonials
home.testimonials = updatedTestimonialData;
await home.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedTestimonialData));
// ✅ AUDIT LOGGING - Testimonial Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: home._id,
action: AUDIT_ACTIONS.UPDATE_TESTIMONIAL,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Testimonials updated successfully");
res.redirect("/admin/home/testimonials");
} catch (err) {
console.error("Error updating testimonials:", err);
req.flash("error_msg", err.message || "Error updating testimonials");
res.redirect("/admin/home/testimonials");
}
};

View File

@@ -1,290 +0,0 @@
const Travel = require("../models/travel");
const { addBaseUrlToImages } = require("../utils/imageHelper");
const fs = require("fs").promises;
const path = require("path");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/**
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
*/
function extractYouTubeId(url) {
if (!url || typeof url !== "string") return null;
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
const regex =
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
}
/**
* Hàm Helper: Làm sạch danh sách blocks của Editor.js
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
*/
function sanitizeContentBlocks(blocks) {
if (!blocks || !Array.isArray(blocks)) return [];
const seenVideoIds = new Set();
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
const processedBlocks = blocks.map((block) => {
if (block.type === "embed") {
const url = block.data.source || block.data.embed || "";
const videoId = extractYouTubeId(url);
if (videoId) {
seenVideoIds.add(videoId);
// Cập nhật lại data chuẩn cho Editor.js
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
block.data.source = url;
block.data.videoId = videoId;
block.data.service = "youtube";
}
}
return block;
});
// Bước 2: Lọc bỏ paragraph rác
return processedBlocks.filter((block) => {
if (block.type === "paragraph") {
const text = (block.data?.text || "").trim();
// Xóa paragraph rỗng
if (text === "" || text === "<br>" || text === "&nbsp;") return false;
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
const videoIdInText = extractYouTubeId(text);
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
console.log(
`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`,
);
return false;
}
}
return true;
});
}
// GET: Show travel editor
exports.index = async (req, res) => {
try {
const travel = await Travel.findOne();
if (!travel) {
return res.render("admin/travel/index", {
title: "Travel Management",
data: {
page: {
title: "Travel Information",
description: "",
metadata: { title: "", description: "" },
},
hero: { title: "Travel Information", backgroundImage: "" },
content: { blocks: [] },
enableScrollspy: false,
},
message: "No travel data found. Please run migration first.",
});
}
res.render("admin/travel/index", {
title: "Travel Management",
data: travel,
});
} catch (error) {
console.error("Error loading travel page:", error);
res.status(500).send("Error loading travel page");
}
};
// POST: Update travel information
exports.update = async (req, res) => {
try {
const { page, hero, content, enableScrollspy } = req.body;
// Get current data for before state
const currentTravel = await Travel.findOne();
// ✅ Capture BEFORE state
const beforeData = currentTravel
? JSON.parse(
JSON.stringify(
currentTravel.toObject ? currentTravel.toObject() : currentTravel,
),
)
: {};
const updateData = {};
if (page) updateData.page = JSON.parse(page);
if (hero) updateData.hero = JSON.parse(hero);
if (content) {
let contentObj = JSON.parse(content);
// Áp dụng bộ lọc dọn dẹp nội dung
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
updateData.content = contentObj;
}
if (enableScrollspy !== undefined) {
updateData.enableScrollspy =
enableScrollspy === "true" || enableScrollspy === true;
}
const updatedTravel = await Travel.findOneAndUpdate({}, updateData, {
upsert: true,
new: true,
});
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(
updatedTravel.toObject ? updatedTravel.toObject() : updatedTravel,
),
);
// ✅ AUDIT LOGGING - Travel Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Travel",
documentId: updatedTravel._id,
action: AUDIT_ACTIONS.UPDATE_TRAVEL,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash(
"success",
"Travel information updated and sanitized successfully",
);
res.redirect("/admin/travel");
} catch (error) {
console.error("Error updating travel:", error);
req.flash("error", "Error updating travel information");
res.redirect("/admin/travel");
}
};
// GET: Travel data API (Sử dụng cho Frontend/Public)
exports.api = exports.getTravelData = async (req, res) => {
try {
const travel = await Travel.findOne();
if (!travel) {
return res.status(404).json({ error: "Travel data not found" });
}
const travelObj = travel.toObject();
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processed = addBaseUrlToImages(travelObj, baseUrl);
return res.json({
success: true,
data: {
hero: processed.hero,
page: processed.page,
content: processed.content,
enableScrollspy: processed.enableScrollspy,
},
});
} catch (error) {
console.error("Error fetching travel data:", error);
res.status(500).json({ error: "Internal server error" });
}
};
// POST: Preview travel
exports.preview = async (req, res) => {
try {
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } =
req.body;
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
let contentObj = JSON.parse(content);
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
const previewData = {
page: {
title: pageTitle || "Travel Information",
year: pageYear || "",
},
hero: {
title: heroTitle || "Travel Information",
backgroundImage: heroBackgroundImage || "",
},
content: contentObj,
enableScrollspy: false,
};
res.render("page/travel", {
title: "Travel Preview",
data: previewData,
});
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
}
};
// GET: Seed/Import from JSON
exports.seed = async (req, res) => {
try {
const jsonPath = path.join(__dirname, "../data/travel.json");
const jsonData = await fs.readFile(jsonPath, "utf-8");
const jsonTravelData = JSON.parse(jsonData);
let contentBlocks = [];
// Trường hợp JSON đã có định dạng bài viết (blog format)
if (
Array.isArray(jsonTravelData.posts) &&
jsonTravelData.posts.length > 0
) {
const firstPost = jsonTravelData.posts[0];
contentBlocks =
firstPost.content && firstPost.content.blocks
? firstPost.content.blocks
: [];
}
// Trường hợp format cũ (legacy)
else {
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
}
// Luôn làm sạch dữ liệu trước khi seed vào DB
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
const travelData = {
page: {
title:
jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
year: jsonTravelData.page?.year || "",
metadata: {
title: "Travel Guide - Go and Grow Camp",
description:
"Everything you need to know about traveling to our camps",
},
},
hero: {
title: jsonTravelData.hero?.title || "Travel Information",
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
},
content: { blocks: cleanedBlocks },
enableScrollspy: true,
};
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
req.flash("success", "Travel data seeded and sanitized successfully");
res.redirect("/admin/travel");
} catch (error) {
console.error("Error seeding travel data:", error);
req.flash("error", "Failed to seed travel data");
res.redirect("/admin/travel");
}
};

View File

@@ -1,228 +0,0 @@
const path = require('path');
const fs = require('fs');
const jsonHelper = require('../utils/jsonHelper');
// Controller xử lý upload ảnh
const uploadController = {
// Upload ảnh và trả về đường dẫn
uploadImage: async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: 'No file was uploaded' });
}
// Lấy loại ảnh từ query params
const imageType = req.query.imageType || 'general';
// Tạo đường dẫn tương đối để lưu vào database
const relativePath = `/uploads/${imageType}/${req.file.filename}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const fullUrl = `${baseUrl}${relativePath}`;
// Kiểm tra nếu file đã tồn tại từ trước
const fileAlreadyExists = req.fileAlreadyExists || false;
// Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json),
// thì đồng bộ camps.image và camps.camp-detail.hero.bgImage
try {
const jsonFile = req.body && req.body.jsonFile;
const campLink = req.body && req.body.campLink;
if (jsonFile && campLink) {
// Đọc JSON và cập nhật camp có link khớp
const jsonFilePath = require('path').join(__dirname, '../data', jsonFile);
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
if (jsonData && Array.isArray(jsonData.camps)) {
// campLink có thể được gửi không có dấu / đầu, chuẩn hóa
const normalizedLink = campLink.startsWith('/') ? campLink : `/${campLink}`;
const camp = jsonData.camps.find(c => (c.link || '').toString() === normalizedLink || (c.link || '').toString() === campLink);
if (camp) {
// Đồng bộ camps.image và camps.camp-detail.hero.bgImage - 2 trường này luôn giống nhau
camp.image = relativePath;
// Đảm bảo camp-detail.hero tồn tại và sync bgImage
if (!camp['camp-detail']) camp['camp-detail'] = {};
if (!camp['camp-detail'].hero) camp['camp-detail'].hero = {};
camp['camp-detail'].hero.bgImage = relativePath;
// Lưu thay đổi
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
}
}
}
} catch (e) {
console.warn('Failed to update JSON file after upload:', e && e.message ? e.message : e);
}
return res.status(200).json({
success: true,
path: relativePath,
url: fullUrl,
reused: fileAlreadyExists,
message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully'
});
} catch (error) {
console.error('Error uploading image:', error);
return res.status(500).json({ success: false, error: 'Server error while uploading image' });
}
},
// Cập nhật đường dẫn ảnh trong file JSON
updateImagePath: async (req, res) => {
try {
const { jsonFile, jsonPath, newImagePath } = req.body;
if (!jsonFile || !jsonPath || !newImagePath) {
return res.status(400).json({
success: false,
message: 'Missing required information (jsonFile, jsonPath, newImagePath)'
});
}
// Đọc file JSON
const jsonFilePath = path.join(__dirname, '../data', jsonFile);
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
// Cập nhật đường dẫn ảnh theo jsonPath
// jsonPath có định dạng như "banner.image" hoặc "partners[0].logo"
const pathParts = jsonPath.split('.');
let current = jsonData;
// Duyệt qua các phần của path trừ phần cuối
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
// Kiểm tra nếu là mảng (ví dụ: partners[0])
if (part.includes('[') && part.includes(']')) {
const arrName = part.substring(0, part.indexOf('['));
const index = parseInt(part.substring(part.indexOf('[') + 1, part.indexOf(']')));
if (!current[arrName] || !Array.isArray(current[arrName])) {
return res.status(400).json({
success: false,
message: `Array ${arrName} not found in data`
});
}
current = current[arrName][index];
} else {
if (!current[part]) {
return res.status(400).json({
success: false,
message: `Property ${part} not found in data`
});
}
current = current[part];
}
}
// Cập nhật giá trị
const lastPart = pathParts[pathParts.length - 1];
current[lastPart] = newImagePath;
// Lưu lại file JSON
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
return res.status(200).json({
success: true,
message: 'Image path updated successfully',
data: { jsonPath, newImagePath }
});
} catch (error) {
console.error('Error updating image path:', error);
return res.status(500).json({ success: false, message: 'Server error while updating image path' });
}
},
// Xóa ảnh
deleteImage: async (req, res) => {
try {
const { imagePath } = req.body;
if (!imagePath) {
return res.status(400).json({ success: false, message: 'Missing image path to delete' });
}
// Chuyển đổi đường dẫn tương đối thành đường dẫn tuyệt đối
const fullPath = path.join(__dirname, '../public', imagePath);
// Kiểm tra xem file có tồn tại không
if (!fs.existsSync(fullPath)) {
return res.status(404).json({ success: false, message: 'Image file not found' });
}
// Xóa file
fs.unlinkSync(fullPath);
return res.status(200).json({
success: true,
message: 'Image deleted successfully',
data: { imagePath }
});
} catch (error) {
console.error('Error deleting image:', error);
return res.status(500).json({ success: false, message: 'Server error while deleting image' });
}
},
// List images in a folder
listImages: async (req, res) => {
try {
const imageType = req.query.imageType || 'general';
const dirPath = path.join(__dirname, '../public/uploads', imageType);
if (!fs.existsSync(dirPath)) {
return res.status(200).json({ success: true, images: [] });
}
const files = fs.readdirSync(dirPath).filter(f => !f.startsWith('.'));
const images = files.map(name => ({
name,
path: `/uploads/${imageType}/${name}`,
url: (process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`) + `/uploads/${imageType}/${name}`
}));
return res.status(200).json({ success: true, images });
} catch (error) {
console.error('Error listing images:', error);
return res.status(500).json({ success: false, error: 'Server error while listing images' });
}
},
// Upload video
uploadVideo: async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: 'No file was uploaded' });
}
// Kiểm tra loại file
const fileType = req.file.mimetype;
if (!fileType.startsWith('video/')) {
// Xóa file nếu không phải video
fs.unlinkSync(req.file.path);
return res.status(400).json({ success: false, error: 'Uploaded file is not a video' });
}
// Tạo đường dẫn tương đối để lưu vào database
const relativePath = `/uploads/videos/${req.file.filename}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const fullUrl = `${baseUrl}${relativePath}`;
return res.status(200).json({
success: true,
path: relativePath,
url: fullUrl
});
} catch (error) {
console.error('Error uploading video:', error);
return res.status(500).json({ success: false, error: 'Server error while uploading video' });
}
}
};
module.exports = uploadController;

View File

@@ -1,119 +0,0 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Home = require("../models/home");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Get videoGallery data from Home model
const getVideoGalleryData = async () => {
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.videoGallery) {
return null;
}
return home.videoGallery.toObject
? home.videoGallery.toObject()
: home.videoGallery;
};
// API to get videoGallery data
exports.api = async (req, res) => {
try {
const videoGallery = await getVideoGalleryData();
if (!videoGallery) {
return res.status(404).json({ error: "Video Gallery data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(videoGallery, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading video gallery data" });
}
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = (await getVideoGalleryData()) || {
heading: "",
videoUrl: "",
thumbnail: "",
};
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/home/videoGallery/index", {
title: "Video Gallery Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in videoGallery index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu videoGallery
exports.update = async (req, res) => {
try {
const { heading, videoUrl, thumbnail } = req.body;
// Tìm hoặc tạo Home document
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
}
// ✅ Capture BEFORE state
const beforeData = home.videoGallery
? JSON.parse(
JSON.stringify(
home.videoGallery.toObject
? home.videoGallery.toObject()
: home.videoGallery,
),
)
: {};
const updatedVideoGalleryData = {
heading: heading || "",
videoUrl: videoUrl || "",
thumbnail: thumbnail || "",
};
// Cập nhật chỉ phần videoGallery
home.videoGallery = updatedVideoGalleryData;
await home.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedVideoGalleryData));
// ✅ AUDIT LOGGING - Video Gallery Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: home._id,
action: AUDIT_ACTIONS.UPDATE_VIDEO_GALLERY,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Video Gallery updated successfully");
res.redirect("/admin/home/video-gallery");
} catch (err) {
console.error("Error updating video gallery:", err);
req.flash("error_msg", err.message || "Error updating video gallery");
res.redirect("/admin/home/video-gallery");
}
};

View File

@@ -1,695 +0,0 @@
// controllers/visaController.js
const addBaseUrlToImages = (data, baseUrl) => {
if (!data) return data;
// Nếu là mảng, duyệt từng phần tử
if (Array.isArray(data)) {
return data.map((item) => addBaseUrlToImages(item, baseUrl));
}
// Nếu là object, duyệt từng key
if (typeof data === "object") {
const newObj = {};
for (const [key, value] of Object.entries(data)) {
// Kiểm tra nếu key là các trường chứa ảnh và value là string
const imageKeys = ["icon", "mainImage", "bannerImage", "image"];
if (
imageKeys.includes(key) &&
typeof value === "string" &&
!value.startsWith("http")
) {
newObj[key] = `${baseUrl}/${value}`
.replace(/\/+/g, "/")
.replace(":/", "://");
}
// Xử lý riêng cho mảng gallery (mảng các chuỗi)
else if (key === "gallery" && Array.isArray(value)) {
newObj[key] = value.map((img) =>
img.startsWith("http")
? img
: `${baseUrl}/${img}`.replace(/\/+/g, "/").replace(":/", "://"),
);
}
// Nếu là object hoặc mảng con khác, đệ quy tiếp
else if (typeof value === "object" && value !== null) {
newObj[key] = addBaseUrlToImages(value, baseUrl);
} else {
newObj[key] = value;
}
}
return newObj;
}
return data;
};
const Visa = require("../models/visa");
const slugify = require("slugify");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const createSlug = (text) => {
return slugify(text, {
lower: true,
strict: true,
locale: "en",
trim: true,
});
};
// -------------------- Helper Functions --------------------
// Get visa data from MongoDB
const getVisaData = async () => {
const visa = await Visa.findOne().sort({ updatedAt: -1 });
return visa || {};
};
// Get default visa data structure (updated to match new JSON)
const getDefaultVisaData = () => ({
hero: {
title: "Visa Service",
summaryList: [],
},
});
// Helper function: Generate next country ID
const getNextCountryId = (countries) => {
if (!Array.isArray(countries) || countries.length === 0) return 1;
return Math.max(...countries.map((c) => c.id || 0)) + 1;
};
// -------------------- Admin Exports --------------------
// Display visa management page
exports.index = async (req, res) => {
try {
// Fetch Visa data
let data = await getVisaData();
// If no data exists, use default
if (!data || Object.keys(data).length === 0) {
data = getDefaultVisaData();
} else {
// Merge with defaults to ensure all fields exist
const defaultData = getDefaultVisaData();
// Ensure hero section exists with defaults
data.hero = data.hero || defaultData.hero;
data.hero.title = data.hero.title || "Visa Service";
data.hero.summaryList = data.hero.summaryList || [];
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/visa/index", {
layout: "layouts/main",
title: "Visa Management",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
// return res.json(data);
} catch (err) {
console.error("Visa index error:", err);
req.flash("error_msg", "Error loading visa data");
res.redirect("/admin/dashboard");
}
};
// Get single country for edit
exports.getCountry = async (req, res) => {
console.log("--------------------------------------------------");
console.log("🚀 [GET] Request nhận được tại /visa/edit/:id");
try {
const { id } = req.params;
console.log("📍 ID từ Params (URL):", id, "| Kiểu dữ liệu:", typeof id);
const visaData = await getVisaData();
// Kiểm tra cấu trúc dữ liệu tổng
if (!visaData) {
console.error("❌ Lỗi: Hàm getVisaData() trả về null/undefined");
return res.status(404).json({ error: "Dữ liệu gốc không tồn tại" });
}
if (!visaData.hero || !visaData.hero.summaryList) {
console.error("❌ Lỗi: Cấu trúc visaData.hero.summaryList không hợp lệ");
return res
.status(404)
.json({ error: "Không tìm thấy danh sách quốc gia" });
}
console.log(
"📊 Tổng số quốc gia hiện có trong mảng:",
visaData.hero.summaryList.length,
);
// 2. Tìm quốc gia theo ID
const targetId = parseInt(id);
console.log("🔍 Đang tìm kiếm Quốc gia có ID (sau khi parse):", targetId);
const country = visaData.hero.summaryList.find((c) => {
// Log từng phần tử để kiểm tra kiểu dữ liệu của c.id trong DB
// console.log(`Checking country: ${c.name} | ID in DB: ${c.id} (${typeof c.id})`);
return c.id === targetId;
});
if (!country) {
console.warn(`⚠️ Không tìm thấy quốc gia nào khớp với ID: ${targetId}`);
// In ra danh sách ID hiện có để so sánh
const existingIds = visaData.hero.summaryList.map((c) => c.id);
console.log("🆔 Các ID hiện có trong Database:", existingIds);
return res.status(404).json({
success: false,
error: `Không tìm thấy quốc gia có ID: ${id}`,
});
}
console.log("✅ Tìm thấy dữ liệu quốc gia:", country.name);
// 3. Trả về dữ liệu
res.json({
success: true,
country: country,
});
} catch (err) {
console.error("🔴 Lỗi nghiêm trọng tại getCountry Controller:", err);
res.status(500).json({ error: "Lỗi hệ thống khi tải thông tin quốc gia" });
}
};
// Update visa data (hero title only)
exports.update = async (req, res) => {
try {
// Get current data
const currentData = await getVisaData();
// ✅ Capture BEFORE state
const beforeData = currentData
? JSON.parse(
JSON.stringify(
currentData.toObject ? currentData.toObject() : currentData,
),
)
: {};
// Create updated data object
const updatedData = {
...(currentData.toObject ? currentData.toObject() : currentData),
};
// Ensure hero structure exists
updatedData.hero = updatedData.hero || {
title: "Visa Service",
summaryList: [],
};
// Update hero title
if (req.body.heroTitle) {
updatedData.hero.title = req.body.heroTitle;
}
// Update or create document
try {
let savedData;
if (currentData._id) {
savedData = await Visa.findByIdAndUpdate(currentData._id, updatedData, {
new: true,
});
} else {
savedData = await Visa.create(updatedData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(savedData.toObject()));
// ✅ AUDIT LOGGING - Visa Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa update: ${changes.length} changes`,
);
} else {
console.log(" No changes detected for Visa update");
}
req.flash("success_msg", "Visa data updated successfully");
return req.session.save(() => res.redirect("/admin/visa"));
} catch (dbError) {
console.error("Database error:", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
};
// Add new country
exports.addCountry = async (req, res) => {
try {
let visaData = await getVisaData();
// Initialize hero structure if not exist
if (!visaData.hero || !visaData.hero.summaryList) {
visaData = getDefaultVisaData();
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// Validate required fields
if (!req.body.name) {
return res.status(400).json({ error: "Name is required" });
}
const finalSlug = req.body.slug
? createSlug(req.body.slug)
: createSlug(req.body.name);
// Parse services array
let services = [];
if (req.body.services) {
if (typeof req.body.services === "string") {
try {
services = JSON.parse(req.body.services);
} catch (e) {
services = [req.body.services];
}
} else if (Array.isArray(req.body.services)) {
services = req.body.services;
}
}
// Parse detailedView if provided (optional)
let detailedView = null;
if (req.body.detailedView) {
try {
detailedView =
typeof req.body.detailedView === "string"
? JSON.parse(req.body.detailedView)
: req.body.detailedView;
} catch (e) {
console.warn("Could not parse detailedView, creating without it");
}
}
// Create new country object
const newCountry = {
id: req.body.id || getNextCountryId(visaData.hero.summaryList),
name: req.body.name,
slug: finalSlug,
icon: req.body.icon || "",
services: services,
...(detailedView && { detailedView }),
};
// Add new country to summaryList
visaData.hero.summaryList.push(newCountry);
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
let savedData;
if (visaData._id) {
savedData = await Visa.findByIdAndUpdate(visaData._id, updatedData, {
new: true,
});
} else {
savedData = await Visa.create(updatedData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
);
// ✅ AUDIT LOGGING - Visa Country Added
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa country addition: ${changes.length} changes`,
);
}
console.log(`✅ Country "${newCountry.name}" added successfully`);
res.json({
success: true,
message: `Country "${newCountry.name}" added successfully`,
country: newCountry,
});
} catch (err) {
console.error("Add country error:", err);
res.status(500).json({ error: err.message });
}
};
// Update single country
exports.updateCountry = async (req, res) => {
try {
// 1. Lấy ID từ params (URL)
const { id } = req.params;
let visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res
.status(400)
.json({ error: "Cấu trúc dữ liệu Visa không hợp lệ" });
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.id === parseInt(id),
);
if (countryIndex === -1) {
return res
.status(404)
.json({ error: `Không tìm thấy quốc gia có ID: ${id}` });
}
const currentCountry = visaData.hero.summaryList[countryIndex];
let finalSlug = currentCountry.slug;
if (req.body.name) {
// Nếu name thay đổi, ta có thể cập nhật lại slug (tùy nhu cầu SEO)
// Ở đây ưu tiên: Nếu có slug mới truyền lên thì dùng, không thì tạo từ name mới
finalSlug = req.body.slug
? createSlug(req.body.slug)
: createSlug(req.body.name);
}
// 3. Xử lý dữ liệu từ req.body
// Vì Client đã gửi JSON stringify, ta lấy trực tiếp hoặc parse nếu cần
let services = req.body.services;
if (typeof services === "string") {
try {
services = JSON.parse(services);
} catch (e) {
services = [services];
}
}
let detailedView = req.body.detailedView;
if (typeof detailedView === "string") {
try {
detailedView = JSON.parse(detailedView);
} catch (e) {
detailedView = currentCountry.detailedView;
}
}
// 4. Cập nhật Object quốc gia
const updatedCountry = {
...currentCountry, // Giữ các trường cũ
id: parseInt(id), // Đảm bảo ID không đổi
name: req.body.name || currentCountry.name,
slug: finalSlug,
icon: req.body.icon || currentCountry.icon,
services: Array.isArray(services) ? services : currentCountry.services,
detailedView: detailedView || currentCountry.detailedView,
};
// 5. Cập nhật vào mảng chính
visaData.hero.summaryList[countryIndex] = updatedCountry;
// 6. Lưu vào Database
if (visaData.markModified) {
// Bắt buộc với Mongoose khi thay đổi nội dung bên trong Array/Object
visaData.markModified("hero.summaryList");
}
let savedData;
if (visaData._id) {
savedData = await visaData.save(); // Sử dụng save() trực tiếp nếu visaData là Mongoose Document
} else {
savedData = await Visa.create(visaData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
);
// ✅ AUDIT LOGGING - Visa Country Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa country update: ${changes.length} changes`,
);
}
console.log(
`✅ Country "${updatedCountry.name}" updated successfully by ID: ${id}`,
);
res.json({
success: true,
message: `Quốc gia "${updatedCountry.name}" đã được cập nhật thành công`,
country: updatedCountry,
});
} catch (err) {
console.error("Update country error:", err);
res.status(500).json({ error: err.message });
}
};
// Delete country
exports.deleteCountry = async (req, res) => {
try {
// 1. Lấy id từ params
const { id } = req.params;
let visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res
.status(400)
.json({ success: false, error: "Cấu trúc dữ liệu Visa không hợp lệ" });
}
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.id === parseInt(id),
);
if (countryIndex === -1) {
return res.status(404).json({
success: false,
error: `Không tìm thấy quốc gia có ID: ${id}`,
});
}
// 3. Xóa phần tử khỏi mảng
const deletedCountry = visaData.hero.summaryList[countryIndex];
visaData.hero.summaryList.splice(countryIndex, 1);
// 4. Cập nhật vào Database
if (visaData.markModified) {
visaData.markModified("hero.summaryList");
}
if (visaData._id) {
await visaData.save();
} else {
await Visa.create(visaData);
}
console.log(`✅ Deleted Successfully: "${deletedCountry.name}"`);
return res.json({
success: true,
message: `Country "${deletedCountry.name}" Deleted Successfully`,
});
} catch (err) {
console.error("❌ Error Delete:", err);
return res.status(500).json({ success: false, error: err.message });
}
};
// -------------------- Public API Exports --------------------
// API to get all visa data for frontend
exports.api = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
const heroData = visaData?.hero;
// 2. Lấy riêng phần hero (Dùng biến mới, không gán đè vào const)
const processedData = heroData;
return res.json({
success: true,
hero: processedData,
});
} catch (err) {
console.error("Visa API error:", err);
res.status(500).json({
success: false,
error: "Error loading visa data",
});
}
};
// API to get all countries (summaryList only)
exports.apiCountries = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Countries data not found",
data: null,
});
}
// 1. Lọc bỏ 'viewDetail' khỏi từng quốc gia trong danh sách
const filteredCountries = visaData.hero.summaryList.map((item) => {
// Tách detailedView ra, gom phần còn lại vào countryInfo
const { detailedView, ...countryInfo } = item;
return {
...countryInfo,
// Lấy mainImage từ sâu bên trong detailedView và gán vào key mới
mainImage: detailedView?.activeCountry?.mainImage || "",
};
});
// 2. Gắn baseUrl vào ảnh cho danh sách đã lọc
const processedData = filteredCountries;
return res.json({
success: true,
data: processedData, // Lúc này data chỉ chứa thông tin quốc gia, không có viewDetail
});
} catch (err) {
console.error("Countries API error:", err);
res.status(500).json({
success: false,
error: "Error loading countries data",
});
}
};
// API to get single country by slug
exports.apiCountry = async (req, res) => {
try {
const { slug } = req.params;
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
// 1. Tìm quốc gia khớp với slug
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
// 2. Kiểm tra nếu không thấy quốc gia hoặc quốc gia đó không có viewDetail
if (!country || !country.viewDetail) {
return res.status(404).json({
success: false,
error: `Detailed information for country "${slug}" not found`,
data: null,
});
}
// 3. Chỉ lấy phần chi tiết (detailed view)
// Lưu ý: Chúng ta copy ra object mới để tránh tham chiếu dữ liệu gốc
const detailedData = JSON.parse(JSON.stringify(country.viewDetail));
// 4. Gắn baseUrl vào các ảnh nằm trong phần chi tiết này
const processedData = detailedData;
return res.json({
success: true,
data: processedData,
});
} catch (err) {
console.error("Visa country API error:", err);
res.status(500).json({
success: false,
error: "Error loading country detailed data",
});
}
};
// API to get hero data (title + summaryList)
exports.apiHero = async (req, res) => {
try {
const visaData = await getVisaData();
// 1. Kiểm tra dữ liệu gốc
if (!visaData || !visaData.hero) {
return res.status(404).json({
success: false,
error: "Hero data not found",
data: null,
});
}
const { summaryList, ...heroData } = JSON.parse(
JSON.stringify(visaData.hero),
);
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(heroData, baseUrl);
return res.json({
success: true,
data: processedData,
});
} catch (err) {
console.error("Visa hero API error:", err);
res.status(500).json({
success: false,
error: "Error loading hero data",
});
}
};

View File

@@ -1,114 +0,0 @@
{
"countries": [
{
"id": 1,
"name": "France",
"icon": "assets/img/home-2/visa/03.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 2,
"name": "UK",
"icon": "assets/img/home-2/visa/11.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 3,
"name": "Canada",
"icon": "assets/img/home-2/visa/02.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 4,
"name": "Germany",
"icon": "assets/img/home-2/visa/12.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 5,
"name": "Spain",
"icon": "assets/img/home-2/visa/13.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 6,
"name": "South Korea",
"icon": "assets/img/home-2/visa/14.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 7,
"name": "Japan",
"icon": "assets/img/home-2/visa/15.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 8,
"name": "Croatia",
"icon": "assets/img/home-2/visa/16.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 9,
"name": "England",
"icon": "assets/img/home-2/visa/17.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 10,
"name": "Indonesia",
"icon": "assets/img/home-2/visa/18.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
}
]
}

View File

@@ -1,146 +0,0 @@
{
"countryDetails": {
"id": 1,
"name": "United States of America",
"title": "COUNTRY USA",
"mainImage": "assets/img/inner-page/country-details/details-1.jpg",
"description": "The United States is one of the most popular destinations for international students and immigrants, offering world-class universities, diverse cultural experiences, and countless career opportunities. With top-ranked education systems, advanced research facilities, and a welcoming environment for skilled professionals, the USA is ideal for those seeking growth and global exposure.",
"additionalInfo": "Our consultancy provides complete guidance for study visas, work permits, and permanent residency pathways tailored to your goals.",
"tagline": "Over the last 35 Years we made an impact that is strong & we have long way to go.",
"visaTypes": [
{
"category": "Tourist & Work",
"items": [
{
"title": "Tourist Visa",
"description": "Broad term that can refer to various aspects of interconnectedness"
},
{
"title": "Work Permit",
"description": "Broad term that can refer to various aspects of interconnectedness"
}
]
},
{
"category": "Student & Family",
"items": [
{
"title": "Student",
"description": "Broad term that can refer to various aspects of interconnectedness"
},
{
"title": "Tourist Visa",
"description": "Broad term that can refer to various aspects of interconnectedness"
}
]
}
],
"visaProcess": [
{
"number": "01",
"title": "Consultation & Eligibility Check",
"description": "Our experts review your profile and visa requirements."
},
{
"number": "02",
"title": "Application Preparation",
"description": "We help with document collection, form filling, and statement drafting."
},
{
"number": "03",
"title": "Submission",
"description": "Visa application is submitted online with required fees."
},
{
"number": "04",
"title": "Interview Guidance",
"description": "Get training and mock sessions for embassy interview."
},
{
"number": "05",
"title": "Approval & Travel",
"description": "Once approved, we provide travel and pre-departure guidance."
}
],
"images": [
"assets/img/inner-page/country-details/details-2.jpg",
"assets/img/inner-page/country-details/details-3.png"
],
"visaCategories": [
"Student Visa (F1, M1, J1)",
"Work Visa (H1B, L1)",
"Tourist Visa (B1/B2)",
"Family/Spouse Visa (K1, IR1, F2A)",
"Green Card / Immigrant Visa"
],
"serviceOptions": [
{
"number": "01",
"title": "Consultation & Eligibility Check",
"description": "Our experts review your profile and visa requirements."
},
{
"number": "02",
"title": "Application Preparation",
"description": "We help with document collection, form filling, and statement drafting."
},
{
"number": "03",
"title": "Submission",
"description": "Visa application is submitted online with required fees."
},
{
"number": "04",
"title": "Interview Guidance",
"description": "Get training and mock sessions for embassy interview."
},
{
"number": "05",
"title": "Approval & Travel",
"description": "Once approved, we provide travel and pre-departure guidance."
}
]
},
"relatedCountries": [
{
"id": 1,
"name": "Canada",
"icon": "assets/img/inner-page/country-details/01.png"
},
{
"id": 2,
"name": "USA",
"icon": "assets/img/inner-page/country-details/02.png"
},
{
"id": 3,
"name": "USA",
"icon": "assets/img/inner-page/country-details/03.png"
},
{
"id": 4,
"name": "Saint Helena",
"icon": "assets/img/inner-page/country-details/05.png"
},
{
"id": 5,
"name": "Iran",
"icon": "assets/img/inner-page/country-details/06.png"
},
{
"id": 6,
"name": "Spain",
"icon": "assets/img/inner-page/country-details/07.png"
},
{
"id": 7,
"name": "Japan",
"icon": "assets/img/inner-page/country-details/08.png"
}
],
"contactInfo": {
"phone": "+009 438 222 9540",
"email": "infor@xridergamil.com",
"location": "Toronto, Montreal, City 2026"
}
}

View File

@@ -1,91 +0,0 @@
{
"hero": {
"title": "About Us",
"breadcrumb": [
"Home",
"About Us"
],
"backgroundImage": "/uploads/about/breadcrumb.jpg"
},
"intro": {
"subheading": "Company Intro",
"heading": "Building Pathways to Your Immigration Success",
"description": "We provide expert guidance, personalized solutions, and transparent processes to help you achieve your immigration goals. Our dedicated team ensures a smooth journey, building pathways to your international success.",
"image": "/uploads/about/businessman.jpg"
},
"mission": {
"subheading": "About Our Consultancy",
"heading": "Turning Study Abroad Dreams Into Reality",
"description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.",
"images": {
"main": "/uploads/about/375x419.jpg",
"secondary": "/uploads/about/375x419.jpg",
"bgShape": "/assets/img/home-1/about/Vector.png",
"planeShape": "/assets/img/home-1/about/plane.png",
"topShape": "/assets/img/home-1/about/shape.png",
"globeShape": "/assets/img/home-1/about/globe.png"
},
"items": [
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
},
{
"icon": "/assets/img/home-1/icon/01.svg",
"label": "Global Reach",
"description": "Expanding Opportunities Worldwide"
}
],
"features": [
"Fastest Visa form processing with skilled immigration agents",
"Partnership with International Educational Institutions"
],
"ctaButton": {
"label": "Get Started",
"href": "/about"
}
},
"features": {
"backgroundImage": "/assets/img/home-2/feature/bg-shape.png",
"subheading": "Your Travel Made Easy",
"heading": "Smooth Visa Journey Guaranteed",
"description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistance",
"image": "/uploads/about/686x906.jpg",
"items": [
{
"icon": "/assets/img/home-2/icon/01.png",
"title": "Expert Consultants",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
},
{
"icon": "/assets/img/home-2/icon/01.png",
"title": "Personalized Support",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
},
{
"icon": "/assets/img/home-2/icon/01.png",
"title": "Transparent Process",
"description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors."
}
],
"ctaButton": {
"label": "Get Started Today",
"href": "/contact"
}
},
"news": {
"subheading": "Visa Tips & Guides",
"heading": "Latest Insights & Updates",
"ctaButton": {
"label": "view all articles",
"href": "/blog"
},
"selectedBlogIds": [
"69857d6c6d04fed459107944",
"69857d6c6d04fed459107942",
"69857d6c6d04fed459107940"
],
"items": []
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +0,0 @@
{
"hero": {
"title": "Make Appointment",
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg",
"subtitle": "About Our Consultancy",
"heading": "Want to meet us for your need?",
"description": "24/7 customer support is always ready to answer all your questions"
},
"visaOptions": [
"Canada Immigration",
"Tourist Visa",
"Medical Visa",
"Coaching",
"Student Visa",
"Spouse Visa",
"Job Opportunity",
"Exam"
],
"form": {
"heading": "Request Appointment",
"fields": [
{
"name": "name",
"label": "Your Name",
"type": "text",
"placeholder": "Your name",
"required": true,
"colClass": "col-lg-4"
},
{
"name": "email",
"label": "Your Email",
"type": "email",
"placeholder": "Your email",
"required": true,
"colClass": "col-lg-4"
},
{
"name": "phone",
"label": "Your Phone",
"type": "tel",
"placeholder": "Phone Number",
"required": false,
"colClass": "col-lg-4"
},
{
"name": "address",
"label": "Your Address",
"type": "text",
"placeholder": "Your address",
"required": false,
"colClass": "col-lg-6"
},
{
"name": "appointmentDate",
"label": "Appointment Date",
"type": "date",
"placeholder": "",
"required": false,
"colClass": "col-lg-6"
},
{
"name": "message",
"label": "Your Message",
"type": "textarea",
"placeholder": "Type your message",
"required": false,
"colClass": "col-lg-12"
}
],
"submitButton": {
"text": "Request Appointment",
"icon": "fa-solid fa-arrow-right",
"buttonClass": "theme-btn"
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,690 +0,0 @@
{
"hero": {
"title": "Booking",
"backgroundImage": "/uploads/booking/b13.jpg"
},
"searchBar": {
"locationLabel": "Location",
"holidaySeasonLabel": "Holiday Season",
"searchButtonText": "Search"
},
"filterPanel": {
"title": "FIND YOUR CAMP!",
"priceTitle": "Price",
"priceLabel": "Maximum Price (USD)",
"pricePlaceholder": "Enter max price",
"priceMin": 0,
"priceMax": 2000,
"activitiesTitle": "Activities",
"ageTitle": "AGE",
"ageSelectPlaceholder": "Select age",
"ageMin": 7,
"ageMax": 18,
"ratingTitle": "RATING WISE",
"ratingOptions": [
{ "value": "", "label": "All Ratings" },
{ "value": "5", "label": "5 Stars" },
{ "value": "4", "label": "4 Stars & Up" },
{ "value": "3", "label": "3 Stars & Up" },
{ "value": "2", "label": "2 Stars & Up" },
{ "value": "1", "label": "1 Star & Up" }
],
"resetButtonText": "Reset"
},
"programs": [
{ "value": "adventure", "label": "Adventure, Sports & Creative" },
{ "value": "arts-crafts", "label": "Arts & Crafts" },
{ "value": "climbing", "label": "Climbing" },
{ "value": "dancing", "label": "Dancing" },
{ "value": "diving", "label": "Diving" },
{ "value": "englisch-camps", "label": "Englischcamps" },
{ "value": "englisch-toefl", "label": "Englisch TOEFL©" },
{ "value": "fishing", "label": "Fishing" },
{ "value": "german-camps", "label": "German Camps" },
{ "value": "horseback", "label": "Horseback Riding" },
{ "value": "husky", "label": "Husky Camp" },
{ "value": "icit", "label": "International Counsellor in Training (ICIT)" },
{ "value": "lifeguarding", "label": "Lifeguarding" },
{ "value": "language", "label": "Language" },
{ "value": "leadership", "label": "Leadership" },
{ "value": "multi-water", "label": "Multi Water Adventure" },
{ "value": "sailing", "label": "Sailing" },
{ "value": "skating", "label": "Skating" },
{ "value": "soccer", "label": "Soccer" },
{ "value": "space", "label": "Space Exploration" },
{ "value": "spanish", "label": "Spanishcourse" },
{ "value": "survival", "label": "Survival" },
{ "value": "swimming", "label": "Swimming" },
{ "value": "tennis", "label": "Tennis" },
{ "value": "windsurf", "label": "Windsurfing" }
],
"holidays": [
{ "value": "autumn", "label": "Autumn" },
{ "value": "spring", "label": "Spring" },
{ "value": "summer", "label": "Summer" }
],
"locations": [
{ "value": "philippines", "label": "Philippines" },
{ "value": "vietnam", "label": "Vietnam" },
{ "value": "portugal", "label": "Portugal" },
{ "value": "china", "label": "China" },
{ "value": "thailand", "label": "Thailand" },
{ "value": "malaysia", "label": "Malaysia" },
{ "value": "holiday", "label": "Holiday" }
],
"camps": [
{
"name": "Adventure, Sports & Creative",
"price": 395,
"priceText": "from 395 USD",
"season": ["spring", "summer", "autumn"],
"age": [12, 18],
"locations": ["thailand"],
"image": "/uploads/booking/00_Abenteuercamp-Hike-533b20fa.jpg",
"link": "/activities/adventure-sports-creative",
"program": "adventure",
"rating": 5
},
{
"name": "Arts & Crafts",
"price": 500,
"priceText": "from 500 USD",
"season": ["spring", "summer", "autumn"],
"age": [12, 18],
"locations": ["vietnam"],
"image": "/uploads/booking/01-Kreativprogramm-in-der-Ferienfreizeit-c6e95722.jpg",
"link": "/activities/arts-crafts",
"program": "arts-crafts",
"rating": 4
},
{
"name": "Climbing",
"price": 515,
"priceText": "from 515 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["philippines"],
"image": "/uploads/booking/00-Kletterkurs_Sommercamp_Bayern-40f1bd8d.jpg",
"link": "/activities/climbing",
"program": "climbing",
"rating": 5
},
{
"name": "Dancing",
"price": 520,
"priceText": "from 520 USD",
"season": ["summer", "autumn"],
"age": [12, 18],
"locations": ["malaysia"],
"image": "/uploads/booking/00-Tanzen-im-Feriencamp-c1834fc7.jpg",
"link": "/activities/dancing",
"program": "dancing",
"rating": 4
},
{
"name": "Diving",
"price": 1190,
"priceText": "from 1190 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["philippines"],
"image": "/uploads/booking/01-Tauchkurs-im-Sommercamp-3309e219.jpg",
"link": "/activities/diving",
"program": "diving",
"rating": 5
},
{
"name": "Englisch TOEFL®",
"price": 1290,
"priceText": "from 1290 USD",
"season": ["spring", "summer"],
"age": [12, 18],
"locations": ["malaysia"],
"image": "/uploads/booking/07-Language-Camps-by-Camp-Adventure-b9f01b6a.jpg",
"link": "/activities/englisch-toefl",
"program": "englisch-toefl",
"rating": 5
},
{
"name": "Englischcamps",
"price": 530,
"priceText": "from 530 USD",
"season": ["spring", "summer", "autumn"],
"age": [12, 18],
"locations": ["philippines", "thailand"],
"image": "/uploads/booking/00-Language-Camps-by-Camp-Adventure-add7aa60.jpg",
"link": "/activities/englischcamps",
"program": "englisch-camps",
"rating": 4
},
{
"name": "Fishing",
"price": 580,
"priceText": "from 580 USD",
"season": ["spring", "summer", "autumn"],
"age": [12, 18],
"locations": ["vietnam"],
"image": "/uploads/booking/01-Angeln-im-Ferienlager-02243939.jpg",
"link": "/activities/fishing",
"program": "fishing",
"rating": 4
},
{
"name": "German Camps",
"price": 610,
"priceText": "from 610 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["thailand", "vietnam"],
"image": "/uploads/booking/Deutschcamps-in-Deutschland-0ed3ea07.jpg",
"link": "/activities/german-camps",
"program": "german-camps",
"rating": 4
},
{
"name": "Horseback Riding",
"price": 620,
"priceText": "from 620 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["portugal"],
"image": "/uploads/booking/00-Reiten-Sommercamp-Ausritt-6930f841.jpg",
"link": "/activities/horseback-riding",
"program": "horseback",
"rating": 5
},
{
"name": "Husky Camp",
"price": 525,
"priceText": "from 525 USD",
"season": ["spring", "summer", "autumn"],
"age": [12, 18],
"locations": ["china"],
"image": "/uploads/booking/00-Husky20Camp_sommercamp20mit20Hunden-9c098a17.jpg",
"link": "/activities/husky-camp",
"program": "husky",
"rating": 5
},
{
"name": "International Counsellor in Training (ICIT)",
"price": 995,
"priceText": "from 995 USD",
"season": ["summer"],
"age": [16, 18],
"locations": ["thailand", "malaysia"],
"image": "/uploads/booking/00-INTERNATIONAL20COUNSELOR20IN20TRAINING_teambuilding-3b91547c.jpg",
"link": "/activities/international-counsellor-in-training-icit",
"program": "icit",
"rating": 5
},
{
"name": "Leadership",
"price": 1185,
"priceText": "from 1185 USD",
"season": ["summer"],
"age": [16, 18],
"locations": ["philippines"],
"image": "/uploads/booking/00-Leadership-Camp-0d21c60a.jpg",
"link": "/activities/senior-plus-leadership",
"program": "leadership",
"rating": 5
},
{
"name": "Lifeguarding",
"price": 580,
"priceText": "from 580 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["malaysia"],
"image": "/uploads/booking/00-Rettungsschwimmen-Feriencamp-6a364891.jpg",
"link": "/activities/lifeguarding",
"program": "lifeguarding",
"rating": 4
},
{
"name": "Multi Water Adventure",
"price": 990,
"priceText": "from 990 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["philippines"],
"image": "/uploads/booking/00-Multi-Water-Adventure-im-Sommercamp-a47c08a3.jpg",
"link": "/activities/multi-water-adventure",
"program": "multi-water",
"rating": 1
},
{
"name": "Sailing",
"price": 990,
"priceText": "from 990 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["thailand"],
"image": "/uploads/booking/01-Segeln-im-Sommercamp-in-Spanien-e9d06b28.jpg",
"link": "/activities/sailing",
"program": "sailing",
"rating": 2
},
{
"name": "Skating",
"price": 420,
"priceText": "from 420 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["vietnam"],
"image": "/uploads/booking/00-Skaten im Sommercamp-8240a4c7.jpg",
"link": "/activities/skating",
"program": "skating",
"rating": 3
},
{
"name": "Soccer",
"price": 495,
"priceText": "from 495 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["malaysia"],
"image": "/uploads/booking/00-Soccer-Camps-543a1625.jpg",
"link": "/activities/soccer",
"program": "soccer",
"rating": 3
},
{
"name": "Space Exploration",
"price": 595,
"priceText": "from 595 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["china"],
"image": "/uploads/booking/00-Space-Exploration-Sommer-Camp-599962e5.jpg",
"link": "/activities/space-exploration",
"program": "space",
"rating": 4
},
{
"name": "Spanish Camps",
"price": 595,
"priceText": "from 595 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["portugal"],
"image": "/uploads/booking/Spanischcamp-in-Spanien-d118b0e9.jpg",
"link": "/activities/spanish-camps",
"program": "spanish",
"rating": 4
},
{
"name": "Survival",
"price": 495,
"priceText": "from 495 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["vietnam"],
"image": "/uploads/booking/03-Walsrode-Survival-e00c16d7.jpg",
"link": "/activities/survival",
"program": "survival",
"rating": 4
},
{
"name": "Swimming",
"price": 495,
"priceText": "from 495 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["philippines"],
"image": "/uploads/booking/Schwimmen_camp-98f48b76.jpg",
"link": "/activities/swimming",
"program": "swimming",
"rating": 4
},
{
"name": "Tennis",
"price": 495,
"priceText": "from 495 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["malaysia"],
"image": "/uploads/booking/00-Tenniscamp-57cd2c79.jpg",
"link": "/activities/tennis",
"program": "tennis",
"rating": 4
},
{
"name": "Windsurfing",
"price": 990,
"priceText": "from 990 USD",
"season": ["summer"],
"age": [12, 18],
"locations": ["thailand"],
"image": "/uploads/booking/00-Windsurfen-im-Sommercamp-ac31b126.jpg",
"link": "/activities/windsurfing",
"program": "windsurf",
"rating": 5
}
],
"formSteps": [
{
"step": 1,
"title": "Participant Information",
"sections": [
{
"id": "logistics",
"fields": [
{
"name": "accommodation",
"label": "Accommodation",
"type": "select",
"required": true,
"options": [
{
"value": "a1",
"label": "Accommodation in tiny houses/huts in the Adventure Camp",
"price": 10
}
]
},
{
"name": "transferTo",
"label": "Getting there",
"type": "select",
"required": true,
"options": [
{
"value": "3",
"label": "Self-organized Arrival (4-6 pm)",
"price": 0
},
{
"value": "351",
"label": "Shuttle Plattling - Meeting Point: Train Station platform 5 (at 3:30 pm)",
"price": 45
}
]
},
{
"name": "transferFrom",
"label": "Departure",
"type": "select",
"required": true,
"options": [
{
"value": "3",
"label": "Self-organized Pick-up",
"price": 0
},
{
"value": "351",
"label": "Shuttle Plattling - Train Station",
"price": 45
}
]
},
{
"name": "activities",
"label": "Activity Profile",
"type": "select",
"required": true,
"options": [
{
"value": "195",
"label": "Adventure, Sports and Creative (Basic profile)",
"price": 0
}
]
},
{
"name": "addons",
"label": "Additional addons",
"type": "checkbox-group",
"required": false,
"options": [
{
"value": "8",
"label": "Travel Cancellation Guarantee (one week)",
"price": 45
}
]
}
]
},
{
"id": "personal_details",
"fields": [
{
"name": "firstName",
"label": "First name",
"type": "text",
"required": true
},
{
"name": "lastName",
"label": "Last name",
"type": "text",
"required": true
},
{
"name": "birthday",
"label": "Birthday",
"type": "date",
"required": true
},
{
"name": "gender",
"label": "Gender",
"type": "select",
"required": true,
"options": [
{
"value": "female",
"label": "Female"
},
{
"value": "male",
"label": "Male"
},
{
"value": "divers",
"label": "Non binary"
}
]
},
{
"name": "nationality",
"label": "Nationality",
"type": "select",
"required": true,
"options": [
{
"value": "Germany",
"label": "Germany"
},
{
"value": "United States",
"label": "United States"
},
{
"value": "United Kingdom",
"label": "United Kingdom"
},
{
"value": "France",
"label": "France"
},
{
"value": "Spain",
"label": "Spain"
}
]
},
{
"name": "lodgingPartner",
"label": "Lodging partner",
"type": "text",
"required": false
}
]
}
]
},
{
"step": 2,
"title": "Guardian Information",
"sections": [
{
"id": "guardian_details",
"fields": [
{
"name": "customerGender",
"label": "Salutation",
"type": "select",
"required": false,
"options": [
{
"value": "female",
"label": "Mrs"
},
{
"value": "male",
"label": "Mr"
},
{
"value": "divers",
"label": "Non binary"
}
]
},
{
"name": "customerFirstName",
"label": "First name",
"type": "text",
"required": true
},
{
"name": "customerLastName",
"label": "Last name",
"type": "text",
"required": true
},
{
"name": "customerEmail",
"label": "E-Mail",
"type": "email",
"required": true
},
{
"name": "customerPhone",
"label": "Phone",
"type": "tel",
"required": true
},
{
"name": "customerStreet",
"label": "Street & Number",
"type": "text",
"required": true
},
{
"name": "customerZip",
"label": "Zip",
"type": "text",
"required": true
},
{
"name": "customerCity",
"label": "City",
"type": "text",
"required": true
},
{
"name": "customerCountry",
"label": "Country",
"type": "select",
"required": true,
"options": [
{
"value": "Germany",
"label": "Germany"
},
{
"value": "United States",
"label": "United States"
},
{
"value": "United Kingdom",
"label": "United Kingdom"
},
{
"value": "France",
"label": "France"
},
{
"value": "Spain",
"label": "Spain"
}
]
}
]
}
]
}
],
"validation": {
"step1Required": [
"accommodation",
"transferTo",
"transferFrom",
"activities",
"firstName",
"lastName",
"birthday",
"gender",
"nationality"
],
"step2Required": [
"customerFirstName",
"customerLastName",
"customerEmail",
"customerPhone",
"customerStreet",
"customerZip",
"customerCity",
"customerCountry"
]
},
"configuration": {
"currency": "USD",
"discounts": [
{
"id": "915",
"name": "Sibling or Returning Camper Discount",
"type": "percentage",
"value": 0.05,
"description": "This discount is granted if your child has attended a Camp Adventure program before or if you register siblings."
},
{
"id": "9152",
"name": "Sibling or Returning Camper Discount",
"type": "percentage",
"value": 0.05,
"description": "This discount is granted if your child has attended a Camp Adventure program before or if you register siblings."
}
],
"vouchers": [
{
"validCodes": "SUMMER2026",
"type": "percentage",
"value": 0.1
},
{
"validCodes": "SUMMER2027",
"type": "percentage",
"value": 0.05
},
{
"validCodes": "CAMP50",
"type": "fixed",
"value": 50
}
]
}
}

View File

@@ -1,119 +0,0 @@
{
"hero": {
"title": "CONTACT US",
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg",
"overlayColor": "rgba(0, 0, 0, 0)",
"sectionClass": "breadcrumb-wrapper fix bg-cover",
"titleClass": "breadcrumb-title",
"enableScrollspy": false,
"backgroundPosition": "center"
},
"contactCards": [
{
"type": "location",
"title": "Location",
"content": [
"43 Sardinella, 3nd Land Walk,",
"Orchard view, London, UK"
],
"iconType": "fa-solid fa-location-dot",
"iconSource": "fontawesome"
},
{
"type": "email",
"title": "Email Address",
"content": [
"supportinfo@gmail.com",
"arluxhotelinfo.com"
],
"iconType": "fa-solid fa-envelope",
"iconSource": "fontawesome"
},
{
"type": "phone",
"title": "Phone Number",
"content": [
"+880 123 427 00",
"+000 938 809 12"
],
"iconType": "fa-solid fa-phone",
"iconSource": "fontawesome"
}
],
"map": {
"coordinates": {
"lat": -37.81450084255415,
"lng": 144.9618311901502
},
"zoom": 15,
"location": "Envato, Melbourne, Australia",
"markerTitle": "Our Office",
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6678.7619084840835!2d144.9618311901502!3d-37.81450084255415!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6ad642b4758afc1d%3A0x3119cc820fdfc62e!2sEnvato!5e0!3m2!1sen!2sbd!4v1641984054261!5m2!1sen!2sbd",
"tileLayer": {
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
"attribution": "",
"maxZoom": 18,
"minZoom": 0
}
},
"form": {
"sectionLabel": "",
"heading": "Send Us Message",
"description": "Have questions about visas or immigration? Send us a message today and our expert team will respond quickly.",
"fields": [
{
"name": "name",
"label": "Your Name",
"type": "text",
"placeholder": "Your name",
"required": true,
"colClass": "col-lg-4"
},
{
"name": "email",
"label": "Your Email",
"type": "email",
"placeholder": "Your email",
"required": true,
"colClass": "col-lg-4"
},
{
"name": "phone",
"label": "Your Phone",
"type": "tel",
"placeholder": "Phone Number",
"required": true,
"colClass": "col-lg-4"
},
{
"name": "address",
"label": "Your Address",
"type": "text",
"placeholder": "Address Now",
"required": false,
"colClass": "col-lg-6"
},
{
"name": "date",
"label": "Your Date",
"type": "date",
"placeholder": "Date",
"required": false,
"colClass": "col-lg-6"
},
{
"name": "message",
"label": "Your Message",
"type": "textarea",
"placeholder": "Type your message",
"required": false,
"colClass": "col-lg-12"
}
],
"submitButton": {
"text": "SEND MESSAGE",
"icon": "fa-solid fa-arrow-right",
"buttonClass": "theme-btn style-2"
}
}
}

View File

@@ -1,61 +0,0 @@
[
{
"title": "Academics",
"url": "/academics/",
"children": [
{
"title": "Foundations",
"url": "/academics/foundations/",
"children": [],
"programmes": [
{
"title": "Pre-A",
"url": "/academics/foundations/PAF1000/"
},
{
"title": "Pre-U",
"url": "/academics/foundations/PUF1000/"
}
]
},
{
"title": "Undergraduate",
"url": "/academics/undergraduate/",
"children": [],
"programmes": [
]
},
{
"title": "Postgraduate",
"url": "/academics/postgraduate/",
"children": [],
"programmes": [
]
},
{
"title": "Global Education",
"url": "/academics/global-education/",
"children": [
{
"title": "Postgraduate Online",
"url": "/academics/postgraduate-online/",
"children": [],
"programmes": [
{
"title": "Accounting and Finance",
"url": "/academics/postgraduate-online/GE7002/"
},
{
"title": "International Business Law",
"url": "/academics/postgraduate-online/GE7008/"
},
]
}
]
}
]
}
]

View File

@@ -1,234 +0,0 @@
{
"hero": {
"title": "Go and Grow Camp",
"backgroundImage": "/uploads/home/b2.jpg",
"overlayColor": "rgba(0, 0, 0, 0)",
"sectionClass": "uk-section-secondary uk-section-overlap uk-preserve-color uk-light",
"titleClass": "uk-heading-large uk-text-center !text-[5vw]",
"enableScrollspy": true,
"backgroundPosition": "top-center"
},
"sidebarNav": [
{
"id": "general-information",
"label": "General Information"
},
{
"id": "camps",
"label": "Camps"
},
{
"id": "camp-routine",
"label": "Camp Routine"
},
{
"id": "camp-counselors",
"label": "Camp Counselors"
},
{
"id": "camp-rules",
"label": "Camp Rules"
},
{
"id": "safety",
"label": "Safety"
},
{
"id": "accommodation-catering",
"label": "Accommodation & Catering"
},
{
"id": "transfers-shuttles",
"label": "Transfers & Shuttles"
}
],
"contactBox": {
"title": "Let's plan your perfect nature escape",
"phone": {
"icon": "phone",
"text": "+(123)-456-789"
},
"email": {
"icon": "email",
"text": "hello@ggcamp.org"
}
},
"faqSections": [
{
"id": "general-information",
"title": "General Information",
"faqs": [
{
"title": "What are FAQ?",
"description": "FAQ are the initials for \"Frequently Asked Questions\".\n\nThe FAQ have been compiled by us over a long period of time and are intended to help give a general overview of our camps and clarify questions that arise before booking a camp."
},
{
"title": "General booking process",
"description": "Once the booking has been confirmed by us, you will receive an e-mail requesting a deposit. As soon as we have received this, you will receive an e-mail with a payment confirmation.\nPlease have a look at the welcome package, which will reach you by e-mail with the Last Travel Information. This contains information that applies to the camp you have booked.\n\nStep 1: Registration\nStep 2: Receipt of registration confirmation, total invoice and deposit request (e-mail)\nStep 3: Deposit of USD 50 (due within 7 days after booking)\nStep 4: Receiving an email with the latest important travel information, a packing list, addresses and important emergency phone numbers plus remaining payment request about 3-4 weeks before the camp starts."
},
{
"title": "Terms & Conditions",
"description": "Our Terms & Conditions can be found in our official documents section."
},
{
"title": "Where can I find a packing guide for Camps?",
"description": "Just click here to download our packing list."
},
{
"title": "Where can I find contact information from Camps and addresses?",
"description": "Here you can find all the necessary information if you want to drive to our camps or send something. If you want to send something please ALWAYS include the full name of your child on the letter/package and please only send it at the time when your kids are staying in camp as we cannot store it for a longer period of time.\n\nWalsrode/Lüneburger Heide - Germany:\nCamp Adventure, Vethem 58, 29664 Walsrode, Germany\nwalsrode@campadventure.de\n\nRegen/Bavarian Forest - Germany:\nCamp Adventure, Badstrasse 18, 94209 Regen\nregen@campadventure.de\n\nBarcelona - Spain:\nBISC International Sailing Center, c/o Camp Adventure, Parc del Fòrum Sota plaça fotovoltàica, 08930 Sant Adrià de Besòs, Barcelona, Spain\nbarcelona@campadventure.de\n\nBath - England:\nUniversity of Bath, c/o Camp Adventure, Claverton Down, Bath BA2 7AY, England\nengland@campadventure.de\n\nRossall - England:\nRossall School, Broadway, Fleetwood, Lancashire FY7 8JW, England\nengland@campadventure.de"
}
]
},
{
"id": "camps",
"title": "Camps",
"faqs": [
{
"title": "Where do kids and camp counselors come from?",
"description": "Camp Adventure attaches great importance to internationality. The participants and supervisors in our camps come from many different countries. Last year, for example, we had participants from over 60 different countries and counselors from 25 different nations. Of course, we don't know where they will come from this year. So we are at least as excited as you are.\n\nThrough our office in Hamburg and our branch office in Canada, we reach motivated and committed counselors from all over the world. Canadian and Australian teamers can therefore be found as well as German or Spanish teamers.\n\nDue to the different experiences and cultural backgrounds an indescribably fantastic, international atmosphere is created."
},
{
"title": "Which languages are spoken in camp?",
"description": "The main language in all our camps is English. In addition, there is the language of the country in which the camp takes place. As we have our headquarters in Germany, German teamers are always present in all camps in Germany. All announcements and explanations are here therefore always in German and English. Of course, all our teamers with their different nationalities are also available for individual translations."
},
{
"title": "Are there problems if children have low language skills?",
"description": "No, because there are usually more participants and team members who speak the same language. We know from experience that children are excellent at communicating nonverbally. They often need a few days to warm up to it, but are then very open to other children as well."
},
{
"title": "Are girls and boys separated?",
"description": "Girls and boys are accommodated separately in the dormitories/tents. The program is completely mixed."
},
{
"title": "How big are the camps? How high is the caregiver ratio?",
"description": "Capacities range from around 30 participants in smaller language camps to a maximum of about 400 children in our camp Lueneburger Heide. However, the maximum capacity is not reached every week. However, a minimum number of participants must be guaranteed in order to run the camp.\n\nIt is important to us that all children are always grouped in small groups of 5-8, with a counselor as a contact person. This way homesickness doesn't stand a chance and despite the size of the camp in their group family, they experience a strong bond on which they can count on!"
},
{
"title": "Should 12-year-olds go to Junior Camp or Senior Camp?",
"description": "This question is not easy to answer and depends on the individual stage of development of your child. Therefore, as parents, we leave you the opportunity to decide for yourself. In the Junior Camp they belong to the older ones and can explore a lot in a playful way. In the Senior Camp they are the younger ones, who have role models through the older ones, whom they can emulate."
}
]
},
{
"id": "camp-routine",
"title": "Camp Routine",
"faqs": [
{
"title": "How is the choice of activities/courses in the camps made?",
"description": "If your child would like to participate in a paid additional course (e.g. horse riding, language course, Survival etc.), this must be booked in advance when registering. In principle, no extra additional courses have to be booked. A program with a variety of activities is of course available to the participants in all camps. The various activities can be chosen by the participants on site in the respective camps. We present the offers to the participants, so that everyone gets an insight into the different courses. The children can then register in the lists of the respective courses."
},
{
"title": "What is a hike?",
"description": "The hike is a 1-3 day walking tour, in which all participants of the Adventure Camp who stay 2 weeks in the camp take part. On this hike the participants will not spend the night in a tent, but either in the open air or under a self-made shelter e.g. from tarpaulins. They will of course be accompanied by their teamers. The hike is a very special experience and a highlight for all participants. For this hike the participants need sturdy shoes and a big backpack."
},
{
"title": "Can I wash my clothes during the camp?",
"description": "In principle, participants should bring sufficient clothing and change of clothes for the entire camp period.\n\nOnly in the camps in Lüneburger Heide and Bayerischer Wald a laundry service will be offered for kids staying three weeks or more, which means that a laundry bag (approx. 3 kg) will be washed in the laundry centre of the next village at a price of USD 45. This service can be booked upon registration for three-week camps. Please note that the laundry will be done either after one week or after two weeks."
},
{
"title": "Anti Homesick Adviser",
"description": "Dear parents\n\nNow it's almost time: In summer your child travels for the first time with Camp Adventure. Maybe it will be the first time that he travels alone without parents or relatives. As we are getting more and more questions, we have decided to put together a small package for you parents with little tips from experts to make everything as easy as possible for you and your child. Follow our tips and your child will have a fantastic holiday, have many new experiences and make friends from all over the world! All these tips have been developed together with the International Camping Fellowship. And the more you think your child will be a \"homesick candidate\" - or your child even claims to be one - the more you consider the following tips."
}
]
},
{
"id": "camp-counselors",
"title": "Camp Counselors - Our Teamers",
"faqs": [
{
"title": "Who are the camp counselors?",
"description": "Every year our team is made up of an international mix. The non-profit association Camp Europe e.V. with headquarters in Hamburg and a branch office in Canada takes care of the acquisition of national and international applicants. Since we have about 50% German-speaking children, there are also German carers in every location. But many also come from other countries, such as England, Spain, Canada and Australia, to name just a few."
},
{
"title": "How are the teamers trained?",
"description": "All counselors go through an extensive application process. For a successful application, not only an interesting curriculum vitae and a minimum age of 19 years are sufficient! We conduct a personal interview with each individual in which our employees get a first impression of the applicant.\n\nBefore the camp season, everyone, both the first supervisors (teamers) as well as many recomers, complete a one-week training in which they are prepared for their assignment by trained coaches. They must have a first aid certificate, which may not be older than two years, as well as an internationally flawless police clearance certificate. We know how important the teamers are for a great camp and therefore select them very conscientiously."
}
]
},
{
"id": "camp-rules",
"title": "Camp Rules",
"faqs": [
{
"title": "Drugs, Alcohol & Camp?",
"description": "From our point of view an absolutely unacceptable and indiscutable combination! Due to our cooperation with the association \"Keine Macht den Drogen\" (No power to drugs) and our common opinion that all kinds of drugs do not belong in the hands of children & teenagers, any possession or consumption of drugs is forbidden for teenagers and children in the camp and also outside the camp.\n\nViolations can lead to exclusion or even to criminal charges. The term \"drugs\" also includes cigarettes and alcohol! Through our varied activities, we offer a much better alternative! We would like to make it clear from the outset that we are also against any form of discrimination or \"putting down\". This is - just like violence - immediately prevented by us, in order to offer each young person a relaxed and joyful time in the camp."
},
{
"title": "Should I call my kid or write an old-fashioned letter?",
"description": "We ask all parents to write to their child at least once. This is especially useful at the beginning, as it is a particularly upsetting experience for every child and every teenager when most of the participants receive a letter, but they do not.\n\nPlease note that there is NO public \"camp phone\" available for incoming or outgoing calls. If your child doesn't bring her/his own phone, she/he won't be able to call you. In case of any problems, we will of course contact you immediately.\n\nIf your child brings a mobile phone, we will collect it on arrival and store it with the valuables. Your child's Teamer may hand it over during the phone time after lunch. Please keep in mind: no news is good news (the location manager will contact you if it is necessary due to homesickness or illness). We kindly ask you not to call the office in Hamburg to ask about your kid's health and wellbeing, nor if you would like to know why your child hasn't called you yet. Please use our camp email service for such enquiries.\n\nOur recommendation is the following:\nWe recommend not to call your child (even if he or she has a mobile phone with him or her) and not to tell him or her to call you. Telephoning can in our experience promote homesickness very strongly and your child will be cured thereby if completely immersed in camp life! At noon after lunch, if absolutely necessary, your child can pick up his or her mobile phone from the counselors until the start of next program and make phone calls. Instead, you are welcome to bring a pre-stamped and addressed envelope with you. We will then make sure that your child has enough time to write letters. Since letters and postcards often arrive late at the camps, we also offer the e-mail service. You can send your child max. ONE email per day directly to the camp, which we then print out and give to your child. There is no way for them to reply, but your child will be happy to receive a small message from home. You can find the postal and email address in the info package of the booked camp."
},
{
"title": "Are there any prohibited items?",
"description": "Yes, there are. Not allowed are pocket knives with lockable blades, all weapons, lighters and matches (danger of fire in the forest!). Drugs of any kind, including alcohol and cigarettes, are also included."
}
]
},
{
"id": "safety",
"title": "Safety",
"faqs": [
{
"title": "Electronic equipment and valuables",
"description": "We recommend that you do not take an MP3 player, e-book, tablet, etc. or any valuables with you. On the one hand we do not assume any liability and on the other hand there are no possibilities to charge the devices. We are of the opinion that the camp time is a special experience for the participants if they do not have the headphones in their ears all the time or are busy with their mobile phones. Instead they have the chance to deal with other topics and they find time to dedicate themselves to the new people in the camp."
},
{
"title": "How do you provide safety for the kids?",
"description": "Before our camp counselors start working with us, we check their police clearance certificates. You must be at least 19 years old to work for us as a teamer. They must also have a \"First Aid Certificate\", which must not be older than two years. In the camps we try to make sure that only adults from our camp or familiar faces are on the campground and that all our carers look after strangers.\n\nWe have many different camp sites. Some of them are fenced in, others are not. There are no armed guards or the like in our camps, as we believe that these conditions create a very insecure feeling. We do not have a high security zone in Germany, Northern Ireland or England, but we keep our eyes open and do everything we can to ensure that all participants have a great time."
},
{
"title": "Insurance in case of illness?",
"description": "If your child should fall ill during the camp and medical help is required, he or she will of course be taken to the doctor by our carers and cared for there as well. It is therefore necessary for each participant to take their insurance card with them to the camp. We offer all participants the possibility of taking out liability, casualty & health insurance for travel abroad with us. This covers all costs in case of illness and prevents international children in particular from having to \"advance\" their own cash. You can find more detailed information on insurance in our documents section."
}
]
},
{
"id": "accommodation-catering",
"title": "Accommodation & Catering",
"faqs": [
{
"title": "How's the food at the camps?",
"description": "Full board for the entire duration of the camp is of course already included in the camp price. In addition, water and fruit are available for the participants around the clock. For us it is a matter of course to provide one variant for vegetarians and one pork-free with each meal. In case of special allergies or intolerances of your children let us know in advance and we will try to find a solution."
},
{
"title": "How is my child accommodated in the camp?",
"description": "In our Adventure Camp Bayerischer Wald and our Camp Lueneburger Heide, the Juniors (7-12) and the Seniors (12-16) can choose between tents and huts.\n\nThe tents are equipped with a floor and a wooden platform, up to 7 children can share one tent. The participants can make themselves comfortable with sleeping bag and sleeping mat. The wooden huts are equipped with bunk beds and can accommodate 4-8 children. At the other locations, participants will be accommodated in shared rooms in youth hostels, sports centres or boarding schools of private schools. You will find detailed information about the accommodation on the individual camp pages."
}
]
},
{
"id": "transfers-shuttles",
"title": "Transfers & Shuttles",
"faqs": [
{
"title": "Entry regulations/Travel Consent for group flights",
"description": "All parents need to fill this out and bring it to camp:\n\nBelow is a summary of the travel requirements for minors from various EU countries traveling with Camp Adventure on group flights. Please note that regulations can change, so it's essential to consult the official resources provided for the most up-to-date information."
},
{
"title": "Which transfers are offered?",
"description": "The respective transfer possibilities depend on the period and venue of the camp. Check directly on the respective camp page under \"Arrival & Departure Services\"."
},
{
"title": "Where can I find the exact arrival and departure times?",
"description": "Information about the different arrival and departure times can be found on the respective camp page under \"Arrival & Departure Services\"."
},
{
"title": "How do the transfer costs come about?",
"description": "When booking a train or air trip, the indicated price includes the arrival and departure as well as the accompaniment by a supervisor."
},
{
"title": "Where can I find the address/driving directions from the camp?",
"description": "You will receive the exact address and directions of the camp with the Last Travel Information about 3-4 weeks before the camp starts."
}
]
}
],
"video": {
"url": "https://www.youtube.com/embed/3NtE5wSwYTo?list=PLSOedrxa1c-bxvH6uuz_oZdIfJkov66wB&disablekb=1",
"title": "Anti Homesickness Adviser"
}
}

View File

@@ -1,80 +0,0 @@
{
"top": {
"bgImage": "/assets/img/home-1/footer-bg.jpg",
"phone": {
"display": "+84 961 83 4040",
"href": "tel:+84961834040"
},
"address": "734 Luy Ban Bich St, Tan Thanh Ward, Tan Phu Dist, HCMC",
"logo": {
"src": "/assets/img/logo/white-logo.svg",
"alt": "logo",
"href": "/"
},
"menuLinks": [
{
"label": "Home",
"href": "/"
},
{
"label": "About Us",
"href": "/about"
},
{
"label": "Visa",
"href": "/country-details"
},
{
"label": "Pages",
"href": "/news-details"
},
{
"label": "Article",
"href": "/news"
},
{
"label": "Contact Us",
"href": "/contact"
}
],
"socialLinks": [
{
"icon": "fa-brands fa-twitter",
"href": "#"
},
{
"icon": "fa-brands fa-instagram",
"href": "#"
},
{
"icon": "fa-brands fa-linkedin",
"href": "#"
},
{
"icon": "fa-brands fa-youtube",
"href": "#"
}
]
},
"bottom": {
"copyright": {
"text": "Copyright©",
"brand": "GRAMENTHEME",
"rights": "All Rights Reserved."
},
"menuLinks": [
{
"label": "Terms & Conditions",
"href": "/contact"
},
{
"label": "Privacy Policy",
"href": "/contact"
},
{
"label": "Contact Us",
"href": "/contact"
}
]
}
}

View File

@@ -1,159 +0,0 @@
[
{
"label": "Home",
"slug": "home",
"href": "/",
"type": "internal",
"order": 1,
"isActive": true,
"children": []
},
{
"label": "About Us",
"slug": "about-us",
"href": "/about",
"type": "internal",
"order": 2,
"isActive": true,
"children": []
},
{
"label": "Pages",
"slug": "pages",
"href": "#",
"type": "internal",
"order": 3,
"isActive": true,
"children": [
{
"label": "Services",
"slug": "services",
"href": "/services",
"type": "internal",
"order": 1,
"isActive": true,
"children": [
{
"label": "Service List",
"slug": "service-list",
"href": "/service",
"type": "internal",
"order": 1,
"isActive": true
},
{
"label": "Service Details",
"slug": "service-details",
"href": "/service-details",
"type": "internal",
"order": 2,
"isActive": true
}
]
},
{
"label": "Country List",
"slug": "country-list",
"href": "/country-list",
"type": "internal",
"order": 2,
"isActive": true,
"children": [
{
"label": "Country List",
"slug": "country-list-all",
"href": "/country-list",
"type": "internal",
"order": 1,
"isActive": true
},
{
"label": "Country Details",
"slug": "country-details",
"href": "/country-details",
"type": "internal",
"order": 2,
"isActive": true
}
]
},
{
"label": "Our Pricing",
"slug": "pricing",
"href": "/pricing",
"type": "internal",
"order": 3,
"isActive": true
},
{
"label": "Appointment",
"slug": "appointment",
"href": "/appointment",
"type": "internal",
"order": 4,
"isActive": true
},
{
"label": "FAQ",
"slug": "faq",
"href": "/faq",
"type": "internal",
"order": 5,
"isActive": true
}
]
},
{
"label": "VISA",
"slug": "visa",
"href": "#",
"type": "internal",
"order": 4,
"isActive": true,
"children": [
{
"label": "Visa List",
"slug": "visa-list",
"href": "/visa-list",
"type": "internal",
"order": 1,
"isActive": true
},
{
"label": "Visa Details",
"slug": "visa-details",
"href": "/visa-details",
"type": "internal",
"order": 2,
"isActive": true
}
]
},
{
"label": "Blog",
"slug": "blog",
"href": "/blog",
"type": "internal",
"order": 5,
"isActive": true,
"children": []
},
{
"label": "Contact Us",
"slug": "contact-us",
"href": "/contact",
"type": "internal",
"order": 6,
"isActive": true,
"children": []
},
{
"label": "External Portal",
"slug": "external-portal",
"href": "https://partner.hailearning.edu.vn",
"type": "external",
"order": 7,
"isActive": false,
"children": []
}
]

View File

@@ -1,52 +0,0 @@
{
"top": {
"phone": "+09 378 357 5222",
"email": "info@hailearning.edu.vn",
"location": "69 Street, 5th Avenue LA, United States",
"socialLinks": [
{
"platform": "linkedin",
"url": "https://linkedin.com",
"icon": "fa-brands fa-linkedin"
},
{
"platform": "twitter",
"url": "https://twitter.com",
"icon": "fa-brands fa-twitter"
},
{
"platform": "instagram",
"url": "https://instagram.com",
"icon": "fa-brands fa-instagram"
},
{
"platform": "youtube",
"url": "https://youtube.com",
"icon": "fa-brands fa-youtube"
}
],
"languages": [
{
"name": "English",
"value": "1"
},
{
"name": "Bangla",
"value": "2"
},
{
"name": "Hindi",
"value": "3"
}
]
},
"offcanvas": {
"description": "Nullam dignissim, ante scelerisque the is euismod fermentum odio sem semper the is erat, a feugiat leo urna eget eros. Duis Aenean a imperdiet risus.",
"contactInfo": {
"address": "Main Street, Melbourne, Australia",
"email": "info@hailearning.edu.vn",
"workingHours": "Mod-Friday, 09am - 05pm",
"phone": "+09 378 357 5222"
}
}
}

View File

@@ -1,334 +0,0 @@
{
"hero": {
"title": "From Application to Visa Weve Got You Covered",
"subtitle": "Global Education Simplified",
"description": "We guide you through every step of the education visa process, from initial application to final approval, ensuring a smooth, hassle-free journey.",
"primaryButton": {
"label": "Apply now",
"href": "/contact"
},
"secondaryButton": {
"label": "Book Free Consultation",
"href": "/contact"
},
"backgroundImage": "/assets/img/home-1/hero/bg.jpg",
"videoUrl": "https://www.youtube.com/watch?v=Cn4G2lZ_g2I"
},
"whyChooseUs": {
"heading": "Turning Study Abroad Dreams Into Reality",
"subheading": "About Our Consultancy",
"description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.",
"items": [
{
"icon": "/assets/img/home-1/icon/01.svg",
"title": "Global Reach",
"description": "Expanding Opportunities Worldwide"
},
{
"icon": "/assets/img/home-1/icon/01.svg",
"title": "Expert Guidance",
"description": "Professional Support Every Step"
}
],
"features": [
"Fastest Visa form processing with skilled immigration agents",
"Partnership with International Educational Institutions"
],
"ctaButton": {
"label": "Get Started",
"href": "/about"
}
},
"visaSolutions": {
"heading": "Comprehensive Visa Solutions",
"subheading": "Our Expert Services",
"items": [
{
"number": "01",
"title": "Student Visa Guidance",
"description": "Assistance with admission, documentation, and visa application.Assistance",
"link": "/services/student-visa"
},
{
"number": "02",
"title": "PTE Exam Preparation",
"description": "We provide expert guidance and personalized support throughout the education visa process,",
"link": "/services/pte-exam"
},
{
"number": "03",
"title": "University Selection Assistance",
"description": "We provide expert guidance and personalized support throughout the education visa process,",
"link": "/services/university-selection"
},
{
"number": "04",
"title": "IELTS Exam Preparation",
"description": "We provide expert guidance and personalized support throughout the education visa process,",
"link": "/services/ielts-exam"
}
]
},
"visaCountries": {
"heading": "Visa & VISAWAY Services To UK",
"subheading": "UK. United Kingdom",
"description": "The Express Entry program is designed for skilled workers who wish to immigrate to Canada. It includes the Federal Skilled Worker Program, the Federal Skilled…",
"countries": [
{
"name": "United Kingdom",
"code": "UK",
"flag": "/assets/img/home-1/feature/shape.png",
"link": "/country-details/uk",
"visaTypes": [
"Visitor Visa",
"Student Visa & Admission",
"Work Visa H1B",
"Business Visa",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"name": "United States",
"code": "US",
"flag": "/assets/img/flags/us.png",
"link": "/country-details/us",
"visaTypes": [
"Student Visa F-1",
"Work Visa H1-B",
"Tourist Visa B-2"
]
},
{
"name": "Canada",
"code": "CA",
"flag": "/assets/img/flags/canada.png",
"link": "/country-details/canada",
"visaTypes": [
"Study Permit",
"Work Permit",
"Express Entry"
]
},
{
"name": "Australia",
"code": "AU",
"flag": "/assets/img/flags/australia.png",
"link": "/country-details/australia",
"visaTypes": [
"Student Visa 500",
"Skilled Migration",
"Working Holiday"
]
},
{
"name": "Germany",
"code": "DE",
"flag": "/assets/img/flags/germany.png",
"link": "/country-details/germany",
"visaTypes": [
"Student Visa",
"Job Seeker Visa",
"EU Blue Card"
]
}
],
"ctaButton": {
"label": "Get Started",
"href": "/contact"
}
},
"testimonials": {
"heading": "Student Reviews & Testimonials",
"subheading": "What Our Students Say",
"videoUrl": "https://www.youtube.com/watch?v=Cn4G2lZ_g2I",
"videoThumbnail": "/assets/img/home-1/testimonial/01.jpg",
"items": [
{
"name": "Sohel Tanvir",
"role": "Student",
"country": "Canada",
"rating": 5,
"comment": "Professional and reliable service. They explained each step clearly, prepared my documents, and supported me during the interview. My visa approval came faster than expected.",
"avatar": "/assets/img/home-1/testimonial/client.png"
},
{
"name": "Ayesha Rahman",
"role": "Student",
"country": "UK. United Kingdom",
"rating": 5,
"comment": "The consultancy guided me from start to finish, making my study abroad journey smooth and stress-free. Thanks to their expert support, I secured my visa successfully.",
"avatar": "/assets/img/home-1/testimonial/client-2.png"
},
{
"name": "Michael Chen",
"role": "Graduate Student",
"country": "Australia",
"rating": 5,
"comment": "Outstanding service from beginning to end. The team was knowledgeable, responsive, and made the entire visa process seamless. Highly recommend to anyone planning to study abroad.",
"avatar": "/assets/img/home-1/testimonial/client.png"
}
]
},
"videoGallery": {
"heading": "VIDEO PLAY GALLERY",
"videoUrl": "https://ex-coders.com/vdo/visa.mp4",
"thumbnail": "/assets/img/home-1/feature/text.png"
},
"faq": {
"heading": "Got Questions? We've Got Answers",
"subheading": "Visa FAQs",
"description": "We understand students often have many questions about studying abroad. Our experts provide clear.",
"ctaButton": {
"label": "contact us",
"href": "/contact"
},
"items": [
{
"question": "How long does the student visa process usually take?",
"answer": "The student visa process typically takes 4-8 weeks depending on the country and time of year. We recommend starting the application process at least 3 months before your intended travel date to ensure sufficient time for document preparation and processing."
},
{
"question": "Do you assist with scholarship applications as well?",
"answer": "Yes, we guide students in identifying suitable scholarships, preparing strong applications, and increasing chances of securing financial aid for their studies abroad."
},
{
"question": "Will you guide me in preparing for the visa interview?",
"answer": "Absolutely! We provide comprehensive visa interview preparation, including mock interviews, document review, and tips on how to answer common questions confidently and effectively."
},
{
"question": "Do you offer post-arrival support for students?",
"answer": "Yes, we provide post-arrival support including airport pickup coordination, accommodation assistance, university orientation guidance, and ongoing support throughout your study period."
},
{
"question": "What documents are required for a student visa application?",
"answer": "Required documents typically include a valid passport, university acceptance letter, proof of financial support, academic transcripts, language proficiency test scores, and health insurance. We provide a complete checklist tailored to your destination country."
}
]
},
"achievements": {
"heading": "Our Achievements in Numbers",
"subheading": "Did You Know",
"items": [
{
"value": "1000",
"suffix": "k+",
"label": "Students Guided",
"description": "Successfully assisted over a thousand students worldwide."
},
{
"value": "50",
"suffix": "+",
"label": "Countries Covered",
"description": "Helping students apply to universities in more than 50 countries."
},
{
"value": "95",
"suffix": "%",
"label": "Visa Success Rate",
"description": "Inspired students to reach their goals globally"
},
{
"value": "10",
"suffix": "+",
"label": "Years of Experience",
"description": "Trusted experts in global education consulting."
}
]
},
"partners": {
"visaConsultancy": {
"heading": "Our Achievements & Awards",
"items": [
{
"name": "Best Visa Consultancy",
"icon": "/assets/img/home-1/feature/icon-1.png",
"year": "2025"
},
{
"name": "Visa Success Award",
"icon": "/assets/img/home-1/feature/icon-2.png",
"year": "2025"
},
{
"name": "Innovation Award",
"icon": "/assets/img/home-1/feature/icon-3.png",
"year": "2025"
},
{
"name": "Global Education Partner",
"icon": "/assets/img/home-1/feature/icon-4.png",
"year": "2025"
}
]
},
"brands": {
"items": [
{
"logo": "/assets/img/home-1/brand/01.png"
},
{
"logo": "/assets/img/home-1/brand/02.png"
},
{
"logo": "/assets/img/home-1/brand/03.png"
},
{
"logo": "/assets/img/home-1/brand/04.png"
},
{
"logo": "/assets/img/home-1/brand/05.png"
}
]
}
},
"blogPreview": {
"heading": "Latest Insights & Updates",
"subheading": "Visa Tips & Guides",
"ctaButton": {
"label": "view all articles",
"href": "/blog"
},
"items": [
{
"title": "Step-by-Step Guide to Applying for a Student Visa",
"excerpt": "Learn the complete process of applying for a student visa, from gathering documents to attending your interview. Our comprehensive guide covers everything you need to know.",
"category": "Student Visa",
"date": "2025-08-20",
"author": {
"name": "Sohel",
"avatar": "/assets/img/home-1/news/client.png"
},
"comments": 8,
"link": "/blog/step-by-step-guide-student-visa",
"thumbnail": "/assets/img/home-1/news/news-1.jpg"
},
{
"title": "Tips to Prepare Financial Documents for Visa Approval",
"excerpt": "Financial documentation is crucial for visa approval. Discover expert tips on preparing bank statements, sponsorship letters, and proof of funds that meet embassy requirements.",
"category": "IELTS / TOEFL",
"date": "2025-08-20",
"author": {
"name": "Sohel",
"avatar": "/assets/img/home-1/news/client.png"
},
"comments": 8,
"link": "/blog/financial-documents-visa-approval",
"thumbnail": "/assets/img/home-1/news/news-2.jpg"
},
{
"title": "Post-Arrival Guide What Every Student Should Know",
"excerpt": "Moving to a new country can be overwhelming. Our post-arrival guide helps international students navigate accommodation, banking, healthcare, and cultural adaptation successfully.",
"category": "Study Abroad",
"date": "2025-08-20",
"author": {
"name": "Sohel",
"avatar": "/assets/img/home-1/news/client.png"
},
"comments": 8,
"link": "/blog/post-arrival-guide-students",
"thumbnail": "/assets/img/home-1/news/news-3.jpg"
}
]
}
}

View File

@@ -1,75 +0,0 @@
{
"hero": {
"title": "Insurance & Travel Cancellation Guarantee",
"subtitle": "Comprehensive coverage for your peace of mind",
"backgroundImage": "/uploads/banner/b13.jpg",
"sectionClass": "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
"backgroundClasses": "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
"overlayStyle": {
"backgroundColor": "rgba(0, 0, 0, 0)"
},
"titleClass": "text-white text-[5vw] uk-text-center",
"subtitleClass": "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
"enableScrollspy": true
},
"page": {
"title": "Insurance & Travel Information",
"divider": true,
"sectionClass": "uk-section-default uk-section-overlap uk-section",
"titleClass": "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
"dividerClass": "uk-divider-small uk-text-left@m uk-text-center"
},
"content": {
"sectionClass": "uk-section-muted uk-section-overlap uk-section",
"textClass": "uk-panel uk-margin text-[1vw]",
"content": [
{
"type": "header",
"level": 2,
"text": "Our Go and Grow Camp Insurance Package"
},
{
"type": "paragraph",
"text": "Liability, casualty and health insurance"
},
{
"type": "paragraph",
"text": "<strong>Price:</strong> USD 45 per person/trip"
},
{
"type": "paragraph",
"text": "It only takes one mouse-click to book our comprehensive holiday insurance package for travels abroad, which includes a liability, casualty and health insurance for the entire duration of your journey. This ensures that your child is well insured in the unlikely event of an accident, a doctor's visit, a stay at the hospital or a misfortune causing damage to external property."
},
{
"type": "paragraph",
"text": "The insurance covers the whole duration of the trip, including the days of arrival and departure."
},
{
"type": "paragraph",
"text": "Please note that all participants without an EU insurance card/private health insurance or without a travel insurance package have to be prepared to cover the costs for medical treatment themselves. Go and Grow Camp does not provide any advance payment for doctor's bills. Non EU residents who do not book our insurance package have to submit a confirmation of their travel insurance."
},
{
"type": "header",
"level": 2,
"text": "Go and Grow Camp Travel Cancellation Guarantee"
},
{
"type": "paragraph",
"text": "It only takes one mouse-click to book our comprehensive holiday insurance package for travels abroad, which includes a liability, casualty and health insurance for the entire duration of your journey. This ensures that your child is well insured in the unlikely event of an accident, a doctor's visit, a stay at the hospital or a misfortune causing damage to external property."
},
{
"type": "paragraph",
"text": "The insurance covers the whole duration of the trip, including the days of arrival and departure."
},
{
"type": "paragraph",
"text": "Please note that all participants without an EU insurance card/private health insurance or without a travel insurance package have to be prepared to cover the costs for medical treatment themselves. Go and Grow Camp does not provide any advance payment for doctor's bills. Non EU residents who do not book our insurance package have to submit a confirmation of their travel insurance."
},
{
"type": "header",
"level": 2,
"text": "Go and Grow Camp - Cooperations & Memberships"
}
]
}
}

View File

@@ -1,100 +0,0 @@
{
"menus": [
{
"menuid": "info",
"parent": null,
"title": "Info",
"url": "#",
"order": 0,
"type": "static"
},
{
"menuid": "info-about-us",
"parent": "info",
"title": "About us",
"url": "/info/about-us",
"order": 0,
"type": "page"
},
{
"menuid": "info-safety",
"parent": "info",
"title": "Safety",
"url": "/info/safety",
"order": 1,
"type": "page"
},
{
"menuid": "info-faq",
"parent": "info",
"title": "FAQ",
"url": "/info/faq",
"order": 2,
"type": "page"
},
{
"menuid": "info-terms-conditions",
"parent": "info",
"title": "Terms & Conditions",
"url": "/info/terms-conditions",
"order": 3,
"type": "page"
},
{
"menuid": "info-insurance",
"parent": "info",
"title": "Insurance",
"url": "/info/insurance",
"order": 4,
"type": "page"
},
{
"menuid": "info-travel-documents",
"parent": "info",
"title": "Travel Documents",
"url": "/info/travel-documents",
"order": 5,
"type": "page"
},
{
"menuid": "camp-locations",
"parent": null,
"title": "Camp Locations",
"url": "/destinations",
"order": 1,
"type": "static"
},
{
"menuid": "activities",
"parent": null,
"title": "Activities",
"url": "/activities",
"order": 2,
"type": "static"
},
{
"menuid": "blog",
"parent": null,
"title": "Blog",
"url": "/blog",
"order": 3,
"type": "static"
},
{
"menuid": "contact-us",
"parent": null,
"title": "Contact US",
"url": "/contact-us",
"order": 4,
"type": "static"
},
{
"menuid": "booking",
"parent": null,
"title": "Booking",
"url": "/booking",
"order": 5,
"type": "static"
}
]
}

View File

@@ -1,118 +0,0 @@
{
"hero": {
"title": "Pricing Plan",
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg",
"shapeImage": "/assets/img/inner-page/shape.png",
"breadcrumb": [
{
"text": "Home",
"link": "/"
},
{
"text": "Pricing Plan",
"link": ""
}
]
},
"pricingSection": {
"subtitle": "pricing plan",
"heading": "Flexible Plans to Suit Every Traveler",
"description": "Choose the plan that fits your visa needs and enjoy expert guidance every step of the way."
},
"plans": {
"monthly": [
{
"name": "Basic Plan",
"price": "32",
"period": "mo",
"currency": "$",
"buttonText": "Get Started Today",
"buttonLink": "/pricing",
"buttonIcon": "fa-solid fa-arrow-right",
"style": "default",
"features": [
"Everything in Basic Plan",
"Visa Interview Preparation",
"Priority Processing Support",
"Phone & Email Assistance",
"Step-by-Step Application Support"
]
},
{
"name": "Premium Plan",
"price": "32",
"period": "mo",
"currency": "$",
"buttonText": "Get Started Today",
"buttonLink": "/pricing",
"buttonIcon": "fa-solid fa-arrow-right",
"style": "style-2",
"features": [
"Everything in Basic Plan",
"Visa Interview Preparation",
"Priority Processing Support",
"Phone & Email Assistance",
"Step-by-Step Application Support"
]
}
],
"yearly": [
{
"name": "Basic Plan",
"price": "32",
"period": "mo",
"currency": "$",
"buttonText": "Get Started Today",
"buttonLink": "/pricing",
"buttonIcon": "fa-solid fa-arrow-right",
"style": "default",
"features": [
"Everything in Basic Plan",
"Visa Interview Preparation",
"Priority Processing Support",
"Phone & Email Assistance",
"Step-by-Step Application Support"
]
},
{
"name": "Premium Plan",
"price": "32",
"period": "mo",
"currency": "$",
"buttonText": "Get Started Today",
"buttonLink": "/pricing",
"buttonIcon": "fa-solid fa-arrow-right",
"style": "style-2",
"features": [
"Everything in Basic Plan",
"Visa Interview Preparation",
"Priority Processing Support",
"Phone & Email Assistance",
"Step-by-Step Application Support"
]
}
]
},
"testimonials": {
"subtitle": "What Our Clients Say",
"heading": "Immigration Success Stories",
"buttonText": "View All Review",
"buttonLink": "/contact",
"buttonIcon": "fa-solid fa-arrow-right",
"image": "/assets/img/home-3/test-thumb.jpg",
"items": [
{
"name": "Mohammed Ali",
"role": "Family Visa",
"rating": 5,
"content": "The team provided exceptional guidance throughout my immigration process. Their expertise, personalized support, and attention to detail ensured a smooth, stress-free experience and successful visa approval."
},
{
"name": "Mohammed Ali",
"role": "Family Visa",
"rating": 5,
"content": "The team provided exceptional guidance throughout my immigration process. Their expertise, personalized support, and attention to detail ensured a smooth, stress-free experience and successful visa approval."
}
]
}
}

View File

@@ -1,212 +0,0 @@
{
"hero": {
"title": "Safety",
"banner": "/uploads/banner/b13.jpg"
},
"approach":{
"badge": "OUR APPROACH",
"title": "Learning, Comfort, and Confidence in Every Step",
"description": "Our camp philosophy ensures that every experience is exciting, engaging, and safe. We combine the thrill of outdoor exploration with a secure, well-managed environment where campers can grow, connect, and enjoy every moment.",
"imgs":
{
"img1": "/uploads/safety/pic1.jpg",
"img2": "/uploads/safety/pic2.jpg"
},
"stats":{
"count": "1,200+",
"label": "Happy Glampers Hosted",
"avatars": [
"https://i.pravatar.cc/100?img=1",
"https://i.pravatar.cc/100?img=5",
"https://i.pravatar.cc/100?img=8"
]
},
"features":[
{
"text":"Community built on trust and respect"
},
{
"text":"Shared responsibility for a safe environment"
},
{
"text":"Zero tolerance for discrimination or abuse"
},
{
"text":"Staff trained and supervised around the clock"
}
],
"cards":[
{
"title":"Camp Protection",
"content":"Comprehensive measures ensure every camper is safe, including trained staff, strict supervision, and clear emergency protocols throughout their stay."
},
{
"title":"Peace of Mind",
"content":"Parents and campers can feel confident knowing that safety, well-being, and support are prioritized at all times."
}
]
},
"philosophy":{
"title":"Go and Grow Camp",
"subtitle":"Our Philosophy",
"cards":[
{
"title":"Community",
"content":"What is most important for us at camp is the community. We want everyone participants, teamers and camp directors, no matter from which country or what culture to have an unforgettable time and every single one of us helps to reach this goal.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Responsibility",
"content":"We want everyone to help shape the daily life at camp. Besides playing this of course also includes social coexistence. Together with us your children keep the camp clean. This means cleaning the dishes and wiping the tables after a meals, as well as keeping the camp and sanitary facilities clean and tidying up the tents and huts together. All this of course, in a manner appropriate to the age of your children. This is how we, in shared responsibility, make everybody feel comfortable.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Internationality",
"content":"At camp new friendships arise even though some campers live thousands of kilometers apart. Our experienced campers immediately include newcomers because this is what they love camp for they come to make new friends and meet their fellow camp mates again. After our camp season many parents tell us about mutual visits some went to France, Spain or Canada. They also tell us about the increased motivation of their children to pay a little more attention to the language lessons at school so conversations at camp next summer become easier.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Log off, get outside",
"content":"We want all campers to have a relaxed holiday. Mobile phones are especially counterproductive to reach this goal. Therefore, our camps are mobile-free zones and we would like your children to hand over their phones and all other electronic devices to our teamers on the day of arrival so they can really relax. This also means that your children cannot be reached by phone outside the daily telephone hour which is after lunch.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"No power to drugs",
"content":"For legal reasons, as a result of our cooperation with the organization 'No power to drugs' and by our conviction that drugs don't belong into the hands of children and young adults, it is strictly forbidden for all campers to possess or consume any kind of drugs including cigarettes and alcoholic drinks. Non-compliance with this rule will lead to the suspension from camp or even criminal charges. It is our belief that with all our activities and the great atmosphere at camp, we offer much better alternatives anyway!",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Dealing with discrimination",
"content":"We would like to point out that we do not accept any form of discrimination, bullying or violence so that all campers can enjoy a happy, relaxed and safe holiday at camp.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
}
]
},
"security":{
"title":"Go and Grow Camp",
"subtitle":"Security Concept",
"cards":[
{
"title":"Background Check",
"content":"Every counselor, chef, teamer or helper that enters our camps has to be registrated, complete a background check, as well as have references. That's why parents are only allowed on the camp site on the day of arrival and departure and not during the week. We want to make sure that we have checked and know every adult who is with us at camp.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Education",
"content":"Each counselor must complete an almost two-week training course with us, from early in the morning until late in the evening includes so many lessons that the number of hours even corresponds to the basic study in educational sciences. Here we focus on the areas of safety, accident prevention, child psychology and needs as well as the various safety aspects in the field of experiential education.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Crisis Intervention",
"content":"If something should happen, it is not only important to provide first aid for the affected person, but also to care for the other children and adolescents. We have a specially trained team for crisis intervention, which then provides immediate care and can thus prevent possible traumatisation due to the experience.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Nightwatch",
"content":"All our camps are also supervised at night by the counselors/teamers. On the one hand we want to prevent visitors from coming to the site - which has not happened until today - and on the other hand we want to be there for the children if they wake up at night and get homesick or have to go to the toilet. The nightwatch patrols the area and is otherwise reachable at a central place for the children. Some of our locations - e.g. the headquarters in Walsrode - are also video-monitored and fenced in.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Caregiver Key",
"content":"No safety without sufficient staff! We are the leaders in Germany with our great caregiver key. There are no camps that have a key worse than 1:6-1:8, which means that one caregiver is responsible for a maximum of 6-8 children. In the junior camps we also use our CIT (Counselor in Training), so that we often reach a key of only 1:4. We know that this key can seem exaggerated, but we want to guarantee the highest possible safety and we firmly believe that this is exactly what our high level of caregiver commitment leads to.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Cooperation",
"content":"Cooperation with the independent representative for questions of sexual child abuse via our umbrella organisation Reisenetz e.V.: Go and Grow Camp was one of the first tour operators for children and young people to develop a protection concept that prevents sexual abuse among children and young people. Today, this concept is considered important by many other tour operators, also due to our personal commitment in various associations and professional circles. Of course, the background check and the '6-eyes principle', which states that a child must never be alone with a caregiver, is also an essential part of our protection concept. The most important thing, however, is to create an 'open system' in which everyone knows that sexual abuse should not be a taboo subject, but that simple instruments such as a grievance box and feedback system can immediately address grievances and that they do not have to be denied.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Quality",
"content":"As a member of the quality committee of the professional association for children and youth travel 'Reisenetz', our managing director Jan Vieth is responsible for further developing and checking the quality guidelines of the entire industry. As Germany's ambassador to the ICF, he is also kept up to date on improvements in camp and training quality worldwide and adapts these as quickly as possible to our own camps.",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"Accessibility",
"content":"Of course, all parents receive a number from us, which allows them to reach us 24 hours a day in an emergency. If an emergency occurs at your home, you can inform us immediately and we can decide together how, when and whether to inform your child",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
},
{
"title":"In case of emergency",
"content":"Every caregiver has a valid first aid certificate and can help if necessary",
"author":{
"avt":"https://i.pravatar.cc/150?img=12",
"name":"abc",
"role":"customer",
"rating":"5"
}
}
]
}
}

View File

@@ -1,363 +0,0 @@
{
"pageTitle": "Visaway Immigration & Visa Consulting HTML Template",
"services": {
"title": {
"subTitle": "What We Offer",
"mainTitle": "Our Immigration Services"
},
"items": [
{
"slug": "immigration-appeal",
"name": "Immigration Appeal & Legal Support",
"description": "Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation.",
"image": "/img/home-3/service/01.jpg",
"layout": "left",
"details": {
"title": "Immigration Appeal & Legal Support",
"description": "Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation. We analyze your case thoroughly and develop custom strategies to maximize your chances of success.",
"mainImage": "/img/inner-page/service-details/details-1.jpg",
"overviewTitle": "Service Overview",
"overviewDescription": "Our Immigration Appeal & Legal Support service is designed to help clients navigate complex immigration challenges. We provide expert legal guidance, case analysis, and strategic representation to maximize your chances of success. With our expert consultants, personalized approach, and global network, we ensure a smooth transition for every client.",
"additionalDescription": "From start to finish, we are committed to turning your immigration challenges into success stories through professional legal representation and strategic planning.",
"keyFeaturesTitle": "Key Features",
"keyFeaturesImage": "/img/inner-page/service-details/details-2.jpg",
"features": [
{
"title": "Personalized Guidance",
"description": "Tailored support for each client's specific legal situation and requirements."
},
{
"title": "Expert Legal Team",
"description": "Experienced immigration lawyers with proven track records in appeals."
},
{
"title": "Case Analysis & Strategy",
"description": "Thorough case review and development of winning appeal strategies."
},
{
"title": "Document Preparation",
"description": "Professional preparation of all legal documents and supporting evidence."
},
{
"title": "Court Representation",
"description": "Expert representation in immigration courts and tribunals."
},
{
"title": "Success Monitoring",
"description": "Regular updates and monitoring throughout the appeal process."
}
],
"faqTitle": "Frequently Asked Question",
"faqImage": "/img/inner-page/service-details/details-3.jpg",
"faq": [
{
"id": "faq-appeal-1",
"question": "01. What are the chances of a successful appeal?",
"answer": "Success rates vary by case type and circumstances, but our experienced legal team significantly improves your chances through thorough case analysis and strategic representation tailored to your specific situation.",
"isExpanded": false
},
{
"id": "faq-appeal-2",
"question": "02. How long does the appeal process take?",
"answer": "Appeal timelines vary by jurisdiction and case complexity, typically ranging from 6-18 months. We keep you informed throughout the process and work to expedite where possible.",
"isExpanded": false
},
{
"id": "faq-appeal-3",
"question": "03. What documents do I need for an appeal?",
"answer": "Required documents vary by case but typically include the original decision, supporting evidence, and legal submissions. We provide a comprehensive checklist and assist with document preparation.",
"isExpanded": false
},
{
"id": "faq-appeal-4",
"question": "04. Do you handle all types of immigration appeals?",
"answer": "Yes, we handle various types of immigration appeals including visa refusals, deportation orders, and residency rejections. Our team has expertise across all immigration categories.",
"isExpanded": false
}
]
}
},
{
"slug": "scholarship-guidance",
"name": "Scholarship & Study Grant Guidance",
"description": "We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals.",
"image": "/img/home-3/service/02.jpg",
"layout": "right",
"details": {
"title": "Scholarship & Study Grant Guidance",
"description": "We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals. From preparing strong applications to meeting eligibility criteria, we ensure you maximize your chances of securing financial aid.",
"mainImage": "/img/inner-page/service-details/details-1.jpg",
"overviewTitle": "Service Overview",
"overviewDescription": "Our Education Visa Consultancy is dedicated to guiding students in achieving their study abroad dreams. We provide complete support including university selection, application assistance, scholarship guidance, visa documentation, and interview preparation. With our expert consultants, personalized approach, and global network, we ensure a smooth transition for every student.",
"additionalDescription": "From start to finish, we are committed to turning your education journey into a successful international experience.",
"keyFeaturesTitle": "Key Features",
"keyFeaturesImage": "/img/inner-page/service-details/details-2.jpg",
"features": [
{
"title": "Personalized Guidance",
"description": "Tailored support for each student's goals and requirements."
},
{
"title": "Target Audience & Persona Development",
"description": "Experienced team with global education and visa knowledge."
},
{
"title": "Scholarship & Grant Assistance",
"description": "Helping students secure financial aid opportunities."
},
{
"title": "Visa Application Support",
"description": "Step-by-step guidance for smooth visa processing."
},
{
"title": "Interview Preparation",
"description": "Coaching for successful student visa interviews."
},
{
"title": "Documentation Assistance",
"description": "Accurate and complete paperwork for faster approvals."
}
],
"faqTitle": "Frequently Asked Question",
"faqImage": "/img/inner-page/service-details/details-3.jpg",
"faq": [
{
"id": "faq-scholarship-1",
"question": "01. Do you assist with university selection?",
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
"isExpanded": false
},
{
"id": "faq-scholarship-2",
"question": "02. Can you help with scholarship applications?",
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
"isExpanded": true
},
{
"id": "faq-scholarship-3",
"question": "03. How long does the visa process take?",
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
"isExpanded": false
},
{
"id": "faq-scholarship-4",
"question": "04. Is post-arrival support available?",
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
"isExpanded": false
}
]
}
},
{
"slug": "permanent-residency",
"name": "Permanent Residency (PR) Services",
"description": "Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.",
"image": "/img/home-3/service/03.jpg",
"layout": "left",
"details": {
"title": "Permanent Residency (PR) Services",
"description": "Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.",
"mainImage": "/img/inner-page/service-details/details-1.jpg",
"overviewTitle": "Service Overview",
"overviewDescription": "Our Permanent Residency services provide comprehensive support for individuals seeking to establish permanent residence in their chosen country. We handle all aspects of the PR application process with expertise and care.",
"additionalDescription": "Our experienced team ensures that your PR application is handled professionally and efficiently, maximizing your chances of approval.",
"keyFeaturesTitle": "Key Features",
"keyFeaturesImage": "/img/inner-page/service-details/details-2.jpg",
"features": [
{
"title": "Eligibility Assessment",
"description": "Comprehensive evaluation of your PR eligibility and options."
},
{
"title": "Points Calculation",
"description": "Accurate calculation and optimization of your points score."
},
{
"title": "Document Verification",
"description": "Thorough verification and preparation of all required documents."
},
{
"title": "Application Tracking",
"description": "Regular updates and tracking of your PR application status."
},
{
"title": "Interview Preparation",
"description": "Coaching and preparation for PR interviews if required."
},
{
"title": "Post-Approval Support",
"description": "Guidance on next steps after PR approval and settlement."
}
],
"faqTitle": "Frequently Asked Question",
"faqImage": "/img/inner-page/service-details/details-3.jpg",
"faq": [
{
"id": "faq-pr-1",
"question": "01. How long does the PR process take?",
"answer": "Processing times vary by country and program, typically ranging from 12-24 months. We provide realistic timelines based on current processing standards.",
"isExpanded": false
},
{
"id": "faq-pr-2",
"question": "02. What documents are required for PR application?",
"answer": "Document requirements vary by country but typically include educational credentials, work experience, language test results, and medical examinations. We provide a complete checklist.",
"isExpanded": true
},
{
"id": "faq-pr-3",
"question": "03. Can I include my family in the PR application?",
"answer": "Yes, most PR programs allow you to include your spouse and dependent children. We help you understand family inclusion requirements and processes.",
"isExpanded": false
},
{
"id": "faq-pr-4",
"question": "04. What happens if my PR application is rejected?",
"answer": "If rejected, we analyze the reasons and explore options including appeals, reapplication, or alternative immigration pathways to achieve your goals.",
"isExpanded": false
}
]
}
},
{
"slug": "citizenship-naturalization",
"name": "Citizenship & Naturalization Guidance",
"description": "We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.",
"image": "/img/home-3/service/04.jpg",
"layout": "right",
"details": {
"title": "Citizenship & Naturalization Guidance",
"description": "We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.",
"mainImage": "/img/inner-page/service-details/details-1.jpg",
"overviewTitle": "Service Overview",
"overviewDescription": "Our Citizenship & Naturalization service helps individuals navigate the complex process of becoming a citizen. We provide step-by-step guidance, documentation support, and legal expertise throughout the entire process.",
"additionalDescription": "With our comprehensive approach, we make the path to citizenship clear, manageable, and successful for every client.",
"keyFeaturesTitle": "Key Features",
"keyFeaturesImage": "/img/inner-page/service-details/details-2.jpg",
"features": [
{
"title": "Citizenship Test Preparation",
"description": "Comprehensive preparation for citizenship knowledge tests."
},
{
"title": "Language Requirements",
"description": "Guidance on meeting language proficiency requirements."
},
{
"title": "Residency Verification",
"description": "Assistance with proving residency and physical presence requirements."
},
{
"title": "Application Processing",
"description": "Complete support throughout the citizenship application process."
},
{
"title": "Interview Coaching",
"description": "Preparation and coaching for citizenship interviews."
},
{
"title": "Ceremony Preparation",
"description": "Support and guidance for the citizenship ceremony process."
}
],
"faqTitle": "Frequently Asked Question",
"faqImage": "/img/inner-page/service-details/details-3.jpg",
"faq": [
{
"id": "faq-citizenship-1",
"question": "What are the basic requirements for citizenship?",
"answer": "Requirements typically include permanent residency, physical presence, language proficiency, and knowledge of the country's history and government. Specific requirements vary by country.",
"isExpanded": false
},
{
"id": "faq-citizenship-2",
"question": "How do I prepare for the citizenship test?",
"answer": "We provide comprehensive study materials, practice tests, and coaching sessions to help you prepare for both the knowledge test and language requirements.",
"isExpanded": false
},
{
"id": "faq-citizenship-3",
"question": "How long does the citizenship process take?",
"answer": "Processing times vary by country but typically range from 12-24 months from application to ceremony. We help you understand specific timelines for your situation.",
"isExpanded": false
},
{
"id": "faq-citizenship-4",
"question": "Can I maintain dual citizenship?",
"answer": "Dual citizenship policies vary by country. We help you understand the implications and requirements for maintaining multiple citizenships if applicable.",
"isExpanded": false
}
]
}
}
]
},
"destinations": {
"backgroundImage": "/img/home-3/choose-us/bg.png",
"title": {
"subTitle": "Countries we offer",
"mainTitle": "Choose Your Immigration Destination"
}
},
"visas": {
"items": [
{
"id": "family-visa",
"number": "01",
"name": "Family Visa",
"description": "Our Family Visa services help reunite loved ones by providing expert guidance.",
"buttonText": "service _ 02",
"buttonLink": "service-details.html"
},
{
"id": "student-visa",
"number": "02",
"name": "Student Visa",
"description": "We provide expert guidance for student visa applications.",
"buttonText": "service _ 02",
"buttonLink": "service-details.html"
},
{
"id": "work-visa",
"number": "03",
"name": "Work Visa",
"description": "Collaboratively disintermediate one to one functionalities and long term.",
"buttonText": "service _ 02",
"buttonLink": "service-details.html"
}
]
},
"reviews": {
"title": {
"subTitle": "What Our Clients Say",
"mainTitle": "Immigration Success Stories"
},
"thumb": "/img/home-3/test-thumb.jpg",
"items": [
{
"id": "client-review-1",
"rating": 5,
"content": "The team provided exceptional guidance throughout my immigration process.",
"author": {
"name": "Mohammed Ali,",
"type": "Family Visa"
},
"icon": "fa-solid fa-quote-right"
},
{
"id": "client-review-2",
"rating": 5,
"content": "Their expertise and personalized support ensured a smooth visa approval.",
"author": {
"name": "Sarah Johnson,",
"type": "Student Visa"
},
"icon": "fa-solid fa-quote-right"
}
]
}
}

View File

@@ -1,152 +0,0 @@
{
"hero": {
"title": "Frequently Asked Questions",
"backgroundImage": "/uploads/terms/faqimage.jpg",
"sectionClass": "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
"backgroundClasses": "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
"overlayStyle": {
"backgroundColor": "rgba(0, 0, 0, 0)"
},
"titleClass": "text-white text-[5vw] uk-text-center",
"enableScrollspy": true
},
"page": {
"title": "Terms & Conditions Go and Grow Camp e.K.",
"divider": true,
"sectionClass": "uk-section-default uk-section-overlap uk-section",
"titleClass": "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
"dividerClass": "uk-divider-small uk-text-left@m uk-text-center"
},
"content": {
"sectionClass": "uk-section-muted uk-section-overlap uk-section",
"textClass": "uk-panel uk-margin text-[1vw]",
"content": [
{
"type": "paragraph",
"text": "This is an English translation of the original and legally binding German document \"Allgemeine Geschäftsbedingungen Go and Grow Camp e.K.\", which can be viewed at <a href=\"https://www.campadventure.de/de/infos/agb\" target=\"_self\">https://www.campadventure.de/de/infos/agb</a>. This translation is for your information only and is not legally binding."
},
{
"type": "paragraph",
"text": "<strong>Go and Grow Camp e.K. is the tour operator for individuals, for camps in Germany, England and Northern Ireland.</strong>"
},
{
"type": "paragraph",
"text": "GUARANTEE: All participants are protected in accordance with the legal regulations governing tour operators in Germany. As per §651, any payments made towards the travel price are insured against insolvency by tourVers."
},
{
"type": "paragraph",
"text": "The following terms and conditions of travel apply to package travel contracts, to which the §§ 651a ff BGB regulations relating to travel contracts apply. The provisions, in so far as these have been effectively agreed, become part of the contract formed between the traveler and tour operator. They supplement and complete the legal regulations of §§ 651 a to y BGB and Articles 250 und 252 EGBGB."
},
{
"type": "section",
"title": "1. Conclusion of the travel contract",
"content": "By registering for travel, the traveler submits a binding offer to conclude the travel agreement. Registrations can be made verbally, by telephone, in writing, by email or by electronic means, such as the internet booking system \"Book a Camp\". The contract comes into effect once a declaration of acceptance has been received. The tour operator will provide the traveler with a booking confirmation in line with legal requirements in a durable medium, unless the traveler is entitled to a travel confirmation in paper form under Article 250 § 6 Paragraph 1 Clause 2 EGBG. If the registration is made electronically, the contract is concluded once the traveler has received confirmation from the tour operator in a durable medium. If the corresponding travel confirmation is displayed directly after using the \"place a binding order\" button, the contract comes into effect upon display of this confirmation. The traveler will receive travel documents 2-3 weeks before the start of the trip. Any additional agreements, arrangements and wishes must be confirmed by us in writing, otherwise the services laid out in the contract apply. The traveler is liable for all contractual obligations of travelers that he registers, just as he is for his own, provided that he has assumed this obligation through an explicit and separate declaration. Should the contents of the booking confirmation deviate from the content of the booking, this constitutes a new offer, to which the tour operator is bound for a period of 10 days. The contract takes effect on the basis of this new offer, provided that the tour operator has indicated the changes relating to this new offer and has fulfilled his precontractual information duties and that the traveler gives the tour operator express consent, either through explicit declaration or deposit, within the commitment period. Pursuant to the legal regulation § 312 g Para. 2, Clause 1 Nr. 9 BGB and relating to all of the above-mentioned booking types, no right of withdrawal exists for distance contracts after contract conclusion. However, withdrawal from the contract on the basis of § 651 h BGB is possible at any time."
},
{
"type": "section",
"title": "2. Terms of payment",
"content": "Go and Grow Camp e.K. shall only request or accept payments towards the travel price before the completion of the trip if the traveler has been provided with a guarantee certificate, stating the name and contact details of the credit institution, in accordance with § 651 r Abs. 4 BGB. A deposit of USD 50 per participant is due within one week of registration and after the issue of a guarantee certificate. The outstanding balance must be transferred, without specific request, no later than four weeks before the start of the trip, provided that the guarantee certificate has been issued and that the tour operator has not exercised its right of withdrawal on the grounds stated in Point 7. If, even after notification, the specified deposit sum is not payed, or the travel price has not been paid in full, prior to the commencement of the trip, although the tour operator is ready to provide the contractual services, has fulfilled all legal obligations and the client has no legal or contractual right of retention, the tour operator is entitled to withdraw from the travel contract after issuing a reminder with a deadline and to charge cancellation fees to the traveler."
},
{
"type": "section",
"title": "3. Services and service modifications",
"content": "a) Our services are defined in our service descriptions and general program information found on the website <a href=\"/\" target=\"_self\">https://www.campadventure.de/en/</a> and in the information given in the travel confirmation. Any additional agreements affecting the scope of the contractual services must be confirmed by us in written form.<br/>b) Luggage will be transported without any additional fee, as long as it does not exceed the norms, here defined as a maximum of 1 suitcase and 1 piece of hand luggage per person.<br/>c) External services arranged by us as part of the journey are not part of the initial travel contract, as long as these services are clearly marked as such with the identity and address of the contractual partner in the travel information and travel confirmation, such that the traveler can recognize that these are not part of the travel services offered by the tour operator.<br/>d) Any modifications to and deviations from the essential travel services agreed upon in the travel contract that become necessary after conclusion of the contract and are made in good faith, are permissible as long as the modifications and deviations are not substantial and do not impact the overall arrangement of the booked trip.<br/>e) The tour operator is obliged to inform the traveler of the reasons for a permissible modification to the essential travel service immediately, clearly, understandably and in a durable medium.<br/>f) In the event of a substantial change to an essential travel service or a deviation from special provisions stipulated in the contract for a traveler, the traveler is entitled to withdraw from the contract or demand another journey of at least equivalent value by the deadline specified at the same time as the contract change. This only applies if the tour operator is in a position to offer such a trip without any extra cost to the traveler. The traveler is free to decide whether to respond to the communication or not. The traveler is obliged to exercise these rights after being notified of the change. If the traveler does not respond by the specified deadline or at all, the communicated changes will be understood to be accepted. Any warranty claims remain unaffected, in so far as the modified services are deficient."
},
{
"type": "section",
"title": "4. Customer cancellation",
"content": "The traveler is advised to communicate cancellation in a durable medium. Should the traveler withdraw from the travel contract before the start of the trip, or should he not begin the trip, the tour operator may claim fair compensation, provided it is not responsible for the withdrawal and that no exceptional circumstances have arisen at the destination or in the immediate vicinity, which have a significant effect on the execution of the trip or the transportation of persons to the destination. The compensation value is based on the travel price less the value of the costs saved by the tour operator and the sum that the tour operator is able to earn through alternative use of its services. The standard rates are based on the time period between the notice of cancellation and the start of the trip, as well as the expected saved expenses and the possible sum resulting from any other use of travel services. Upon receipt of notice of cancellation, compensation is calculated according to a sliding percentage scale, as follows (cancellation costs per person):",
"subsections": [
{
"type": "cancellation_table",
"title": "Standard Cancellation Fees",
"items": [
"cancellation up to 60 days before the beginning of the trip USD 50/100",
"cancellation up to 31 days before the beginning of the trip 30% of travel costs, USD 50 minimum",
"cancellation up to 14 days before the beginning of the trip 50% of travel costs, USD 50 minimum",
"cancellation up to 1 day before the beginning of the trip 80% of travel costs, USD 50 minimum",
"cancellation on the day of arrival or later 90% of travel costs"
]
},
{
"type": "cancellation_section",
"title": "Cancellation policy for school groups:",
"items": [
"A correction of student numbers up to 10% students is free of charge. Any higher alteration of numbers will lead to an extra cost.",
"Cancellation till 60 days before start of the trip: the fee will be 20% of the total price.",
"Cancellation till 30 days before start of the trip: the fee will be 40% of the total price.",
"Cancellation till 14 days before start of the trip: the fee will be 60% of the total price.",
"Cancellation till 1 day before start of the trip: the fee will be 90% of the total price.",
"Any later cancellations till the day before the trip: the fee will be 100% of the total price."
]
},
{
"type": "note",
"text": "In any event, it is up to the customer to demonstrate that compensation owed to the tour operator is significantly lower that the cancellation fee claimed. The tour operator reserves the right, by way of deviation from the above charges, to claim a higher, individually calculated compensation sum, insofar as it can prove that significantly greater expenses than the relevant flat rate were incurred. In this case, the tour operator is required to calculate and prove these extra costs, taking into account the costs saved by the tour operator and the sum that the tour operator is able to earn through alternative use of the services. Following cancellation, the tour operator is obliged to issue a refund immediately, but in any case within 14 days of receipt of the notice of cancellation. § 651 e BGB remains unaffected by the above conditions. It is recommended that travelers take out cancellation insurance."
}
]
},
{
"type": "section",
"title": "5. Modifications at the traveler's request",
"content": "After conclusion of the contract the traveler may not change travel dates, the destination, starting location, accommodation or mode of transport. This does not apply if the change to the booking is necessary because the tour operator provided the traveler due to inadequate or false precontractual information provided by the tour operator, as per Art. 250 § 3 EGBGB. In this case, travel may be rebooked at no extra cost. Should the traveler demand changes or rebooking after conclusion of the contract, up to 32 days before departure, the tour operator is entitled to charge a processing fee of USD 20, unless the tour operator demonstrates that higher compensation is due, the sum of which is based on the travel price minus the costs saved by the tour operator and the sum that the tour operator is able to earn through alternative use of its services. Requests to change bookings after this period can only be honored, if at all, by withdrawing from the travel contract and simultaneously reregistering, as per Section 4. This does not apply to requests only resulting in minor additional costs."
},
{
"type": "section",
"title": "6. Disruption by the traveler",
"content": "If the traveler continuously disrupts the travel program, despite warnings from the tour operator, or behaves contrary to the contract, such that immediate termination of the contract is justified, the tour operator may cancel the travel contract without notification. This also applies when the traveler does not consider reasonable and well-founded instructions. In such cases, the tour operator is entitled to retain the full travel price, minus the costs saved by the tour operator and the sum that the tour operator is able to earn through alternative use of the unused service, including any sums credited to it by service providers, so the daily rate can be reduced by 20% as a result of savings made by services not provided. Compensation claims remain unaffected. This shall not apply if such behavior contrary to the terms of the contract is a result of a breach of information duties on the part of the tour operator."
},
{
"type": "section",
"title": "7. Minimum number of participants",
"content": "If the number of participants registered for our holiday camps our transfer services is less than 10-60 participants (depending on the trip), the tour operator may withdraw from the travel contract up to 6 weeks before the start of the trip. The tour operator must have stated the minimum number of participants for the relevant trip and the latest date by which the traveler must be informed of cancellation in the travel information and must also have clearly stated the minimum number of participants and the latest possible date of withdrawal in the travel confirmation. If it is evident at an earlier stage that the minimum number of participants will not be reached, the tour operator is obliged to inform the traveler immediately. If the trip does not take place for this reason, the tour operator is obliged to issue a refund of any payments made on the travel price immediately and in any case within 14 days of notice of withdrawal."
},
{
"type": "section",
"title": "8. Warranty and remedy",
"content": "Should services not be rendered according to the contract, the traveler is entitled to claim legal warranty rights for a reduction in the trip price, according to § 651 m BGB, provided that the traveler has not failed in his contractual duties to report any faults to the tour operator which may have occurred during the provision of services. In the event of a defect during the tour, the traveler can only remedy the defect himself or, in the case of a considerable defect, as described in § 651 i Abs. 2 BGB, cancel the trip, according to § 651 l BGB, as long as the tour operator has been given an adequate time to remedy the defect. A deadline need not be defined if remedial action is impossible or rejected by the tour operator or if immediate remedial action or termination is justified due to particular interests of the client. The traveler is obliged to inform the tour operator of any defect immediately and on the spot. Defects should be reported to the tour manager of the tour operator, to the contact person at the contact address or the tour operator directly. Should a representative of the tour operator not be available or contractually obliged, the tour operator must be informed of any defects relating to the trip at the following address: Go and Grow Camp e.K., Museumstr. 39, 22765 Hamburg. It is recommended that such notifications are made in a durable medium. In accordance with § 651 j BGB, claims shall lapse two years after the final day of the trip, as defined by the contract. We refer to the mutual assistance clause under § 651 q BGB, according to which the traveler is entitled to adequate assistance, notably through the provision of appropriate information concerning healthcare services, local authorities and consular assistance, as well as support in establishing communication links and in the search for other travel options, without delay in the event of § 651 k Para. 4 BGB or if the traveler faces difficulties for other reasons. § 651 k Para. 3 BGB remains unaffected."
},
{
"type": "section",
"title": "9. Traveler's duty of cooperation",
"content": "The passenger is obliged to cooperate within the framework of legal regulations and to avoid or minimize potential damages. In the case of travel involving minors, it is the person with the supervisory role and not the tour operator, who is liable for any damages that arise. A violation of regulations may result in exclusion from the trip, as stipulated in Point 6 \"Disruption by the traveler\". Destruction, loss, damage or delay of baggage must be communicated to the transport company immediately. The transport company is required to issue written confirmation. In the case of no notification, there is a danger of losing the right to claims. The tour operator recommends that damage or delay in delivery when travelling by air is urgently and immediately reported to the relevant airline on the spot by means of a property irregularity report (P.I.R.). As a rule, airlines refuse to provide compensation if a property irregularity report has not been completed. The property irregularity report must be submitted within 7 days for lost luggage and within 21 days of delivery of delayed luggage. Otherwise, loss, damage or misdirection of baggage must be reported to the tour operator or to the local representative of the operator. This does not release the traveler from providing the airline with a property irregularity report within the above-mentioned periods."
},
{
"type": "section",
"title": "10. Limitation of liability",
"content": "The tour operator's contractual liability for damages, not including damage to the body, nor damage caused by the negligence of the tour operator, is limited to three times the tour price. Any claims under international agreements or on legal regulations based on these remain unaffected by this limitation. We are not liable for service disruptions, personal injury or property damage in connection with third party services that are explicitly designated as such in the travel description and travel confirmation, where the name and address of the contract partner are given, in such a way that the traveler can clearly recognize that these are not an integral part of the travel services offered by the tour operator and that these are chosen separately. This applies in particular to additional programs over the course of the trip. §§ 651 b, 651 c, 651 w und 651 y remain unaffected. The tour operator is however liable if and insofar as the traveler suffers damages as a result of the failure of the tour operator to fulfill its information, clarification and organization obligations."
},
{
"type": "section",
"title": "11. Passport, visa and health requirements",
"content": "The tour operator will inform the customer of any important changes to the general regulations contained in the travel announcement before the start of the trip. Before conclusion of the contract, the tour operator will inform the traveler of visa requirements and health formalities applicable to the destination country, including approximate periods for obtaining the necessary visa and will inform the traveler of any changes to these before the start of the trip. The tour operator shall not be liable for the timely issue and acquisition of necessary visas from the relevant diplomatic representation, if the traveler has charged the tour operator with the procurement of visas, unless the tour operator neglected its duties or is responsible for the delay. The traveler is responsible for compliance with all regulations important for the operation of the tour. The traveler is responsible for obtaining and carrying the necessary travel documents, any necessary vaccinations and for adhering to customs and foreign exchange regulations. Any disadvantages arising from failure to comply with these regulations, including but not limited to the payment of cancellation fees, shall be at the traveler's cost. This does not apply if the tour operator has not provided information, or if the information provided proves to be insufficient or false."
},
{
"type": "section",
"title": "12. Data protection",
"content": "The protection of clients' privacy and personal data is very important to Go and Grow Camp. Go and Grow Camp collects and processes data according to legal regulations. Personal data is only stored when necessary for the performance of booked services or to comply with legal regulations."
},
{
"type": "section",
"title": "13. Place of jurisdiction",
"content": "The entire legal and contractual relationship between the travel operator and travelers with no general place of residence or registered office in Germany shall be governed exclusively by German law, on the proviso that, should the traveler have a general place of residence in another country in accordance with Art. 6 Para. 2 of the Rome I Regulation, they are also protected by any mandatory rules of law in that country, which would not otherwise apply. The traveler can take legal action against the tour operator only at its registered office. Should the travel operator take legal action against the traveler, the domicile of the traveler is decisive, unless action is directed against registered traders or persons who have changed their residence or customary place of abode to a foreign country or whose residence or customary place of abode is not known at the time when legal action is brought. In such cases, the registered office of the tour operator is decisive. With respect to the law concerning consumer dispute resolution, the tour operator advises that it will not take part in any voluntary dispute settlement. Should the tour operator be obliged to take part in a dispute settlement after the printing of these travel conditions, the tour operator will inform the traveler of this in appropriate form. In relation to all travel contracts concluded electronically, the tour operator refers to the European online dispute resolution platform <a href=\"http://ec.europa.eu/consumers/odr/\">http://ec.europa.eu/consumers/odr/</a>."
},
{
"type": "section",
"title": "14. Identity of the operating airline",
"content": "Should the travel contract include transport by plane, the traveler will be informed of the identity and name(s) of the operating airline(s) providing all air transport services as part of the booked trip. Should the identity of the airline(s) be undetermined at the time of booking, the tour operator will inform the traveler of the airline or airlines that are most likely to operate the flight or flights and will inform the traveler immediately, as soon as this is determined. The tour operator must inform the traveler immediately if the airline is changed. The tour operator must take all appropriate steps to ensure that the customer is informed of the change as quickly as possible. The list of airlines on the EU blacklist can be found here: <a href=\"https://ec.europa.eu/transport/modes/air/safety/air-ban/search_en\" target=\"_blank\" rel=\"noopener noreferrer\">https://ec.europa.eu/transport/modes/air/safety/air-ban/search_en</a>"
},
{
"type": "section",
"title": "15. Invalidity of individual terms",
"content": "The invalidity of individual terms does not render other conditions or the contract as a whole invalid. 16. VAT Exemption in accordance with § 4 Nr. 23 UstG, Go and Grow Camp e.K. is exempt from sales tax for all child and youth travel."
},
{
"type": "paragraph",
"text": "Last updated: August 2018"
}
]
}
}

View File

@@ -1,34 +0,0 @@
{
"hero": {
"title": "Go and Grow Camp\nLast travel informations",
"backgroundImage": "/uploads/banner/b18.jpg"
},
"page": {
"type": "blog",
"title": "Go and Grow Camp - Travel",
"year": "2026"
},
"posts": [
{
"id": "travel-info-2026",
"title": "Travel Information — Go and Grow Camp 2026",
"slug": "travel-information-2026",
"date": "2026-01-01",
"author": "Go and Grow Camp",
"excerpt": "Summary of important travel details, arrival/departure times and contact points.",
"coverImage": "/uploads/banner/b18.jpg",
"categories": ["Travel", "Info"],
"tags": ["travel", "camp", "2026"],
"content": {
"blocks": [
{
"type": "paragraph",
"data": {
"text": "Our entire team is looking forward to an exciting and adventurous holiday camp with you. Below you will find a summary of all the important information about our adventure, sports and language camps. If you have any further questions, please contact us at office@campadventure.de"
}
}
]
}
}
]
}

View File

@@ -1,300 +0,0 @@
{
"hero": {
"title": "Visa Service",
"summaryList": [
{
"id": 1,
"name": "France",
"slug": "france",
"icon": "/img/home-2/visa/03.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
],
"detailedView": {
"activeCountry": {
"id": 1,
"name": "United States of America ",
"title": "COUNTRY USA",
"mainImage": "/img/inner-page/country-details/details-1.jpg",
"description": "The United States is one of the most popular destinations for international students and immigrants, offering world-class universities, diverse cultural experiences, and countless career opportunities...",
"additionalInfo": "Our consultancy provides complete guidance for study visas, work permits, and permanent residency pathways tailored to your goals.",
"tagline": "Over the last 35 Years we made an impact that is strong & we have long way to go.",
"visaTypes": [
{
"category": "Tourist & Work",
"items": [
{
"title": "Tourist Visa",
"description": "Broad term that can refer to various aspects of interconnectedness"
},
{
"title": "Work Permit",
"description": "Broad term that can refer to various aspects of interconnectedness"
}
]
},
{
"category": "Student & Family",
"items": [
{
"title": "Student",
"description": "Broad term that can refer to various aspects of interconnectedness"
},
{
"title": "Tourist Visa",
"description": "Broad term that can refer to various aspects of interconnectedness"
}
]
}
],
"visaProcess": {
"title": "USA Visa Process",
"steps": [
{
"number": "01",
"title": "Consultation & Eligibility Check",
"description": "Our experts review your profile and visa requirements."
},
{
"number": "02",
"title": "Application Preparation",
"description": "We help with document collection, form filling, and statement drafting."
},
{
"number": "03",
"title": "Submission",
"description": "Visa application is submitted online with required fees."
},
{
"number": "04",
"title": "Interview Guidance",
"description": "Get training and mock sessions for embassy interview."
},
{
"number": "05",
"title": "Approval & Travel",
"description": "Once approved, we provide travel and pre-departure guidance."
}
]
},
"gallery": [
"/img/inner-page/country-details/details-2.jpg",
"/img/inner-page/country-details/details-3.png"
],
"visaCategories": {
"title": "Types of USA Visas",
"steps": [
[
"Student Visa (F1, M1, J1)",
"Work Visa (H1B, L1)",
"Tourist Visa (B1/B2)"
],
[
"Family/Spouse Visa (K1, IR1, F2A)",
"Green Card / Immigrant Visa"
]
]
},
"visaService": {
"title": "Our USA Visa Service Options",
"steps": [
{
"number": "01",
"title": "Consultation & Eligibility Check",
"description": "Our experts review your profile and visa requirements."
},
{
"number": "02",
"title": "Application Preparation",
"description": "We help with document collection, form filling, and statement drafting."
},
{
"number": "03",
"title": "Submission",
"description": "Visa application is submitted online with required fees."
},
{
"number": "04",
"title": "Interview Guidance",
"description": "Get training and mock sessions for embassy interview."
},
{
"number": "05",
"title": "Approval & Travel",
"description": "Once approved, we provide travel and pre-departure guidance."
}
]
}
},
"relatedCountries": [
{
"id": 1,
"name": "Canada",
"icon": "/img/inner-page/country-details/01.png"
},
{
"id": 2,
"name": "USA",
"icon": "/img/inner-page/country-details/02.png"
},
{
"id": 3,
"name": "USA",
"icon": "/img/inner-page/country-details/03.png"
},
{
"id": 4,
"name": "Saint Helena",
"icon": "/img/inner-page/country-details/05.png"
},
{
"id": 5,
"name": "Iran",
"icon": "/img/inner-page/country-details/06.png"
},
{
"id": 6,
"name": "Spain",
"icon": "/img/inner-page/country-details/07.png"
},
{
"id": 7,
"name": "Japan",
"icon": "/img/inner-page/country-details/08.png"
}
],
"contactInfo": {
"img": "/img/inner-page/country-details/bg.jpg",
"sectionTitle": "Visa & Immigration",
"helpText": "Need Help? Book Lab Visit",
"phone": {
"label": "Call Us",
"value": "+009 438 222 9540",
"link": "tel:+0094382229540"
},
"email": {
"label": "Mail Us",
"value": "infor@xridergamil.com",
"link": "mailto:infor@xridergamil.com"
},
"location": {
"label": "Location",
"address": "Toronto, Montreal, City 2026"
}
}
}
},
{
"id": 2,
"name": "UK",
"slug": "uk",
"icon": "/img/home-2/visa/11.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 3,
"name": "Canada",
"slug": "canada",
"icon": "/img/home-2/visa/02.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 4,
"name": "Germany",
"slug": "germany",
"icon": "/img/home-2/visa/12.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 5,
"name": "Spain",
"slug": "spain",
"icon": "/img/home-2/visa/13.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 6,
"name": "South Korea",
"slug": "south-korea",
"icon": "/img/home-2/visa/14.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 7,
"name": "Japan",
"slug": "japan",
"icon": "/img/home-2/visa/15.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 8,
"name": "Croatia",
"slug": "croatia",
"icon": "/img/home-2/visa/16.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 9,
"name": "England",
"slug": "england",
"icon": "/img/home-2/visa/17.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 10,
"name": "Indonesia",
"slug": "indonesia",
"icon": "/img/home-2/visa/18.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
}
]
}
}

17
middleware/apiKey.js Normal file
View File

@@ -0,0 +1,17 @@
/**
* API Key middleware
* Validates the api_key query parameter against process.env.API_KEY
* Spec: GET /api/verify-degree/{id}?api_key={API_KEY}
*/
function validateApiKey(req, res, next) {
const apiKey = req.query.api_key;
if (!apiKey || apiKey !== process.env.API_KEY) {
return res.status(401).json({ error: 'Unauthorized - Invalid API key' });
}
next();
}
module.exports = { validateApiKey };

View File

@@ -155,8 +155,44 @@ async function convertToWebp(req, res, next) {
}
// Storage cho degree images — lưu ngoài public/ để không serve trực tiếp
const degreeStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '../private/uploads/degree');
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true });
}
cb(null, uploadPath);
},
filename: function (req, file, cb) {
const ext = path.extname(file.originalname);
cb(null, `degree-${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
}
});
// Lọc file chỉ cho phép ảnh degree
const degreeFileFilter = (req, file, cb) => {
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image/jpeg, image/png, image/webp files are allowed!'));
}
};
// Cấu hình upload degree
const uploadDegree = multer({
storage: degreeStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB per file
fileFilter: degreeFileFilter
}).fields([
{ name: 'degree_image', maxCount: 1 },
{ name: 'certificate_image', maxCount: 1 }
]);
module.exports = {
upload,
uploadVideo,
convertToWebp
convertToWebp,
uploadDegree
};

View File

@@ -1,108 +0,0 @@
const mongoose = require("mongoose");
const aboutUsSchema = new mongoose.Schema(
{
hero: {
title: String,
breadcrumb: [String],
backgroundImage: String,
},
intro: {
subheading: String,
heading: String,
description: String,
image: String,
},
mission: {
subheading: String,
heading: String,
description: String,
images: {
main: String,
secondary: String,
bgShape: String,
planeShape: String,
topShape: String,
globeShape: String,
},
items: [
new mongoose.Schema(
{
icon: String,
label: String,
description: String,
},
{ _id: false },
),
],
features: [String],
ctaButton: {
label: String,
href: String,
},
},
features: {
backgroundImage: String,
subheading: String,
heading: String,
description: String,
image: String,
items: [
new mongoose.Schema(
{
icon: String,
title: String,
description: String,
},
{ _id: false },
),
],
ctaButton: {
label: String,
href: String,
},
},
news: {
subheading: String,
heading: String,
ctaButton: {
label: String,
href: String,
},
selectedBlogIds: [{ type: mongoose.Schema.Types.ObjectId, ref: "Blog" }],
// Deprecated: items field kept for backward compatibility during migration
items: [
new mongoose.Schema(
{
title: String,
category: String,
date: String,
comments: Number,
author: {
name: String,
avatar: String,
},
link: String,
thumbnail: String,
},
{ _id: false },
),
],
},
},
{
timestamps: true,
collection: "aboutus",
},
);
// Static method để đảm bảo luôn chỉ có 1 bản ghi duy nhất (Singleton)
aboutUsSchema.statics.getSingle = async function () {
let doc = await this.findOne();
if (!doc) {
doc = await this.create({});
}
return doc;
};
module.exports = mongoose.model("AboutUs", aboutUsSchema);

View File

@@ -1,194 +0,0 @@
const mongoose = require("mongoose");
const activitySchema = new mongoose.Schema(
{
// Hero section for activity page header (supports Activities and Booking variants)
hero: {
titleActivities: {
type: String,
trim: true,
default: ''
},
titleBooking: {
type: String,
trim: true,
default: ''
},
bannerImageActivities: {
type: String,
trim: true,
default: ''
},
bannerImageBooking: {
type: String,
trim: true,
default: ''
},
},
name: {
type: String,
required: true,
trim: true,
},
price: {
type: Number,
required: true,
min: 0,
},
priceText: {
type: String,
trim: true,
},
season: [
{
type: String,
enum: ["spring", "summer", "autumn", "winter"],
},
],
age: {
type: [Number],
validate: {
validator: function (v) {
return v.length === 2 && v[0] <= v[1];
},
message: "Age must be an array of [minAge, maxAge]",
},
},
locations: [
{
type: String,
trim: true,
},
],
image: {
type: String,
trim: true,
},
link: {
type: String,
trim: true,
},
// Global filters document (single document in Activity collection)
filters: [
{
label: { type: String, required: true, trim: true },
value: { type: String, required: true, trim: true },
items: [
{
value: { type: String, required: true },
label: { type: String, required: true },
},
],
order: { type: Number, default: 0 },
},
],
program: {
type: String,
trim: true,
},
rating: {
type: Number,
min: 1,
max: 5,
default: 4,
},
isActive: {
type: Boolean,
default: true,
},
order: {
type: Number,
default: 0,
},
// marker for the single document that stores global filters
isFiltersDoc: {
type: Boolean,
default: false,
},
// Rich camp details from camp-detail field in activities.json
campDetail: {
type: mongoose.Schema.Types.Mixed,
default: {},
},
// Booking sessions - các đợt booking với thông số riêng
bookingSessions: [
{
sessionId: { type: String, required: true },
startDate: { type: Date, required: true },
endDate: { type: Date, required: true },
overnightStays: { type: Number, required: true, default: 14 },
// Spots theo giới tính
totalMaleSpots: { type: Number, default: 25 },
totalFemaleSpots: { type: Number, default: 25 },
bookedMaleSpots: { type: Number, default: 0 },
bookedFemaleSpots: { type: Number, default: 0 },
price: { type: Number },
isActive: { type: Boolean, default: true },
// Danh sách booking cho session này
bookingList: [
{
address: { type: String, required: true },
agreeNewsletter: { type: Boolean, default: false },
agreeTerms: { type: Boolean, required: true },
city: { type: String, required: true },
country: { type: String, required: true },
dietaryRestrictions: {
type: String,
enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'],
default: 'none'
},
email: {
type: String,
required: true,
lowercase: true,
trim: true
},
emergencyContact: { type: String, required: true },
emergencyPhone: { type: String, required: true },
medicalConditions: { type: String, default: '' },
numberOfParticipants: { type: Number, required: true, min: 1 },
parentFirstName: { type: String, required: true, trim: true },
parentLastName: { type: String, required: true, trim: true },
participantBirthDate: { type: Date, required: true },
participantFirstName: { type: String, required: true, trim: true },
participantGender: {
type: String,
enum: ['male', 'female', 'other'],
required: true
},
participantLastName: { type: String, required: true, trim: true },
phone: { type: String, required: true },
postalCode: { type: String, required: true },
sessionDate: { type: String, required: true }, // sessionId reference
specialRequests: { type: String, default: '' },
// Thêm các trường quản lý
bookingStatus: {
type: String,
enum: ['pending', 'confirmed', 'cancelled', 'completed'],
default: 'pending'
},
paymentStatus: {
type: String,
enum: ['pending', 'partial', 'paid', 'refunded'],
default: 'pending'
},
totalAmount: { type: Number, default: 0 },
paidAmount: { type: Number, default: 0 },
bookingDate: { type: Date, default: Date.now },
confirmationCode: { type: String, unique: true },
adminNotes: { type: String, default: '' }
}
]
}
],
},
{timestamps: true}
);
// Add index for better query performance
activitySchema.index({name: 1});
activitySchema.index({isActive: 1, order: 1});
activitySchema.index({season: 1});
activitySchema.index({locations: 1});
module.exports = mongoose.model("Activity", activitySchema);

View File

@@ -1,206 +0,0 @@
const mongoose = require("mongoose");
// Clear cache
if (mongoose.models.Appointment) {
delete mongoose.models.Appointment;
}
if (mongoose.connection.models.Appointment) {
delete mongoose.connection.models.Appointment;
}
// Schema cho hero section
const heroSchema = new mongoose.Schema(
{
title: {
type: String,
trim: true,
default: "Make Appointment",
},
backgroundImage: {
type: String,
trim: true,
default: "",
},
subtitle: {
type: String,
trim: true,
default: "",
},
heading: {
type: String,
trim: true,
default: "",
},
description: {
type: String,
trim: true,
default: "",
},
},
{ _id: false }
);
// Schema cho form field
const formFieldSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true,
},
label: {
type: String,
trim: true,
default: "",
},
type: {
type: String,
required: true,
trim: true,
enum: ["text", "email", "tel", "textarea", "date", "select"],
},
placeholder: {
type: String,
trim: true,
default: "",
},
required: {
type: Boolean,
default: false,
},
colClass: {
type: String,
trim: true,
default: "col-lg-12",
},
},
{ _id: false }
);
// Schema cho submit button
const submitButtonSchema = new mongoose.Schema(
{
text: {
type: String,
required: true,
trim: true,
default: "Request Appointment",
},
icon: {
type: String,
trim: true,
default: "fa-solid fa-arrow-right",
},
buttonClass: {
type: String,
trim: true,
default: "theme-btn",
},
},
{ _id: false }
);
// Schema cho form
const formSchema = new mongoose.Schema(
{
heading: {
type: String,
trim: true,
default: "Request Appointment",
},
fields: {
type: [formFieldSchema],
default: [],
},
submitButton: {
type: submitButtonSchema,
default: () => ({}),
},
},
{ _id: false }
);
// Main Appointment Schema
const appointmentSchema = new mongoose.Schema(
{
name: {
type: String,
default: "default",
unique: true,
},
hero: {
type: heroSchema,
default: () => ({}),
},
visaOptions: {
type: [String],
default: [],
},
form: {
type: formSchema,
default: () => ({}),
},
},
{
timestamps: true,
}
);
// Migration method to import data from JSON
appointmentSchema.statics.migrateFromJson = async function (jsonData) {
try {
// Check if default appointment exists
const existingAppointment = await this.findOne({ name: "default" });
// Process data from JSON
const processedData = {
hero: {
title: jsonData.hero?.title || "Make Appointment",
backgroundImage: jsonData.hero?.backgroundImage || "",
subtitle: jsonData.hero?.subtitle || "",
heading: jsonData.hero?.heading || "",
description: jsonData.hero?.description || "",
},
visaOptions: Array.isArray(jsonData.visaOptions) ? jsonData.visaOptions : [],
form: {
heading: jsonData.form?.heading || "Request Appointment",
fields: (jsonData.form?.fields || []).map((field) => ({
name: field.name || "",
label: field.label || "",
type: field.type || "text",
placeholder: field.placeholder || "",
required: field.required || false,
colClass: field.colClass || "col-lg-12",
})),
submitButton: {
text: jsonData.form?.submitButton?.text || "Request Appointment",
icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right",
buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn",
},
},
};
if (existingAppointment) {
// Update existing appointment
existingAppointment.hero = processedData.hero;
existingAppointment.visaOptions = processedData.visaOptions;
existingAppointment.form = processedData.form;
await existingAppointment.save();
console.log("Appointment data updated successfully");
return existingAppointment;
} else {
// Create new appointment
const newAppointment = await this.create({
name: "default",
...processedData,
});
console.log("Appointment data imported successfully");
return newAppointment;
}
} catch (error) {
console.error("Error migrating appointment data:", error);
throw error;
}
};
module.exports = mongoose.model("Appointment", appointmentSchema);

View File

@@ -1,83 +0,0 @@
const mongoose = require("mongoose");
/**
* Schema for Appointment Submissions
* Stores appointment requests from users
*/
const appointmentSubmissionSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, "Name is required"],
trim: true,
maxlength: [100, "Name cannot exceed 100 characters"],
},
email: {
type: String,
required: [true, "Email is required"],
trim: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"],
},
phone: {
type: String,
trim: true,
default: "",
},
address: {
type: String,
trim: true,
default: "",
},
appointmentDate: {
type: String,
trim: true,
default: "",
},
message: {
type: String,
trim: true,
default: "",
},
visaTypes: {
type: [String],
default: [],
},
status: {
type: String,
enum: ["pending", "confirmed", "completed", "cancelled"],
default: "pending",
},
notes: {
type: String,
trim: true,
default: "",
},
confirmedAt: {
type: Date,
default: null,
},
completedAt: {
type: Date,
default: null,
},
ipAddress: {
type: String,
default: "",
},
userAgent: {
type: String,
default: "",
},
},
{
timestamps: true,
}
);
// Index for faster queries
appointmentSubmissionSchema.index({ status: 1, createdAt: -1 });
appointmentSubmissionSchema.index({ email: 1 });
appointmentSubmissionSchema.index({ appointmentDate: 1 });
module.exports = mongoose.model("AppointmentSubmission", appointmentSubmissionSchema);

View File

@@ -1,148 +0,0 @@
const mongoose = require('mongoose');
const blogSchema = new mongoose.Schema({
// Basic blog information
title: {
type: String,
required: true,
trim: true
},
slug: {
type: String,
required: true,
unique: true,
trim: true
},
excerpt: {
type: String,
required: true,
maxlength: 500
},
content: {
type: mongoose.Schema.Types.Mixed, // Có thể là string HTML hoặc JSON EditorJS
required: true
},
// Media
featuredImage: {
type: String,
default: ''
},
galleryImages: [{
type: String
}], // Mảng URL ảnh cho gallery (details-2, details-3)
// Author and publishing
author: {
type: String,
default: 'Admin'
},
publishedAt: {
type: String, // Format: "11 March 2025"
default: function() {
return new Date().toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
},
// Categorization (simple strings, no references)
category: [{
type: String // ["Visa", "Travel", ...] - Một bài có thể thuộc nhiều category
}],
tags: [{
type: String // ["WorkVisa", "StudentVisa", ...]
}],
// Status and features
status: {
type: String,
enum: ['draft', 'published'],
default: 'published'
},
isFeatured: {
type: Boolean,
default: false
},
// Comments count (có thể fake trước)
commentsCount: {
type: Number,
default: 0
},
// Quote/Sidebar section
quote: {
type: String,
default: '',
trim: true
},
// Content after quote
contentAfterQuote: {
type: String,
default: '',
trim: true
}
}, {
timestamps: true
});
// Indexes
blogSchema.index({ status: 1, createdAt: -1 });
blogSchema.index({ category: 1, status: 1 });
blogSchema.index({ isFeatured: 1, status: 1 });
blogSchema.index({ tags: 1, status: 1 });
// Remove __v from JSON output
blogSchema.set('toJSON', {
transform: function(doc, ret) {
delete ret.__v;
return ret;
}
});
// Pre-save middleware
blogSchema.pre('save', function(next) {
// Auto-generate slug if not provided
if (!this.slug && this.title) {
this.slug = this.title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
}
next();
});
// Static methods
blogSchema.statics.getPublished = function() {
return this.find({ status: 'published' }).sort({ createdAt: -1 });
};
blogSchema.statics.getFeatured = function() {
return this.find({
status: 'published',
isFeatured: true
}).sort({ createdAt: -1 });
};
blogSchema.statics.getByCategory = function(category) {
return this.find({
status: 'published',
category: { $in: [category] } // Tìm trong array categories
}).sort({ createdAt: -1 });
};
blogSchema.statics.getByTag = function(tag) {
return this.find({
status: 'published',
tags: tag
}).sort({ createdAt: -1 });
};
module.exports = mongoose.model('Blog', blogSchema);

View File

@@ -1,75 +0,0 @@
const mongoose = require('mongoose');
const blogCategorySchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
unique: true // "Permanent Residency (PR)"
},
slug: {
type: String,
required: true,
unique: true,
trim: true // "permanent-residency"
},
postCount: {
type: Number,
default: 0 // "(04)"
},
description: {
type: String,
default: ''
},
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
// Indexes
blogCategorySchema.index({ isActive: 1, name: 1 });
// Remove __v from JSON output
blogCategorySchema.set('toJSON', {
transform: function(doc, ret) {
delete ret.__v;
return ret;
}
});
// Pre-save middleware
blogCategorySchema.pre('save', function(next) {
// Auto-generate slug if not provided
if (!this.slug && this.name) {
this.slug = this.name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
}
next();
});
// Static methods
blogCategorySchema.statics.getActive = function() {
return this.find({ isActive: true }).sort({ name: 1 });
};
// Method to update post count
blogCategorySchema.methods.updatePostCount = async function() {
const Blog = require('./blog');
const count = await Blog.countDocuments({
category: { $in: [this.name] }, // Tìm trong array categories
status: 'published'
});
this.postCount = count;
await this.save();
return count;
};
module.exports = mongoose.model('BlogCategory', blogCategorySchema);

View File

@@ -1,104 +0,0 @@
const mongoose = require('mongoose');
const blogCommentSchema = new mongoose.Schema({
postId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Blog',
required: true
},
authorName: {
type: String,
required: true,
trim: true // "Frank Flores"
},
authorEmail: {
type: String,
default: '',
trim: true
},
authorPhone: {
type: String,
default: '',
trim: true
},
authorAddress: {
type: String,
default: '',
trim: true
},
authorDate: {
type: String,
default: '',
trim: true
},
authorAvatar: {
type: String,
default: '' // "/assets/img/inner-page/news-details/comment-1.png"
},
content: {
type: String,
required: true,
trim: true
},
parentId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'BlogComment',
default: null // Cho threaded comments (reply)
},
status: {
type: String,
enum: ['pending', 'approved', 'rejected'],
default: 'pending'
}
}, {
timestamps: true // Vẫn giữ timestamps cho admin quản lý
});
// Indexes
blogCommentSchema.index({ postId: 1, status: 1, createdAt: -1 });
blogCommentSchema.index({ parentId: 1 });
blogCommentSchema.index({ status: 1 });
// Remove __v from JSON output
blogCommentSchema.set('toJSON', {
transform: function(doc, ret) {
delete ret.__v;
return ret;
}
});
// Static methods
blogCommentSchema.statics.getApprovedByPost = function(postId) {
return this.find({
postId: postId,
status: 'approved',
parentId: null // Chỉ lấy comments gốc, không lấy replies
}).sort({ createdAt: -1 });
};
blogCommentSchema.statics.getReplies = function(parentId) {
return this.find({
parentId: parentId,
status: 'approved'
}).sort({ createdAt: 1 }); // Replies sắp xếp theo thời gian tăng dần
};
blogCommentSchema.statics.getByStatus = function(status) {
return this.find({ status: status })
.populate('postId', 'title slug')
.sort({ createdAt: -1 });
};
// Method to approve comment
blogCommentSchema.methods.approve = function() {
this.status = 'approved';
return this.save();
};
// Method to reject comment
blogCommentSchema.methods.reject = function() {
this.status = 'rejected';
return this.save();
};
module.exports = mongoose.model('BlogComment', blogCommentSchema);

View File

@@ -1,77 +0,0 @@
const mongoose = require('mongoose');
const blogTagSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
unique: true // "WorkVisa"
},
slug: {
type: String,
required: true,
unique: true,
trim: true // "work-visa"
},
postCount: {
type: Number,
default: 0
},
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
// Indexes
blogTagSchema.index({ isActive: 1, name: 1 });
// Remove __v from JSON output
blogTagSchema.set('toJSON', {
transform: function(doc, ret) {
delete ret.__v;
return ret;
}
});
// Pre-save middleware
blogTagSchema.pre('save', function(next) {
// Auto-generate slug if not provided
if (!this.slug && this.name) {
this.slug = this.name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
}
next();
});
// Static methods
blogTagSchema.statics.getActive = function() {
return this.find({ isActive: true }).sort({ name: 1 });
};
blogTagSchema.statics.getPopular = function(limit = 10) {
return this.find({ isActive: true })
.sort({ postCount: -1, name: 1 })
.limit(limit);
};
// Method to update post count
blogTagSchema.methods.updatePostCount = async function() {
const Blog = require('./blog');
const count = await Blog.countDocuments({
tags: { $in: [this.name] }, // Tìm trong array tags
status: 'published'
});
this.postCount = count;
await this.save();
return count;
};
module.exports = mongoose.model('BlogTag', blogTagSchema);

View File

@@ -1,106 +0,0 @@
const mongoose = require("mongoose");
// Clear cache
if (mongoose.models.Booking) {
delete mongoose.models.Booking;
}
if (mongoose.connection.models.Booking) {
delete mongoose.connection.models.Booking;
}
const bookingSchema = new mongoose.Schema(
{
hero: {
title: String,
backgroundImage: String,
},
searchBar: {
locationLabel: String,
holidaySeasonLabel: String,
searchButtonText: String,
},
filterPanel: {
title: String,
priceTitle: String,
priceLabel: String,
pricePlaceholder: String,
priceMin: Number,
priceMax: Number,
activitiesTitle: String,
ageTitle: String,
ageSelectPlaceholder: String,
ageMin: Number,
ageMax: Number,
ratingTitle: String,
ratingOptions: [
{
value: String,
label: String,
},
],
resetButtonText: String,
},
programs: [
{
value: String,
label: String,
},
],
holidays: [
{
value: String,
label: String,
},
],
locations: [
{
value: String,
label: String,
},
],
camps: [
{
name: String,
price: Number,
priceText: String,
season: [String],
age: [Number],
locations: [String],
image: String,
link: String,
program: String,
rating: Number,
},
],
// Configuration - Dùng Mixed type để chấp nhận bất kỳ structure nào
configuration: mongoose.Schema.Types.Mixed,
formSteps: [
{
step: Number,
title: String,
sections: [
{
id: String,
fields: [mongoose.Schema.Types.Mixed],
},
],
},
],
validation: mongoose.Schema.Types.Mixed,
},
{
timestamps: true,
strict: false
}
);
module.exports = mongoose.model("Booking", bookingSchema);

View File

@@ -1,200 +0,0 @@
const mongoose = require("mongoose");
// Clear cache
if (mongoose.models.BookingSubmission) {
delete mongoose.models.BookingSubmission;
}
if (mongoose.connection.models.BookingSubmission) {
delete mongoose.connection.models.BookingSubmission;
}
const bookingSubmissionSchema = new mongoose.Schema(
{
// Liên kết với activity và session
activityId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Activity',
required: true
},
sessionId: {
type: String,
required: true
},
// Thông tin người đăng ký
parentFirstName: {
type: String,
required: true,
trim: true
},
parentLastName: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
trim: true,
lowercase: true
},
phone: {
type: String,
required: true,
trim: true
},
// Thông tin địa chỉ
address: {
type: String,
required: true,
trim: true
},
city: {
type: String,
required: true,
trim: true
},
country: {
type: String,
required: true,
trim: true
},
postalCode: {
type: String,
required: true,
trim: true
},
// Thông tin người tham gia
participantFirstName: {
type: String,
required: true,
trim: true
},
participantLastName: {
type: String,
required: true,
trim: true
},
participantBirthDate: {
type: Date,
required: true
},
participantGender: {
type: String,
enum: ['male', 'female', 'other'],
required: true
},
numberOfParticipants: {
type: Number,
required: true,
min: 1
},
// Thông tin y tế và đặc biệt
medicalConditions: {
type: String,
trim: true,
default: ''
},
dietaryRestrictions: {
type: String,
enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'],
default: 'none'
},
specialRequests: {
type: String,
trim: true,
default: ''
},
// Thông tin liên hệ khẩn cấp
emergencyContact: {
type: String,
required: true,
trim: true
},
emergencyPhone: {
type: String,
required: true,
trim: true
},
// Điều khoản và thông báo
agreeTerms: {
type: Boolean,
required: true,
default: false
},
agreeNewsletter: {
type: Boolean,
default: false
},
// Trạng thái đăng ký
status: {
type: String,
enum: ['pending', 'confirmed', 'cancelled', 'completed'],
default: 'pending'
},
// Ghi chú admin
adminNotes: {
type: String,
trim: true,
default: ''
},
// Thông tin thanh toán
paymentStatus: {
type: String,
enum: ['pending', 'partial', 'paid', 'refunded'],
default: 'pending'
},
totalAmount: {
type: Number,
default: 0
},
paidAmount: {
type: Number,
default: 0
}
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
}
);
// Virtual để tính tuổi của participant
bookingSubmissionSchema.virtual('participantAge').get(function() {
if (this.participantBirthDate) {
const today = new Date();
const birthDate = new Date(this.participantBirthDate);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
return 0;
});
// Virtual để lấy thông tin activity
bookingSubmissionSchema.virtual('activity', {
ref: 'Activity',
localField: 'activityId',
foreignField: '_id',
justOne: true
});
// Index for better performance
bookingSubmissionSchema.index({ activityId: 1, sessionId: 1 });
bookingSubmissionSchema.index({ email: 1 });
bookingSubmissionSchema.index({ status: 1 });
bookingSubmissionSchema.index({ createdAt: -1 });
module.exports = mongoose.model("BookingSubmission", bookingSubmissionSchema);

32
models/certificate.js Normal file
View File

@@ -0,0 +1,32 @@
const mongoose = require('mongoose');
const certificateSchema = new mongoose.Schema({
certification_number: {
type: String, required: true, unique: true, trim: true
},
student_name: {
type: String, required: true, trim: true
},
program_name: {
type: String, required: true, trim: true
},
department: {
type: mongoose.Schema.Types.ObjectId, ref: 'Department', required: true
},
level: {
type: mongoose.Schema.Types.ObjectId, ref: 'Level', required: true
},
issued_date: {
type: Date, required: true
},
status: {
type: String, enum: ['active', 'revoked'], default: 'active'
},
// Optional personal info
passport_number: { type: String, trim: true },
address: { type: String, trim: true },
// Document image
certificate_image: { type: String }
}, { timestamps: true });
module.exports = mongoose.model('Certificate', certificateSchema);

View File

@@ -1,423 +0,0 @@
const mongoose = require("mongoose");
// Schema cho hero section
const heroSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
},
backgroundImage: {
type: String,
trim: true,
default: "",
},
overlayColor: {
type: String,
trim: true,
default: "rgba(0, 0, 0, 0)",
},
sectionClass: {
type: String,
trim: true,
default: "",
},
titleClass: {
type: String,
trim: true,
default: "",
},
enableScrollspy: {
type: Boolean,
default: false,
},
backgroundPosition: {
type: String,
trim: true,
default: "center",
},
},
{ _id: false }
);
// Schema cho contact card
const contactCardSchema = new mongoose.Schema(
{
type: {
type: String,
required: true,
trim: true,
enum: [
"phone",
"email",
"location",
"hours",
"website",
"social",
"custom",
],
},
title: {
type: String,
required: true,
trim: true,
},
content: {
type: [String],
default: [],
},
iconType: {
type: String,
required: false,
trim: true,
default: "",
},
iconSource: {
type: String,
required: false,
trim: true,
enum: ["fontawesome", "image"],
default: "fontawesome",
},
},
{ _id: false }
);
// Schema cho map coordinates
const coordinatesSchema = new mongoose.Schema(
{
lat: {
type: Number,
required: true,
},
lng: {
type: Number,
required: true,
},
},
{ _id: false }
);
// Schema cho tile layer
const tileLayerSchema = new mongoose.Schema(
{
url: {
type: String,
required: true,
trim: true,
},
attribution: {
type: String,
trim: true,
default: "",
},
maxZoom: {
type: Number,
default: 18,
},
minZoom: {
type: Number,
default: 0,
},
},
{ _id: false }
);
// Schema cho map
const mapSchema = new mongoose.Schema(
{
coordinates: {
type: coordinatesSchema,
required: true,
},
zoom: {
type: Number,
default: 15,
},
location: {
type: String,
required: true,
trim: true,
},
markerTitle: {
type: String,
trim: true,
default: "",
},
embedUrl: {
type: String,
trim: true,
default: "",
},
tileLayer: {
type: tileLayerSchema,
required: true,
},
},
{ _id: false }
);
// Schema cho form field
const formFieldSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true,
},
label: {
type: String,
trim: true,
default: "",
},
type: {
type: String,
required: true,
trim: true,
enum: ["text", "email", "tel", "textarea", "programme", "date"],
},
placeholder: {
type: String,
trim: true,
default: "",
},
required: {
type: Boolean,
default: false,
},
colClass: {
type: String,
trim: true,
default: "col-lg-12",
},
programmeName: {
type: String,
trim: true,
default: "",
},
},
{ _id: false }
);
// Schema cho submit button
const submitButtonSchema = new mongoose.Schema(
{
text: {
type: String,
required: true,
trim: true,
},
icon: {
type: String,
trim: true,
default: "fa-solid fa-arrow-right",
},
buttonClass: {
type: String,
trim: true,
default: "theme-btn style-2",
},
},
{ _id: false }
);
// Schema cho form
const formSchema = new mongoose.Schema(
{
sectionLabel: {
type: String,
trim: true,
default: "",
},
heading: {
type: String,
trim: true,
default: "",
},
description: {
type: String,
trim: true,
default: "",
},
fields: {
type: [formFieldSchema],
default: [],
},
submitButton: {
type: submitButtonSchema,
required: true,
},
},
{ _id: false }
);
// Main Contact Schema
const contactSchema = new mongoose.Schema(
{
name: {
type: String,
default: "default",
unique: true,
},
hero: {
type: heroSchema,
required: true,
},
contactCards: {
type: [contactCardSchema],
default: [],
},
map: {
type: mapSchema,
required: true,
},
form: {
type: formSchema,
required: true,
},
},
{
timestamps: true,
}
);
// Mapping iconType cũ sang Font Awesome icon mới
const iconTypeMapping = {
phone: "fas fa-phone",
email: "fas fa-envelope",
location: "fas fa-map-marker-alt",
clock: "fas fa-clock",
hours: "fas fa-clock",
};
// Tạo migration script để import dữ liệu từ contact-data.json
contactSchema.statics.migrateFromJson = async function (jsonData) {
try {
// Kiểm tra xem đã có contact mặc định chưa
const existingContact = await this.findOne({ name: "default" });
// Xử lý và chuẩn hóa dữ liệu từ JSON
const processedData = {
hero: {
title: jsonData.hero?.title || "Contact Us",
backgroundImage: jsonData.hero?.backgroundImage || "",
overlayColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)",
sectionClass: jsonData.hero?.sectionClass || "",
titleClass: jsonData.hero?.titleClass || "",
enableScrollspy: jsonData.hero?.enableScrollspy || false,
backgroundPosition: jsonData.hero?.backgroundPosition || "center",
},
contactCards: (jsonData.contactCards || []).map((card) => {
let iconType = card.iconType || "";
let iconSource = card.iconSource;
// Nếu không có iconSource, tự động detect từ iconType
if (!iconSource) {
// Nếu iconType là image path (bắt đầu bằng /uploads/ hoặc http)
if (
iconType.startsWith("/uploads/") ||
iconType.startsWith("http://") ||
iconType.startsWith("https://")
) {
iconSource = "image";
} else {
// Nếu iconType là string cũ (phone, email, location, clock)
iconSource = "fontawesome";
// Map iconType cũ sang Font Awesome icon mới
if (iconTypeMapping[iconType]) {
iconType = iconTypeMapping[iconType];
} else if (
iconType &&
!iconType.startsWith("fas ") &&
!iconType.startsWith("fab ")
) {
// Nếu iconType không phải là Font Awesome class hợp lệ, thử map
iconType = iconTypeMapping[iconType] || iconType;
}
}
} else {
// Nếu đã có iconSource nhưng iconType là string cũ, map sang Font Awesome
if (
iconSource === "fontawesome" &&
iconType &&
!iconType.startsWith("fas ") &&
!iconType.startsWith("fab ") &&
iconTypeMapping[iconType]
) {
iconType = iconTypeMapping[iconType];
}
}
return {
type: card.type || "custom",
title: card.title || "",
content: Array.isArray(card.content) ? card.content : [],
iconType: iconType,
iconSource: iconSource || "fontawesome",
};
}),
map: {
coordinates: {
lat: jsonData.map?.coordinates?.lat || 0,
lng: jsonData.map?.coordinates?.lng || 0,
},
zoom: jsonData.map?.zoom || 15,
location: jsonData.map?.location || "",
markerTitle: jsonData.map?.markerTitle || "",
embedUrl: jsonData.map?.embedUrl || "",
tileLayer: {
url:
jsonData.map?.tileLayer?.url ||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: jsonData.map?.tileLayer?.attribution || "",
maxZoom: jsonData.map?.tileLayer?.maxZoom || 18,
minZoom: jsonData.map?.tileLayer?.minZoom || 0,
},
},
form: {
sectionLabel: jsonData.form?.sectionLabel || "",
heading: jsonData.form?.heading || "",
description: jsonData.form?.description || "",
fields: (jsonData.form?.fields || []).map((field) => ({
name: field.name || "",
label: field.label || "",
type: field.type || "text",
placeholder: field.placeholder || "",
required: field.required || false,
colClass: field.colClass || "col-lg-12",
programmeName: field.programmeName || "",
})),
submitButton: {
text: jsonData.form?.submitButton?.text || "Send Message",
icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right",
buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn style-2",
},
},
};
if (existingContact) {
// Cập nhật contact hiện có với dữ liệu đã xử lý
existingContact.hero = processedData.hero;
existingContact.contactCards = processedData.contactCards;
existingContact.map = processedData.map;
existingContact.form = processedData.form;
await existingContact.save();
console.log("Contact data updated successfully");
return existingContact;
} else {
// Tạo contact mới với dữ liệu đã xử lý
const newContact = await this.create({
name: "default",
...processedData,
});
console.log("Contact data imported successfully");
return newContact;
}
} catch (error) {
console.error("Error migrating contact data:", error);
throw error;
}
};
module.exports = mongoose.model("Contact", contactSchema);

View File

@@ -1,74 +0,0 @@
const mongoose = require("mongoose");
/**
* Schema for Contact Form Submissions
* Stores user inquiries from the contact form
*/
const contactSubmissionSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, "Name is required"],
trim: true,
maxlength: [100, "Name cannot exceed 100 characters"],
},
email: {
type: String,
required: [true, "Email is required"],
trim: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"],
},
phone: {
type: String,
trim: true,
default: "",
},
address: {
type: String,
trim: true,
default: "",
},
date: {
type: String,
trim: true,
default: "",
},
message: {
type: String,
trim: true,
default: "",
},
status: {
type: String,
enum: ["pending", "read", "replied", "archived"],
default: "pending",
},
notes: {
type: String,
trim: true,
default: "",
},
repliedAt: {
type: Date,
default: null,
},
ipAddress: {
type: String,
default: "",
},
userAgent: {
type: String,
default: "",
},
},
{
timestamps: true,
}
);
// Index for faster queries
contactSubmissionSchema.index({ status: 1, createdAt: -1 });
contactSubmissionSchema.index({ email: 1 });
module.exports = mongoose.model("ContactSubmission", contactSubmissionSchema);

88
models/degree.js Normal file
View File

@@ -0,0 +1,88 @@
const mongoose = require('mongoose');
const degreeSchema = new mongoose.Schema({
// Required fields
qualification_number: {
type: String,
required: true,
unique: true,
trim: true
},
student_name: {
type: String,
required: true,
trim: true
},
program_name: {
type: String,
required: true,
trim: true
},
type: {
type: String,
required: true,
enum: ['qualification', 'certification']
},
department: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Department',
required: true
},
level: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Level',
required: true
},
issued_date: {
type: Date,
required: true
},
status: {
type: String,
enum: ['active', 'revoked'],
default: 'active'
},
// Optional fields
certification_number: {
type: String,
trim: true,
},
passport_number: {
type: String,
trim: true
},
address: {
type: String,
trim: true
},
topic_name: {
type: String,
trim: true
},
topic_short_desc: {
type: String,
trim: true
},
degree_image: {
type: String
},
certificate_image: {
type: String
}
}, {
timestamps: true
});
// Indexes
degreeSchema.index({ certification_number: 1 }, { unique: true, sparse: true });
// Pre-save hook: certification type requires certification_number
degreeSchema.pre('save', function (next) {
if (this.type === 'certification' && !this.certification_number) {
return next(new Error('certification_number is required for certification type'));
}
next();
});
module.exports = mongoose.model('Degree', degreeSchema);

View File

@@ -1,222 +0,0 @@
const mongoose = require('mongoose');
const faqItemSchema = new mongoose.Schema({
title: {
type: String,
required: true
},
description: {
type: String,
required: true
}
}, { _id: true });
const faqSectionSchema = new mongoose.Schema({
id: {
type: String,
required: true
},
title: {
type: String,
required: true
},
faqs: [faqItemSchema]
}, { _id: true });
const sidebarNavSchema = new mongoose.Schema({
id: {
type: String,
required: true
},
label: {
type: String,
required: true
}
});
const heroSchema = new mongoose.Schema({
title: String,
backgroundImage: String,
overlayColor: String,
sectionClass: String,
titleClass: String,
enableScrollspy: Boolean,
backgroundPosition: String
});
const contactBoxSchema = new mongoose.Schema({
title: String,
phone: {
icon: String,
text: String
},
email: {
icon: String,
text: String
}
});
const videoSchema = new mongoose.Schema({
url: String,
title: String
});
const faqSchema = new mongoose.Schema({
name: {
type: String,
default: 'default',
},
hero: heroSchema,
sidebarNav: [sidebarNavSchema],
contactBox: contactBoxSchema,
faqSections: [faqSectionSchema],
video: videoSchema
}, {
timestamps: true
});
// Virtual property để tính tổng số FAQ items
faqSchema.virtual('totalFaqs').get(function() {
return this.faqSections.reduce((total, section) => {
return total + (section.faqs ? section.faqs.length : 0);
}, 0);
});
// Static method: Lấy FAQ mặc định
faqSchema.statics.getDefault = async function() {
let faq = await this.findOne({ name: 'default' });
// Nếu không có, tạo mới
if (!faq) {
faq = new this({
name: 'default',
hero: {
title: 'Frequently Asked Questions',
backgroundImage: 'yootheme/cache/18/faqs_header_new.jpg',
overlayColor: 'rgba(0, 0, 0, 0)',
sectionClass: 'uk-section-secondary uk-section-overlap uk-preserve-color uk-light',
titleClass: 'uk-heading-large uk-text-center !text-[5vw]',
enableScrollspy: true,
backgroundPosition: 'top-center'
},
sidebarNav: [
{ id: 'general-information', label: 'General Information' }
],
contactBox: {
title: 'Let\'s plan your perfect nature escape',
phone: { icon: 'phone', text: '+(123)-456-789' },
email: { icon: 'email', text: 'hello@ggcamp.org' }
},
faqSections: [
{
id: 'general-information',
title: 'General Information',
faqs: [
{
title: 'Sample FAQ Question',
description: 'This is a sample FAQ answer. Please update with your actual content.'
}
]
}
]
});
await faq.save();
}
return faq;
};
// Static method: Import từ JSON
faqSchema.statics.importFromJson = async function(data) {
let faq = await this.findOne({ name: 'default' });
// Đảm bảo có name
const faqData = {
name: 'default',
...data
};
if (!faq) {
faq = new this(faqData);
} else {
// Update các trường
Object.keys(faqData).forEach(key => {
faq[key] = faqData[key];
});
}
await faq.save();
return faq;
};
// Method: Thêm FAQ vào section
faqSchema.methods.addFaqToSection = async function(sectionId, faqItem) {
const section = this.faqSections.find(s => s.id === sectionId);
if (!section) {
throw new Error(`Section with id ${sectionId} not found`);
}
section.faqs.push(faqItem);
await this.save();
return this;
};
// Method: Update FAQ item
faqSchema.methods.updateFaqItem = async function(sectionId, faqId, updates) {
const section = this.faqSections.find(s => s.id === sectionId);
if (!section) {
throw new Error(`Section with id ${sectionId} not found`);
}
const faqItem = section.faqs.id(faqId);
if (!faqItem) {
throw new Error(`FAQ item with id ${faqId} not found in section ${sectionId}`);
}
if (updates.title !== undefined) faqItem.title = updates.title;
if (updates.description !== undefined) faqItem.description = updates.description;
await this.save();
return this;
};
// Method: Delete FAQ item
faqSchema.methods.deleteFaqItem = async function(sectionId, faqId) {
const section = this.faqSections.find(s => s.id === sectionId);
if (!section) {
throw new Error(`Section with id ${sectionId} not found`);
}
const faqItem = section.faqs.id(faqId);
if (!faqItem) {
throw new Error(`FAQ item with id ${faqId} not found in section ${sectionId}`);
}
section.faqs.pull(faqId);
await this.save();
return this;
};
// Method: Update FAQ section
faqSchema.methods.updateFaqSection = async function(sectionId, updates) {
const section = this.faqSections.find(s => s.id === sectionId);
if (!section) {
throw new Error(`Section with id ${sectionId} not found`);
}
if (updates.title !== undefined) section.title = updates.title;
await this.save();
return this;
};
const FAQ = mongoose.model('FAQ', faqSchema);
module.exports = FAQ;

View File

@@ -1,213 +0,0 @@
const mongoose = require("mongoose");
// Schema cho menu links
const menuLinkSchema = new mongoose.Schema(
{
label: {
type: String,
required: true,
trim: true,
},
href: {
type: String,
required: true,
trim: true,
},
order: {
type: Number,
required: false,
default: 0,
},
},
{ _id: false },
);
// Schema cho social links
const socialLinkSchema = new mongoose.Schema(
{
icon: {
type: String,
required: true,
trim: true,
},
href: {
type: String,
required: true,
trim: true,
},
},
{ _id: false },
);
// Schema cho phone
const phoneSchema = new mongoose.Schema(
{
display: {
type: String,
required: false,
trim: true,
default: "",
},
href: {
type: String,
required: false,
trim: true,
default: "",
},
},
{ _id: false },
);
// Schema cho logo
const logoSchema = new mongoose.Schema(
{
src: {
type: String,
required: false,
trim: true,
default: "",
},
alt: {
type: String,
required: false,
trim: true,
default: "",
},
href: {
type: String,
required: false,
trim: true,
default: "/",
},
},
{ _id: false },
);
// Schema cho copyright
const copyrightSchema = new mongoose.Schema(
{
text: {
type: String,
required: false,
trim: true,
default: "Copyright©",
},
brand: {
type: String,
required: false,
trim: true,
default: "",
},
rights: {
type: String,
required: false,
trim: true,
default: "All Rights Reserved.",
},
},
{ _id: false },
);
// Schema cho top section
const topSchema = new mongoose.Schema(
{
bgImage: {
type: String,
required: false,
trim: true,
default: "",
},
phone: {
type: phoneSchema,
default: () => ({ display: "", href: "" }),
},
address: {
type: String,
required: false,
trim: true,
default: "",
},
logo: {
type: logoSchema,
default: () => ({ src: "", alt: "", href: "/" }),
},
menuLinks: {
type: [menuLinkSchema],
default: [],
},
socialLinks: {
type: [socialLinkSchema],
default: [],
},
},
{ _id: false },
);
// Schema cho bottom section
const bottomSchema = new mongoose.Schema(
{
copyright: {
type: copyrightSchema,
default: () => ({ text: "Copyright©", brand: "", rights: "All Rights Reserved." }),
},
menuLinks: {
type: [menuLinkSchema],
default: [],
},
},
{ _id: false },
);
// Main Footer Schema - khớp 100% với footer.json
const footerSchema = new mongoose.Schema(
{
top: {
type: topSchema,
default: () => ({
bgImage: "",
phone: { display: "", href: "" },
address: "",
logo: { src: "", alt: "", href: "/" },
menuLinks: [],
socialLinks: [],
}),
},
bottom: {
type: bottomSchema,
default: () => ({
copyright: { text: "Copyright©", brand: "", rights: "All Rights Reserved." },
menuLinks: [],
}),
},
},
{
timestamps: true,
},
);
// Static method để lấy hoặc tạo footer duy nhất
footerSchema.statics.getSingle = async function () {
let footer = await this.findOne();
if (!footer) {
footer = await this.create({});
}
return footer;
};
// Migration method để import từ JSON hiện tại
footerSchema.statics.migrateFromJson = async function (jsonData) {
try {
// Xóa tất cả documents hiện có
await this.deleteMany({});
// Tạo document mới
const footer = await this.create(jsonData);
console.log("Footer data migrated successfully");
return footer;
} catch (error) {
console.error("Error migrating footer data:", error);
throw error;
}
};
module.exports = mongoose.model("Footer", footerSchema);

View File

@@ -1,51 +0,0 @@
const mongoose = require('mongoose');
const formSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
unique: true
},
admission: {
background_image: String,
title: String,
year: String,
description: String,
form: {
fields: [{
type: { type: String },
placeholder: String
}],
button: {
text: String,
url: String
}
}
},
apply: {
title: String,
steps: [{
title: String,
description: String
}]
},
application_form: {
title: String,
question: String,
button: {
text: String,
icon: String,
url: String
},
links: [{
text: String,
url: String
}]
},
updatedAt: Date
}, {
timestamps: true
});
module.exports = mongoose.model('Form', formSchema);

View File

@@ -1,115 +0,0 @@
const mongoose = require("mongoose");
const socialLinkSchema = new mongoose.Schema(
{
platform: {
type: String,
required: true,
enum: ["linkedin", "twitter", "instagram", "youtube", "facebook"],
},
url: {
type: String,
required: true,
},
icon: String,
order: {
type: Number,
default: 0,
},
},
{ _id: false },
);
const languageSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
},
{ _id: false },
);
const menuItemSchema = new mongoose.Schema(
{
label: {
type: String,
required: true,
},
href: {
type: String,
required: true,
},
icon: String,
order: {
type: Number,
default: 0,
},
children: [this],
},
{ _id: false },
);
const headerSchema = new mongoose.Schema(
{
// Top bar
top: {
phone: String,
email: String,
location: String,
socialLinks: [socialLinkSchema],
languages: [languageSchema],
},
// Offcanvas
offcanvas: {
description: String,
contactInfo: {
address: String,
email: String,
workingHours: String,
phone: String,
},
},
// Menu
menu: [menuItemSchema],
// Logo
logo: {
light: String,
dark: String,
alt: String,
},
// CTA Button
ctaButton: {
label: String,
href: String,
style: {
type: String,
enum: ["primary", "secondary", "outline"],
default: "primary",
},
},
// Status
status: {
type: String,
enum: ["active", "inactive"],
default: "active",
},
order: {
type: Number,
default: 1,
},
},
{ timestamps: true },
);
module.exports = mongoose.model("Header", headerSchema);

View File

@@ -1,48 +0,0 @@
const mongoose = require('mongoose');
const HeaderMenuSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true
},
slug: {
type: String,
required: true,
trim: true,
lowercase: true
},
url: {
type: String,
required: true,
trim: true
},
parentId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'HeaderMenu',
default: null
},
order: {
type: Number,
default: 0
},
status: {
type: String,
enum: ['active', 'inactive'],
default: 'active'
},
type: {
type: String,
enum: ['internal', 'external'],
default: 'internal'
}
}, {
timestamps: true
});
// Indexes for optimization
HeaderMenuSchema.index({ order: 1 });
HeaderMenuSchema.index({ status: 1 });
HeaderMenuSchema.index({ parentId: 1, order: 1 });
module.exports = mongoose.model('HeaderMenu', HeaderMenuSchema);

View File

@@ -1,96 +0,0 @@
const mongoose = require('mongoose');
const heroSchema = new mongoose.Schema({
hero: {
title: {
type: String,
required: true
},
description: {
type: String,
required: true
},
backgroundImage: {
type: String,
required: true
},
overlayColor: {
type: String,
default: 'rgba(0, 0, 0, 0.35)'
},
enableScrollspy: {
type: Boolean,
default: true
},
backgroundPosition: {
type: String,
default: 'center center'
},
containerStyles: {
width: { type: String, default: '98%' },
height: { type: String, default: '130vh' },
margin: { type: String, default: '0 auto' },
borderRadius: { type: String, default: '2vw' },
overflow: { type: String, default: 'hidden' },
position: { type: String, default: 'relative' },
top: { type: String, default: '-10vh' }
},
titleClass: {
type: String,
default: 'uk-heading-large uk-text-center uk-text-white'
},
titleStyles: {
fontSize: { type: String, default: 'clamp(2rem, 5vw, 4.5rem)' },
fontWeight: { type: String, default: 'bold' },
lineHeight: { type: String, default: '1.2' },
marginBottom: { type: String, default: '1.5rem' },
color: { type: String, default: 'white' },
textShadow: { type: String, default: '0 2px 10px rgba(0, 0, 0, 0.3)' }
},
descriptionClass: {
type: String,
default: 'uk-text-white'
},
descriptionStyles: {
fontSize: { type: String, default: 'clamp(1rem, 1.5vw, 1.25rem)' },
maxWidth: { type: String, default: '800px' },
margin: { type: String, default: '0 auto 2rem' },
lineHeight: { type: String, default: '1.6' },
color: { type: String, default: 'white' },
textShadow: { type: String, default: '0 1px 5px rgba(0, 0, 0, 0.3)' }
}
},
button: {
label: {
type: String,
default: 'Book Your Adventure'
},
href: {
type: String,
default: '/booking'
},
type: {
type: String,
default: 'magic'
}
},
contactBox: {
enabled: {
type: Boolean,
default: true
},
position: {
position: { type: String, default: 'absolute' },
bottom: { type: String, default: '3rem' },
left: { type: String, default: '50%' },
transform: { type: String, default: 'translateX(-50%)' },
width: { type: String, default: '100%' },
zIndex: { type: Number, default: 3 },
padding: { type: String, default: '0 1rem' }
}
}
}, {
timestamps: true
});
module.exports = mongoose.model('Hero', heroSchema);

View File

@@ -1,277 +0,0 @@
const mongoose = require("mongoose");
const { Schema } = mongoose;
// Reusable small schemas
const LinkSchema = new Schema(
{
label: { type: String, default: "" },
href: { type: String, default: "" },
},
{ _id: false },
);
// Hero slide (for multiple hero items in slider)
const HeroSlideSchema = new Schema(
{
title: { type: String, default: "" },
subtitle: { type: String, default: "" },
description: { type: String, default: "" },
primaryButton: { type: LinkSchema, default: () => ({}) },
secondaryButton: { type: LinkSchema, default: () => ({}) },
heroImage: { type: String, default: "" },
videoUrl: { type: String, default: "" },
},
{ _id: false },
);
const HeroSchema = new Schema(
{
// Background for whole hero section
backgroundImage: { type: String, default: "" },
// Multiple slides
slides: { type: [HeroSlideSchema], default: [] },
// Legacy single-slide fields (backward compatible)
title: { type: String, default: "" },
subtitle: { type: String, default: "" },
description: { type: String, default: "" },
primaryButton: { type: LinkSchema, default: () => ({}) },
secondaryButton: { type: LinkSchema, default: () => ({}) },
heroImage: { type: String, default: "" },
videoUrl: { type: String, default: "" },
},
{ _id: false },
);
const WhyChooseUsItemSchema = new Schema(
{
icon: { type: String, default: "" },
title: { type: String, default: "" },
description: { type: String, default: "" },
},
{ _id: false },
);
const WhyChooseUsSchema = new Schema(
{
heading: { type: String, default: "" },
subheading: { type: String, default: "" },
description: { type: String, default: "" },
highlightWord: { type: String, default: "" },
mainImage: { type: String, default: "" },
secondaryImage: { type: String, default: "" },
items: { type: [WhyChooseUsItemSchema], default: [] },
features: { type: [String], default: [] },
ctaButton: { type: LinkSchema, default: () => ({}) },
},
{ _id: false },
);
const VisaSolutionItemSchema = new Schema(
{
number: { type: String, default: "" },
title: { type: String, default: "" },
description: { type: String, default: "" },
link: { type: String, default: "" },
},
{ _id: false },
);
const VisaSolutionsSchema = new Schema(
{
heading: { type: String, default: "" },
subheading: { type: String, default: "" },
items: { type: [VisaSolutionItemSchema], default: [] },
},
{ _id: false },
);
const VisaCountrySchema = new Schema(
{
name: { type: String, default: "" },
code: { type: String, default: "" },
flag: { type: String, default: "" },
link: { type: String, default: "" },
visaTypes: { type: [String], default: [] },
},
{ _id: false },
);
const VisaCountriesSchema = new Schema(
{
heading: { type: String, default: "" },
subheading: { type: String, default: "" },
description: { type: String, default: "" },
countries: { type: [VisaCountrySchema], default: [] },
ctaButton: { type: LinkSchema, default: () => ({}) },
},
{ _id: false },
);
const TestimonialSchema = new Schema(
{
name: { type: String, default: "" },
role: { type: String, default: "" },
country: { type: String, default: "" },
rating: { type: Number, default: 5 },
comment: { type: String, default: "" },
avatar: { type: String, default: "" },
},
{ _id: false },
);
const TestimonialsSchema = new Schema(
{
heading: { type: String, default: "" },
subheading: { type: String, default: "" },
videoUrl: { type: String, default: "" },
videoThumbnail: { type: String, default: "" },
items: { type: [TestimonialSchema], default: [] },
},
{ _id: false },
);
const VideoGallerySchema = new Schema(
{
heading: { type: String, default: "" },
videoUrl: { type: String, default: "" },
thumbnail: { type: String, default: "" },
},
{ _id: false },
);
const FaqItemSchema = new Schema(
{
question: { type: String, default: "" },
answer: { type: String, default: "" },
},
{ _id: false },
);
const FaqSchema = new Schema(
{
heading: { type: String, default: "" },
subheading: { type: String, default: "" },
description: { type: String, default: "" },
ctaButton: { type: LinkSchema, default: () => ({}) },
items: { type: [FaqItemSchema], default: [] },
},
{ _id: false },
);
const AchievementItemSchema = new Schema(
{
value: { type: String, default: "" },
suffix: { type: String, default: "" },
label: { type: String, default: "" },
description: { type: String, default: "" },
},
{ _id: false },
);
const AchievementsSchema = new Schema(
{
heading: { type: String, default: "" },
subheading: { type: String, default: "" },
items: { type: [AchievementItemSchema], default: [] },
},
{ _id: false },
);
const VisaConsultancyItemSchema = new Schema(
{
name: { type: String, default: "" },
icon: { type: String, default: "" },
year: { type: String, default: "" },
},
{ _id: false },
);
const VisaConsultancySchema = new Schema(
{
items: { type: [VisaConsultancyItemSchema], default: [] },
},
{ _id: false },
);
const BrandItemSchema = new Schema(
{
logo: { type: String, default: "" },
},
{ _id: false },
);
const BrandsSchema = new Schema(
{
items: { type: [BrandItemSchema], default: [] },
},
{ _id: false },
);
const PartnersSchema = new Schema(
{
visaConsultancy: { type: VisaConsultancySchema, default: () => ({}) },
brands: { type: BrandsSchema, default: () => ({}) },
},
{ _id: false },
);
const BlogPreviewItemSchema = new Schema(
{
title: { type: String, default: "" },
excerpt: { type: String, default: "" },
category: { type: String, default: "" },
date: { type: String, default: "" }, // keep as string for easy JSON compatibility (e.g. "2025-08-20")
author: {
name: { type: String, default: "" },
avatar: { type: String, default: "" },
},
comments: { type: Number, default: 0 },
link: { type: String, default: "" },
thumbnail: { type: String, default: "" },
},
{ _id: false },
);
const BlogPreviewSchema = new Schema(
{
heading: { type: String, default: "" },
subheading: { type: String, default: "" },
ctaButton: { type: LinkSchema, default: () => ({}) },
items: { type: [BlogPreviewItemSchema], default: [] },
selectedBlogIds: [{ type: Schema.Types.ObjectId, ref: 'Blog' }],
},
{ _id: false },
);
/**
* Home page content model
*
* NOTE:
* - This schema is based on `hailearning.edu.vn/app/home.json`.
* - `strict: false` keeps backward compatibility with any existing CMS-only sections
* (e.g. about/missionVision/programs/newsletter/latestPosts...) that the admin UI might still send.
*/
const HomeSchema = new Schema(
{
hero: { type: HeroSchema, default: () => ({}) },
whyChooseUs: { type: WhyChooseUsSchema, default: () => ({}) },
visaSolutions: { type: VisaSolutionsSchema, default: () => ({}) },
visaCountries: { type: VisaCountriesSchema, default: () => ({}) },
testimonials: { type: TestimonialsSchema, default: () => ({}) },
videoGallery: { type: VideoGallerySchema, default: () => ({}) },
faq: { type: FaqSchema, default: () => ({}) },
achievements: { type: AchievementsSchema, default: () => ({}) },
partners: { type: PartnersSchema, default: () => ({}) },
blogPreview: { type: BlogPreviewSchema, default: () => ({}) },
},
{
timestamps: true,
strict: false,
},
);
module.exports = mongoose.model("Home", HomeSchema);

View File

@@ -1,302 +0,0 @@
const mongoose = require("mongoose");
// Schema cho content items
const contentItemSchema = new mongoose.Schema(
{
type: {
type: String,
enum: ["paragraph", "section", "list", "note", "embed", "header"],
required: true,
},
text: {
type: String,
trim: true,
default: "",
},
title: {
type: String,
trim: true,
default: "",
},
content: {
type: String,
trim: true,
default: "",
},
items: {
type: [String],
default: [],
},
level: {
type: Number,
default: 2,
},
// Embed/video fields
embed: {
type: String,
trim: true,
default: ''
},
url: {
type: String,
trim: true,
default: ''
},
source: {
type: String,
trim: true,
default: ''
},
videoId: {
type: String,
trim: true,
default: ''
},
caption: {
type: String,
trim: true,
default: ''
},
width: {
type: Number,
default: 0
},
height: {
type: Number,
default: 0
},
},
{ _id: false }
);
// Schema cho overlay style
const overlayStyleSchema = new mongoose.Schema(
{
backgroundColor: {
type: String,
trim: true,
default: "rgba(0, 0, 0, 0)",
},
},
{ _id: false }
);
// Schema cho hero section
const heroSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
default: "Insurance & Travel Cancellation Guarantee",
},
subtitle: {
type: String,
trim: true,
default: "Comprehensive coverage for your peace of mind",
},
backgroundImage: {
type: String,
trim: true,
default: "/uploads/banner/b13.jpg",
},
sectionClass: {
type: String,
trim: true,
default: "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
},
backgroundClasses: {
type: String,
trim: true,
default: "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
},
overlayStyle: {
type: overlayStyleSchema,
default: () => ({ backgroundColor: "rgba(0, 0, 0, 0)" }),
},
titleClass: {
type: String,
trim: true,
default: "text-white text-[5vw] uk-text-center",
},
subtitleClass: {
type: String,
trim: true,
default: "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
},
enableScrollspy: {
type: Boolean,
default: true,
},
},
{ _id: false }
);
// Schema cho page section
const pageSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
default: "Insurance & Travel Information",
},
divider: {
type: Boolean,
default: true,
},
sectionClass: {
type: String,
trim: true,
default: "uk-section-default uk-section-overlap uk-section",
},
titleClass: {
type: String,
trim: true,
default: "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
},
dividerClass: {
type: String,
trim: true,
default: "uk-divider-small uk-text-left@m uk-text-center",
},
},
{ _id: false }
);
// Schema cho content section
const contentSchema = new mongoose.Schema(
{
sectionClass: {
type: String,
trim: true,
default: "uk-section-muted uk-section-overlap uk-section",
},
textClass: {
type: String,
trim: true,
default: "uk-panel uk-margin text-[1vw]",
},
content: {
type: [contentItemSchema],
default: [],
},
},
{ _id: false }
);
// Main Insurance Schema - CẤU TRÚC MỚI
const insuranceSchema = new mongoose.Schema(
{
name: {
type: String,
default: "default",
unique: true,
},
// 3 PHẦN CHÍNH
hero: {
type: heroSchema,
required: true,
},
page: {
type: pageSchema,
required: true,
},
content: {
type: contentSchema,
required: true,
},
language: {
type: String,
default: "en",
},
version: {
type: String,
default: "2.0.0",
},
isActive: {
type: Boolean,
default: true,
},
migratedFromOldStructure: {
type: Boolean,
default: false,
},
},
{
timestamps: true,
}
);
// Static method: Lấy insurance default
insuranceSchema.statics.getDefault = async function(language = "en") {
try {
let insurance = await this.findOne({ name: "default", language: language });
if (!insurance) {
// Tạo default data nếu chưa có
insurance = await this.create({
name: "default",
language: language,
hero: {
title: "Insurance & Travel Cancellation Guarantee",
subtitle: "Comprehensive coverage for your peace of mind",
backgroundImage: "/uploads/banner/b13.jpg",
},
page: {
title: "Insurance & Travel Information",
divider: true,
},
content: {
content: []
}
});
}
return insurance;
} catch (error) {
console.error("Error in getDefault:", error);
throw error;
}
};
// Method để get insurance data
insuranceSchema.methods.getInsuranceData = function() {
return this.toObject();
};
// Migration method - chỉ hỗ trợ cấu trúc mới
insuranceSchema.statics.migrateFromJson = async function(jsonData, language = "en") {
try {
console.log('Migrating insurance from JSON...');
// Xóa document cũ nếu có
await this.deleteOne({ name: "default", language: language });
// Sử dụng dữ liệu từ JSON trực tiếp
const processedData = {
name: "default",
language: language,
version: "2.0.0",
isActive: true,
hero: jsonData.hero,
page: jsonData.page,
content: jsonData.content
};
// Tạo document mới
const newInsurance = await this.create(processedData);
const contentItems = jsonData.content?.content || [];
console.log(`Insurance data migrated successfully for language: ${language}`);
console.log(`Total content items: ${contentItems.length}`);
return newInsurance;
} catch (error) {
console.error("Error migrating insurance data to new structure:", error);
throw error;
}
};
const Insurance = mongoose.model("Insurance", insuranceSchema);
module.exports = Insurance;

View File

@@ -1,65 +1,12 @@
const mongoose = require('mongoose');
const levelSchema = new mongoose.Schema({
brochure: { type: String },
type: {
type: String,
required: true,
unique: true,
trim: true
},
banner: {
image: String,
title: String,
text: String
},
overview: {
title: String,
paragraphs: [String],
contact_info: {
title: String,
subtitle: String,
items: [{
text: String
}]
},
social_info: {
title: String,
social_links: [{
image: String,
url: String,
alt: String
}],
apply_button: {
text: String,
url: String
}
}
},
requirements: {
title: String,
items: [String]
},
action_buttons: {
title: String,
buttons: [{
text: String,
link: String
}]
},
why_study: {
title: String,
items: [{
number: String,
title: String,
text: String
}]
},
// Thêm tham chiếu đến Form
form: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Form'
},
updatedAt: Date
}
}, {
timestamps: true
});

View File

@@ -1,185 +0,0 @@
const mongoose = require('mongoose');
const MenuSchema = new mongoose.Schema({
menuid: { type: String, required: true, unique: true }, // ID tùy chỉnh
parent: { type: String, default: null }, // ID menu cha
title: { type: String, required: true }, // Tên hiển thị
url: { type: String, default: '' }, // Đường dẫn
order: { type: Number, default: 0 }, // Thứ tự hiển thị
type: { type: String, enum: ['static', 'page', 'level'], default: 'static' }, // Loại menu
fetch: { type: Boolean, default: false }, // Có fetch programme không
isActive: { type: Boolean, default: true } // Trạng thái hoạt động của menu
}, { timestamps: false });
// Index để tối ưu query
MenuSchema.index({ parent: 1, order: 1 });
MenuSchema.index({ type: 1 });
// Method để lấy menu tree
MenuSchema.statics.getMenuTree = async function () {
try {
const allMenus = await this.find().sort({ order: 1 }).lean();
const menuMap = new Map();
const rootMenus = [];
// Đưa tất cả menus vào map và xử lý URL dựa trên isActive
allMenus.forEach(menu => {
const activeUrl = menu.isActive === false ? '/maintenance/' : menu.url;
menuMap.set(menu.menuid, {
...menu,
url: activeUrl, // Sử dụng URL đã được xử lý
children: []
});
});
// Xây dựng tree structure
allMenus.forEach(menu => {
const menuObj = menuMap.get(menu.menuid);
if (!menu.parent) {
rootMenus.push(menuObj);
} else {
const parent = menuMap.get(menu.parent);
if (parent) {
parent.children.push(menuObj);
}
}
});
return rootMenus;
} catch (error) {
console.error('Error building menu tree:', error);
throw error;
}
};
// Method để lấy menu tree với programmes
MenuSchema.statics.getMenuTreeWithProgrammes = async function () {
try {
const menuTree = await this.getMenuTree();
// Thêm programmes cho các menu level
for (const menu of menuTree) {
await this.addProgrammesToMenu(menu);
}
return menuTree;
} catch (error) {
console.error('Error building menu tree with programmes:', error);
throw error;
}
};
// Method để thêm programmes vào menu
MenuSchema.statics.addProgrammesToMenu = async function (menuItem) {
try {
if (menuItem.type === 'level' && menuItem.fetch) {
const programmes = await this.getProgrammesByMenuId(menuItem.menuid);
menuItem.programmes = programmes;
}
if (menuItem.children && menuItem.children.length > 0) {
for (const child of menuItem.children) {
await this.addProgrammesToMenu(child);
}
}
} catch (error) {
console.error('Error adding programmes to menu:', error);
throw error;
}
};
// Method để lấy programmes theo menu ID
MenuSchema.statics.getProgrammesByMenuId = async function (menuId) {
try {
const Programme = require('./programme');
const Level = require('./level');
// Sử dụng trực tiếp menuId làm levelType vì menuid đã được đặt đúng khi tạo
const levelType = menuId;
const level = await Level.findOne({ type: levelType });
if (!level) return [];
const programmes = await Programme.find({ level: level._id })
.select('name code level_type')
.sort({ name: 1 })
.lean();
return programmes;
} catch (error) {
console.error('Error getting programmes by menu ID:', error);
throw error;
}
};
// Method để tạo menu mới
MenuSchema.statics.createMenu = async function (menuData) {
try {
const menu = new this(menuData);
await menu.save();
return menu;
} catch (error) {
console.error('Error creating menu:', error);
throw error;
}
};
// Method để cập nhật menu
MenuSchema.statics.updateMenu = async function (menuId, updateData) {
try {
const menu = await this.findOneAndUpdate({ menuid: menuId }, updateData, { new: true });
return menu;
} catch (error) {
console.error('Error updating menu:', error);
throw error;
}
};
// Method để xóa menu
MenuSchema.statics.deleteMenu = async function (menuId) {
try {
// Xóa tất cả children trước
await this.deleteMany({ parent: menuId });
// Sau đó xóa menu chính
const result = await this.findOneAndDelete({ menuid: menuId });
return result;
} catch (error) {
console.error('Error deleting menu:', error);
throw error;
}
};
// Method để sắp xếp lại order
MenuSchema.statics.reorderMenus = async function (parentId, menuIds) {
try {
const updates = menuIds.map((menuId, index) => ({
updateOne: {
filter: { menuid: menuId },
update: { order: index }
}
}));
await this.bulkWrite(updates);
console.log('Menus reordered successfully');
} catch (error) {
console.error('Error reordering menus:', error);
throw error;
}
};
// Method để lấy URL dựa trên trạng thái isActive
MenuSchema.methods.getActiveUrl = function () {
if (this.isActive === false) {
return '/maintenance/';
}
return this.url;
};
// Method để kiểm tra trạng thái hoạt động
MenuSchema.methods.isMenuActive = function () {
return this.isActive !== false;
};
module.exports = mongoose.model('MenuHeader', MenuSchema);

View File

@@ -1,328 +0,0 @@
const mongoose = require("mongoose");
// Clear cache
if (mongoose.models.Pricing) {
delete mongoose.models.Pricing;
}
if (mongoose.connection.models.Pricing) {
delete mongoose.connection.models.Pricing;
}
// Schema for breadcrumb item
const breadcrumbItemSchema = new mongoose.Schema(
{
text: {
type: String,
trim: true,
default: "",
},
link: {
type: String,
trim: true,
default: "",
},
},
{ _id: false }
);
// Schema for hero section
const heroSchema = new mongoose.Schema(
{
title: {
type: String,
trim: true,
default: "Pricing Plan",
},
backgroundImage: {
type: String,
trim: true,
default: "/assets/img/inner-page/breadcrumb.jpg",
},
shapeImage: {
type: String,
trim: true,
default: "/assets/img/inner-page/shape.png",
},
breadcrumb: {
type: [breadcrumbItemSchema],
default: [],
},
},
{ _id: false }
);
// Schema for pricing section header
const pricingSectionSchema = new mongoose.Schema(
{
subtitle: {
type: String,
trim: true,
default: "pricing plan",
},
heading: {
type: String,
trim: true,
default: "Flexible Plans to Suit Every Traveler",
},
description: {
type: String,
trim: true,
default: "",
},
},
{ _id: false }
);
// Schema for individual plan
const planSchema = new mongoose.Schema(
{
name: {
type: String,
trim: true,
required: true,
},
price: {
type: String,
trim: true,
default: "0",
},
period: {
type: String,
trim: true,
default: "mo",
},
currency: {
type: String,
trim: true,
default: "$",
},
buttonText: {
type: String,
trim: true,
default: "Get Started Today",
},
buttonLink: {
type: String,
trim: true,
default: "/pricing",
},
buttonIcon: {
type: String,
trim: true,
default: "fa-solid fa-arrow-right",
},
style: {
type: String,
trim: true,
enum: ["default", "style-2"],
default: "default",
},
features: {
type: [String],
default: [],
},
},
{ _id: false }
);
// Schema for plans container
const plansSchema = new mongoose.Schema(
{
monthly: {
type: [planSchema],
default: [],
},
yearly: {
type: [planSchema],
default: [],
},
},
{ _id: false }
);
// Schema for testimonial item
const testimonialItemSchema = new mongoose.Schema(
{
name: {
type: String,
trim: true,
default: "",
},
role: {
type: String,
trim: true,
default: "",
},
rating: {
type: Number,
min: 1,
max: 5,
default: 5,
},
content: {
type: String,
trim: true,
default: "",
},
},
{ _id: false }
);
// Schema for testimonials section
const testimonialsSchema = new mongoose.Schema(
{
subtitle: {
type: String,
trim: true,
default: "What Our Clients Say",
},
heading: {
type: String,
trim: true,
default: "Immigration Success Stories",
},
buttonText: {
type: String,
trim: true,
default: "View All Review",
},
buttonLink: {
type: String,
trim: true,
default: "/contact",
},
buttonIcon: {
type: String,
trim: true,
default: "fa-solid fa-arrow-right",
},
image: {
type: String,
trim: true,
default: "",
},
items: {
type: [testimonialItemSchema],
default: [],
},
},
{ _id: false }
);
// Main Pricing Schema
const pricingSchema = new mongoose.Schema(
{
name: {
type: String,
default: "default",
unique: true,
},
hero: {
type: heroSchema,
default: () => ({}),
},
pricingSection: {
type: pricingSectionSchema,
default: () => ({}),
},
plans: {
type: plansSchema,
default: () => ({}),
},
testimonials: {
type: testimonialsSchema,
default: () => ({}),
},
},
{
timestamps: true,
}
);
// Migration method to import data from JSON
pricingSchema.statics.migrateFromJson = async function (jsonData) {
try {
// Check if default pricing exists
const existingPricing = await this.findOne({ name: "default" });
// Process data from JSON
const processedData = {
hero: {
title: jsonData.hero?.title || "Pricing Plan",
backgroundImage: jsonData.hero?.backgroundImage || "/assets/img/inner-page/breadcrumb.jpg",
shapeImage: jsonData.hero?.shapeImage || "/assets/img/inner-page/shape.png",
breadcrumb: (jsonData.hero?.breadcrumb || []).map((item) => ({
text: item.text || "",
link: item.link || "",
})),
},
pricingSection: {
subtitle: jsonData.pricingSection?.subtitle || "pricing plan",
heading: jsonData.pricingSection?.heading || "Flexible Plans to Suit Every Traveler",
description: jsonData.pricingSection?.description || "",
},
plans: {
monthly: (jsonData.plans?.monthly || []).map((plan) => ({
name: plan.name || "",
price: plan.price || "0",
period: plan.period || "mo",
currency: plan.currency || "$",
buttonText: plan.buttonText || "Get Started Today",
buttonLink: plan.buttonLink || "/pricing",
buttonIcon: plan.buttonIcon || "fa-solid fa-arrow-right",
style: plan.style || "default",
features: plan.features || [],
})),
yearly: (jsonData.plans?.yearly || []).map((plan) => ({
name: plan.name || "",
price: plan.price || "0",
period: plan.period || "mo",
currency: plan.currency || "$",
buttonText: plan.buttonText || "Get Started Today",
buttonLink: plan.buttonLink || "/pricing",
buttonIcon: plan.buttonIcon || "fa-solid fa-arrow-right",
style: plan.style || "default",
features: plan.features || [],
})),
},
testimonials: {
subtitle: jsonData.testimonials?.subtitle || "What Our Clients Say",
heading: jsonData.testimonials?.heading || "Immigration Success Stories",
buttonText: jsonData.testimonials?.buttonText || "View All Review",
buttonLink: jsonData.testimonials?.buttonLink || "/contact",
buttonIcon: jsonData.testimonials?.buttonIcon || "fa-solid fa-arrow-right",
image: jsonData.testimonials?.image || "",
items: (jsonData.testimonials?.items || []).map((item) => ({
name: item.name || "",
role: item.role || "",
rating: item.rating || 5,
content: item.content || "",
})),
},
};
if (existingPricing) {
// Update existing pricing
existingPricing.hero = processedData.hero;
existingPricing.pricingSection = processedData.pricingSection;
existingPricing.plans = processedData.plans;
existingPricing.testimonials = processedData.testimonials;
await existingPricing.save();
console.log("Pricing data updated successfully");
return existingPricing;
} else {
// Create new pricing
const newPricing = await this.create({
name: "default",
...processedData,
});
console.log("Pricing data imported successfully");
return newPricing;
}
} catch (error) {
console.error("Error migrating pricing data:", error);
throw error;
}
};
module.exports = mongoose.model("Pricing", pricingSchema);

35
models/qualification.js Normal file
View File

@@ -0,0 +1,35 @@
const mongoose = require('mongoose');
const qualificationSchema = new mongoose.Schema({
qualification_number: {
type: String, required: true, unique: true, trim: true
},
student_name: {
type: String, required: true, trim: true
},
program_name: {
type: String, required: true, trim: true
},
department: {
type: mongoose.Schema.Types.ObjectId, ref: 'Department', required: true
},
level: {
type: mongoose.Schema.Types.ObjectId, ref: 'Level', required: true
},
issued_date: {
type: Date, required: true
},
status: {
type: String, enum: ['active', 'revoked'], default: 'active'
},
// Optional personal info
passport_number: { type: String, trim: true },
address: { type: String, trim: true },
// PhD fields — presence of topic_name signals PhD view on frontend
topic_name: { type: String, trim: true },
topic_short_desc: { type: String, trim: true },
// Document image
degree_image: { type: String }
}, { timestamps: true });
module.exports = mongoose.model('Qualification', qualificationSchema);

View File

@@ -1,79 +0,0 @@
const mongoose = require('mongoose');
// Recent Post model - có thể là view hoặc collection riêng để optimize performance
const recentPostSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true
},
slug: {
type: String,
required: true,
trim: true
},
thumbnail: {
type: String,
default: '' // Ảnh nhỏ ở sidebar
},
publishedAt: {
type: String, // "March 26, 2025"
required: true
},
// Reference to original blog post
originalPostId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Blog',
required: true
}
}, {
timestamps: true
});
// Indexes
recentPostSchema.index({ createdAt: -1 });
recentPostSchema.index({ originalPostId: 1 });
// Remove __v from JSON output
recentPostSchema.set('toJSON', {
transform: function(doc, ret) {
delete ret.__v;
return ret;
}
});
// Static method to sync with Blog posts
recentPostSchema.statics.syncFromBlogs = async function(limit = 5) {
const Blog = require('./blog');
// Get recent published blogs
const recentBlogs = await Blog.find({ status: 'published' })
.sort({ createdAt: -1 })
.limit(limit)
.select('title slug featuredImage publishedAt');
// Clear existing recent posts
await this.deleteMany({});
// Create new recent posts
const recentPosts = recentBlogs.map(blog => ({
title: blog.title,
slug: blog.slug,
thumbnail: blog.featuredImage,
publishedAt: blog.publishedAt,
originalPostId: blog._id
}));
if (recentPosts.length > 0) {
await this.insertMany(recentPosts);
}
return recentPosts;
};
// Static method to get recent posts
recentPostSchema.statics.getRecent = function(limit = 5) {
return this.find({}).sort({ createdAt: -1 }).limit(limit);
};
module.exports = mongoose.model('RecentPost', recentPostSchema);

View File

@@ -1,76 +0,0 @@
const mongoose = require("mongoose");
// Schema cho hero section
const safetySchema = new mongoose.Schema(
{
//hero section
hero: {
banner: String,
title: String,
},
//approach section
approach: {
badge: String,
title:String,
description:String,
imgs:{
img1:String,
img2:String
},
stats:{
count:String,
label:String,
avatars:[String]
},
features:[
{text:String}
],
cards: [
{
title: String,
content: String,
},
],
},
//philosophy section
philosophy: {
title: String,
subtitle: String,
cards: [
{
title: String,
content: String,
author: {
avt: String,
name: String,
role: String,
rating: String,
},
},
],
},
//security section
security: {
title: String,
subtitle: String,
cards: [
{
title: String,
content: String,
author: {
avt: String,
name: String,
role: String,
rating: String,
},
},
],
},
},
{ timestamps: true }
);
module.exports = mongoose.model("Safety", safetySchema);

View File

@@ -1,118 +0,0 @@
const mongoose = require("mongoose");
// Define sub-schemas first
const authorSchema = new mongoose.Schema(
{
name: String,
type: String,
},
{ _id: false },
);
const clientReviewSchema = new mongoose.Schema(
{
id: String,
rating: Number,
content: String,
author: authorSchema,
icon: String,
},
{ _id: false },
);
const featureSchema = new mongoose.Schema(
{
title: String,
description: String,
},
{ _id: false },
);
const faqSchema = new mongoose.Schema(
{
id: String,
question: String,
answer: String,
isExpanded: { type: Boolean, default: false },
},
{ _id: false },
);
const serviceDetailsSchema = new mongoose.Schema(
{
title: String,
description: String,
mainImage: String,
overviewTitle: String,
overviewDescription: String,
additionalDescription: String,
keyFeaturesTitle: String,
keyFeaturesImage: String,
features: [featureSchema],
faqTitle: String,
faqImage: String,
faq: [faqSchema],
},
{ _id: false },
);
// Main service page schema
const serviceSchema = new mongoose.Schema(
{
pageTitle: String,
// Main services section
services: {
title: {
subTitle: String,
mainTitle: String,
},
items: [
{
slug: String,
name: String,
description: String,
image: String,
layout: String,
details: serviceDetailsSchema,
},
],
},
// Destination countries section
destinations: {
backgroundImage: String,
title: {
subTitle: String,
mainTitle: String,
},
},
// Visa types section
visas: {
items: [
{
id: String,
number: String,
name: String,
description: String,
buttonText: String,
buttonLink: String,
},
],
},
// Client reviews section
reviews: {
title: {
subTitle: String,
mainTitle: String,
},
thumb: String,
items: [clientReviewSchema],
},
},
{ timestamps: true },
);
module.exports = mongoose.model("Service", serviceSchema);

View File

@@ -1,519 +0,0 @@
// models/terms.js
const mongoose = require("mongoose");
// Schema cho content items
const contentItemSchema = new mongoose.Schema(
{
type: {
type: String,
enum: ["paragraph", "section", "header", "list", "cancellation_table", "cancellation_section", "note", "embed", "image"],
required: true,
},
text: {
type: String,
trim: true,
default: "",
},
// Header level (h2, h3, h4, h5, h6)
level: {
type: Number,
min: 1,
max: 6,
default: 2,
},
title: {
type: String,
trim: true,
default: "",
},
content: {
type: String,
trim: true,
default: "",
},
subsections: {
type: [mongoose.Schema.Types.Mixed], // Recursive reference
default: [],
},
items: {
type: [String],
default: [],
},
// List style (for list type)
style: {
type: String,
enum: ["ordered", "unordered"],
default: "unordered",
},
// Embed/video fields (optional)
embed: {
type: String,
trim: true,
default: ''
},
url: {
type: String,
trim: true,
default: ''
},
source: {
type: String,
trim: true,
default: ''
},
videoId: {
type: String,
trim: true,
default: ''
},
caption: {
type: String,
trim: true,
default: ''
},
width: {
type: Number,
default: 0
},
height: {
type: Number,
default: 0
},
},
{ _id: false }
);
// Schema cho overlay style
const overlayStyleSchema = new mongoose.Schema(
{
backgroundColor: {
type: String,
trim: true,
default: "rgba(0, 0, 0, 0)",
},
},
{ _id: false }
);
// Schema cho hero section - CẤU TRÚC MỚI
const heroSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
default: "Frequently Asked Questions",
},
backgroundImage: {
type: String,
trim: true,
default: "/uploads/terms/faqimage.jpg",
},
sectionClass: {
type: String,
trim: true,
default: "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
},
backgroundClasses: {
type: String,
trim: true,
default: "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
},
overlayStyle: {
type: overlayStyleSchema,
default: () => ({ backgroundColor: "rgba(0, 0, 0, 0)" }),
},
titleClass: {
type: String,
trim: true,
default: "text-white text-[5vw] uk-text-center",
},
enableScrollspy: {
type: Boolean,
default: true,
},
},
{ _id: false }
);
// Schema cho page section - CẤU TRÚC MỚI
const pageSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
default: "Terms & Conditions Go and Grow Camp e.K.",
},
divider: {
type: Boolean,
default: true,
},
sectionClass: {
type: String,
trim: true,
default: "uk-section-default uk-section-overlap uk-section",
},
titleClass: {
type: String,
trim: true,
default: "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
},
dividerClass: {
type: String,
trim: true,
default: "uk-divider-small uk-text-left@m uk-text-center",
},
},
{ _id: false }
);
// Schema cho content section - CẤU TRÚC MỚI
const contentSchema = new mongoose.Schema(
{
sectionClass: {
type: String,
trim: true,
default: "uk-section-muted uk-section-overlap uk-section",
},
textClass: {
type: String,
trim: true,
default: "uk-panel uk-margin text-[1vw]",
},
content: {
type: [contentItemSchema],
default: [],
},
},
{ _id: false }
);
// Main Terms Schema - CẤU TRÚC MỚI
const termsSchema = new mongoose.Schema(
{
name: {
type: String,
default: "default",
unique: true,
},
// CHỈ CÒN 3 PHẦN CHÍNH
hero: {
type: heroSchema,
required: true,
},
page: {
type: pageSchema,
required: true,
},
content: {
type: contentSchema,
required: true,
},
language: {
type: String,
default: "en",
},
version: {
type: String,
default: "2.0.0", // Tăng version vì cấu trúc thay đổi
},
isActive: {
type: Boolean,
default: true,
},
migratedFromOldStructure: {
type: Boolean,
default: false,
},
},
{
timestamps: true,
}
);
// Static method: Lấy terms default - CẬP NHẬT THEO CẤU TRÚC MỚI
termsSchema.statics.getDefault = async function(language = "en") {
try {
let terms = await this.findOne({ name: "default", language: language });
if (!terms) {
// Tạo terms mặc định theo cấu trúc mới
terms = new this({
name: "default",
language: language,
hero: {
title: "Frequently Asked Questions",
subtitle: "Our Terms & Conditions",
backgroundImage: "/uploads/terms/faqimage.jpg",
sectionClass: "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
backgroundClasses: "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
overlayStyle: {
backgroundColor: "rgba(0, 0, 0, 0)"
},
titleClass: "text-white text-[5vw] uk-text-center",
subtitleClass: "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
enableScrollspy: true
},
page: {
title: "Terms & Conditions Go and Grow Camp e.K.",
divider: true,
sectionClass: "uk-section-default uk-section-overlap uk-section",
titleClass: "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
dividerClass: "uk-divider-small uk-text-left@m uk-text-center"
},
content: {
sectionClass: "uk-section-muted uk-section-overlap uk-section",
textClass: "uk-panel uk-margin text-[1vw]",
content: [
{
type: "paragraph",
text: "This is an English translation of the original and legally binding German document \"Allgemeine Geschäftsbedingungen Go and Grow Camp e.K.\", which can be viewed at <a href=\"https://www.campadventure.de/de/infos/agb\" target=\"_self\">https://www.campadventure.de/de/infos/agb</a>. This translation is for your information only and is not legally binding."
},
{
type: "paragraph",
text: "<strong>Go and Grow Camp e.K. is the tour operator for individuals, for camps in Germany, England and Northern Ireland.</strong>"
},
{
type: "paragraph",
text: "GUARANTEE: All participants are protected in accordance with the legal regulations governing tour operators in Germany. As per §651, any payments made towards the travel price are insured against insolvency by tourVers."
}
]
},
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false
});
await terms.save();
console.log(`Created default terms for language: ${language} (new structure)`);
}
return terms;
} catch (error) {
console.error("Error in getDefault:", error);
throw error;
}
};
// Method để get terms data
termsSchema.methods.getTermsData = function() {
return this.toObject();
};
// Migration method từ JSON CŨ sang cấu trúc MỚI
termsSchema.statics.migrateFromJson = async function(jsonData, language = "en") {
try {
console.log('Migrating from JSON to new structure...');
// Xóa document cũ nếu có
await this.deleteOne({ name: "default", language: language });
// Chuyển đổi từ cấu trúc cũ sang mới
const processedData = {
name: "default",
language: language,
version: "2.0.0",
isActive: true,
migratedFromOldStructure: true,
hero: {
title: jsonData.hero?.title || "Go and Grow Camp",
subtitle: jsonData.hero?.subtitle || "Our Terms & Conditions",
backgroundImage: jsonData.hero?.backgroundImage || "/uploads/terms/faqimage.jpg",
sectionClass: "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
backgroundClasses: "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
overlayStyle: {
backgroundColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)"
},
titleClass: "text-white text-[5vw] uk-text-center",
subtitleClass: "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
enableScrollspy: jsonData.hero?.enableScrollspy || true
},
page: {
title: jsonData.termsHeader?.title || "Terms & Conditions Go and Grow Camp e.K.",
divider: jsonData.termsHeader?.divider !== false,
sectionClass: jsonData.termsHeader?.sectionClass || "uk-section-default uk-section-overlap uk-section",
titleClass: jsonData.termsHeader?.titleClass || "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
dividerClass: jsonData.termsHeader?.dividerClass || "uk-divider-small uk-text-left@m uk-text-center"
},
content: {
sectionClass: jsonData.layout?.termsSectionClass || "uk-section-muted uk-section-overlap uk-section",
textClass: jsonData.layout?.textContentClass || "uk-panel uk-margin text-[1vw]",
content: []
}
};
// Chuyển đổi sections cũ sang content mới
const contentItems = [];
// Thêm disclaimer đầu tiên nếu có
if (jsonData.disclaimer?.text) {
contentItems.push({
type: "paragraph",
text: jsonData.disclaimer.text
});
}
if (jsonData.disclaimer?.importantNote) {
contentItems.push({
type: "paragraph",
text: `<strong>${jsonData.disclaimer.importantNote}</strong>`
});
}
if (jsonData.disclaimer?.legalNote) {
contentItems.push({
type: "paragraph",
text: jsonData.disclaimer.legalNote
});
}
// Thêm disclaimer note
if (jsonData.disclaimer?.note) {
contentItems.push({
type: "paragraph",
text: jsonData.disclaimer.note
});
}
// Thêm các sections
if (jsonData.sections && Array.isArray(jsonData.sections)) {
jsonData.sections.forEach(section => {
if (section.title && section.content) {
const contentItem = {
type: "section",
title: section.title,
content: section.content
};
// Thêm subsections nếu có
if (section.subsections && section.subsections.length > 0) {
contentItem.subsections = section.subsections.map(sub => ({
type: "note",
text: sub.content || sub
}));
}
// Thêm cancellation fees nếu có
if (section.fees) {
contentItem.subsections = contentItem.subsections || [];
// Individual fees
if (section.fees.individual && section.fees.individual.length > 0) {
contentItem.subsections.push({
type: "cancellation_table",
title: "Standard Cancellation Fees",
items: section.fees.individual.map(fee => `${fee.period} ${fee.fee}`)
});
}
// School group fees
if (section.fees.schoolGroups && section.fees.schoolGroups.fees) {
contentItem.subsections.push({
type: "cancellation_section",
title: "Cancellation policy for school groups:",
items: [
section.fees.schoolGroups.freeCorrection,
...section.fees.schoolGroups.fees.map(fee => `${fee.period}: ${fee.fee}`)
]
});
}
// Fee note
if (section.fees.note) {
contentItem.subsections.push({
type: "note",
text: section.fees.note
});
}
}
contentItems.push(contentItem);
}
});
}
// Thêm footer note nếu có
if (jsonData.footerNote?.text) {
contentItems.push({
type: "paragraph",
text: jsonData.footerNote.text
});
}
// Gán content items đã chuyển đổi
processedData.content.content = contentItems;
// Tạo document mới
const newTerms = await this.create(processedData);
console.log(`Terms data migrated to new structure for language: ${language}`);
console.log(`Total content items: ${contentItems.length}`);
return newTerms;
} catch (error) {
console.error("Error migrating terms data to new structure:", error);
throw error;
}
};
// Migration method từ cấu trúc MỚI sang cấu trúc MỚI (dành cho JSON mới)
termsSchema.statics.migrateFromNewJson = async function(jsonData, language = "en") {
try {
console.log('Migrating from new JSON structure...');
// Xóa document cũ nếu có
await this.deleteOne({ name: "default", language: language });
// Tạo document mới với cấu trúc mới
const newTerms = await this.create({
name: "default",
language: language,
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false,
hero: {
title: jsonData.hero?.title || "Go and Grow Camp",
subtitle: jsonData.hero?.subtitle || "Our Terms & Conditions",
backgroundImage: jsonData.hero?.backgroundImage || "/uploads/terms/faqimage.jpg",
sectionClass: jsonData.hero?.sectionClass || "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
backgroundClasses: jsonData.hero?.backgroundClasses || "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
overlayStyle: jsonData.hero?.overlayStyle || { backgroundColor: "rgba(0, 0, 0, 0)" },
titleClass: jsonData.hero?.titleClass || "text-white text-[5vw] uk-text-center",
subtitleClass: jsonData.hero?.subtitleClass || "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
enableScrollspy: jsonData.hero?.enableScrollspy !== undefined ? jsonData.hero.enableScrollspy : true
},
page: {
title: jsonData.page?.title || "Terms & Conditions Go and Grow Camp e.K.",
divider: jsonData.page?.divider !== undefined ? jsonData.page.divider : true,
sectionClass: jsonData.page?.sectionClass || "uk-section-default uk-section-overlap uk-section",
titleClass: jsonData.page?.titleClass || "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
dividerClass: jsonData.page?.dividerClass || "uk-divider-small uk-text-left@m uk-text-center"
},
content: {
sectionClass: jsonData.content?.sectionClass || "uk-section-muted uk-section-overlap uk-section",
textClass: jsonData.content?.textClass || "uk-panel uk-margin text-[1vw]",
content: jsonData.content?.content || []
}
});
console.log(`Terms data created with new structure for language: ${language}`);
console.log(`Hero title: ${newTerms.hero.title}`);
console.log(`Page title: ${newTerms.page.title}`);
console.log(`Content items: ${newTerms.content.content.length}`);
return newTerms;
} catch (error) {
console.error("Error creating terms data from new structure:", error);
throw error;
}
};
const Terms = mongoose.model("Terms", termsSchema);
module.exports = Terms;

View File

@@ -1,45 +0,0 @@
const mongoose = require("mongoose");
const travelSchema = new mongoose.Schema(
{
page: {
title: {
type: String,
default: "Travel Information",
},
description: {
type: String,
default: "",
},
year: {
type: String,
default: "",
},
metadata: {
title: String,
description: String,
},
},
hero: {
title: {
type: String,
default: "Travel Information",
},
backgroundImage: {
type: String,
default: "",
},
},
content: {
type: mongoose.Schema.Types.Mixed,
default: { blocks: [] },
},
enableScrollspy: {
type: Boolean,
default: false,
},
},
{ timestamps: true }
);
module.exports = mongoose.model("Travel", travelSchema);

View File

@@ -1,234 +0,0 @@
// models/visa.js
const mongoose = require("mongoose");
// ==================== SCHEMAS ====================
// VisaItem Schema
const VisaItemSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
description: { type: String, default: "" },
},
{ _id: false },
);
// VisaTypeCategory Schema
const VisaTypeCategorySchema = new mongoose.Schema(
{
category: { type: String, default: "" },
items: [VisaItemSchema],
},
{ _id: false },
);
// VisaProcessStep Schema
const VisaProcessStepSchema = new mongoose.Schema(
{
number: { type: String, default: "" },
title: { type: String, default: "" },
description: { type: String, default: "" },
},
{ _id: false },
);
// VisaProcess Schema
const VisaProcessSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: [VisaProcessStepSchema],
},
{ _id: false },
);
// VisaCategory Schema
const VisaCategorySchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: {
type: [[String]],
default: [],
},
},
{ _id: false },
);
// VisaService Schema
const VisaServiceSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: [VisaProcessStepSchema],
},
{ _id: false },
);
// RelatedCountry Schema
const RelatedCountrySchema = new mongoose.Schema(
{
id: { type: Number, default: 0 },
name: { type: String, default: "" },
icon: { type: String, default: "" },
},
{ _id: false },
);
// Phone Schema
const PhoneSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
value: { type: String, default: "" },
link: { type: String, default: "" },
},
{ _id: false },
);
// Email Schema
const EmailSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
value: { type: String, default: "" },
link: { type: String, default: "" },
},
{ _id: false },
);
// Location Schema
const LocationSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
address: { type: String, default: "" },
},
{ _id: false },
);
// ContactInfo Schema
const ContactInfoSchema = new mongoose.Schema(
{
img: { type: String, default: "" },
sectionTitle: { type: String, default: "" },
helpText: { type: String, default: "" },
phone: {
type: PhoneSchema,
default: () => ({}),
},
email: {
type: EmailSchema,
default: () => ({}),
},
location: {
type: LocationSchema,
default: () => ({}),
},
},
{ _id: false },
);
// ActiveCountry Schema
const ActiveCountrySchema = new mongoose.Schema(
{
id: { type: Number, default: 0 },
name: { type: String, default: "" },
title: { type: String, default: "" },
mainImage: { type: String, default: "" },
description: { type: String, default: "" },
additionalInfo: { type: String, default: "" },
tagline: { type: String, default: "" },
visaTypes: [VisaTypeCategorySchema],
visaProcess: {
type: VisaProcessSchema,
default: null,
},
gallery: {
type: [String],
default: [],
},
visaCategories: {
type: VisaCategorySchema,
default: null,
},
visaService: {
type: VisaServiceSchema,
default: null,
},
},
{ _id: false },
);
// DetailedView Schema
const DetailedViewSchema = new mongoose.Schema(
{
activeCountry: {
type: ActiveCountrySchema,
default: null,
},
relatedCountries: {
type: [RelatedCountrySchema],
default: [],
},
contactInfo: {
type: ContactInfoSchema,
default: null,
},
},
{ _id: false },
);
// ==================== MAIN VISA COUNTRY SCHEMA ====================
// Main VisaCountry Schema (Individual country object)
const VisaCountrySchema = new mongoose.Schema(
{
// Không dùng `index: true` ở đây vì đã tạo index riêng cho hero.summaryList.* bên dưới
id: { type: Number, required: true },
name: { type: String, required: true },
slug: { type: String, required: true },
icon: { type: String, default: "" },
services: {
type: [String],
default: [],
},
detailedView: {
type: DetailedViewSchema,
default: null,
},
},
{ _id: false },
);
// ==================== HERO SCHEMA ====================
const HeroSchema = new mongoose.Schema(
{
title: { type: String, default: "Visa" },
summaryList: {
type: [VisaCountrySchema],
default: [],
},
},
{ _id: false },
);
// ==================== MAIN VISA SCHEMA ====================
const visaDataSchema = new mongoose.Schema(
{
hero: {
type: HeroSchema,
default: () => ({ title: "Visa", summaryList: [] }),
},
},
{
timestamps: true,
},
);
// ==================== INDEXES ====================
visaDataSchema.index({ "hero.summaryList.slug": 1 });
visaDataSchema.index({ "hero.summaryList.id": 1 });
visaDataSchema.index({ "hero.summaryList.name": 1 });
// ==================== MODEL ====================
module.exports = mongoose.models.Visa || mongoose.model("Visa", visaDataSchema);

Some files were not shown because too many files have changed in this diff Show More