nuxt-layer-core 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/app/app.config.ts +5 -0
  3. package/app/app.vue +11 -0
  4. package/app/assets/css/main.css +113 -0
  5. package/app/components/.gitkeep +0 -0
  6. package/app/components/ui/sonner/Sonner.vue +62 -0
  7. package/app/components/ui/sonner/index.ts +1 -0
  8. package/app/composables/.gitkeep +0 -0
  9. package/app/layers/solution/app/assets/proxmox-css/main.css +89 -0
  10. package/app/layers/solution/app/assets/proxmox-css/proxmox-utils.css +68 -0
  11. package/app/layers/solution/app/components/ClusterDiagram.vue +72 -0
  12. package/app/layers/solution/app/components/CoursesSection.vue +245 -0
  13. package/app/layers/solution/app/components/CtaSection.vue +347 -0
  14. package/app/layers/solution/app/components/DynamicPageSections.vue +103 -0
  15. package/app/layers/solution/app/components/FeaturesSection.vue +67 -0
  16. package/app/layers/solution/app/components/HeroSection.vue +144 -0
  17. package/app/layers/solution/app/components/PricingPlansSection.vue +396 -0
  18. package/app/layers/solution/app/components/RegistrationForm.vue +283 -0
  19. package/app/layers/solution/app/components/RegistrationSection.vue +24 -0
  20. package/app/layers/solution/app/components/RegistrationSidebar.vue +70 -0
  21. package/app/layers/solution/app/components/TestimonialsSection.vue +58 -0
  22. package/app/layers/solution/app/components/TheFooter.vue +61 -0
  23. package/app/layers/solution/app/components/TheHeader.vue +193 -0
  24. package/app/layers/solution/app/components/proxmox/AppIcon.vue +45 -0
  25. package/app/layers/solution/app/components/proxmox/ComparisonSection.vue +54 -0
  26. package/app/layers/solution/app/components/proxmox/FaqSection.vue +58 -0
  27. package/app/layers/solution/app/components/proxmox/HciOverviewSection.vue +37 -0
  28. package/app/layers/solution/app/components/proxmox/HeroSection.vue +74 -0
  29. package/app/layers/solution/app/components/proxmox/TestimonialsSection.vue +44 -0
  30. package/app/layers/solution/app/components/proxmox/TrainingBenefitsSection.vue +37 -0
  31. package/app/layers/solution/app/components/proxmox/VeFeaturesSection.vue +55 -0
  32. package/app/layers/solution/app/composables/useActiveSection.ts +47 -0
  33. package/app/layers/solution/app/composables/useAuthApi.ts +244 -0
  34. package/app/layers/solution/app/composables/useAuthState.ts +16 -0
  35. package/app/layers/solution/app/composables/useConsultationRequest.ts +8 -0
  36. package/app/layers/solution/app/composables/useCookie.ts +79 -0
  37. package/app/layers/solution/app/composables/useCookieHelper.ts +81 -0
  38. package/app/layers/solution/app/composables/useCrmApi.ts +32 -0
  39. package/app/layers/solution/app/composables/useDynamicCollection.ts +23 -0
  40. package/app/layers/solution/app/composables/usePlanNormalizer.ts +45 -0
  41. package/app/layers/solution/app/composables/useScrollAnimate.ts +40 -0
  42. package/app/layers/solution/app/composables/useScrollToSection.ts +13 -0
  43. package/app/layers/solution/app/composables/useSolutionApi.ts +73 -0
  44. package/app/layers/solution/app/composables/useToast.ts +14 -0
  45. package/app/layers/solution/app/data/pricingPlans.ts +150 -0
  46. package/app/layers/solution/app/data/proxmox-content.ts +158 -0
  47. package/app/layers/solution/app/images/Proxmox HCI.png +0 -0
  48. package/app/layers/solution/app/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
  49. package/app/layers/solution/app/layouts/default.vue +5 -0
  50. package/app/layers/solution/app/nuxt.config.ts +15 -0
  51. package/app/layers/solution/app/pages/[slug]/[slug2]/[slug3]/index.vue +22 -0
  52. package/app/layers/solution/app/pages/[slug]/[slug2]/index.vue +23 -0
  53. package/app/layers/solution/app/pages/[slug]/index.vue +22 -0
  54. package/app/layers/solution/app/pages/cap-nhat-thong-tin/index.vue.disabled +302 -0
  55. package/app/layers/solution/app/pages/dang-ky/index.vue.disabled +627 -0
  56. package/app/layers/solution/app/pages/dang-ky-tu-van/khoa-hoc-proxmox.vue.disabled +39 -0
  57. package/app/layers/solution/app/pages/dang-nhap/index.vue.disabled +208 -0
  58. package/app/layers/solution/app/pages/doi-mat-khau/index.vue.disabled +237 -0
  59. package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/index.vue.disabled +37 -0
  60. package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue.disabled +45 -0
  61. package/app/layers/solution/app/pages/giai-phap-proxmox/dang-ky-khoa-hoc/[slug].vue.disabled +56 -0
  62. package/app/layers/solution/app/pages/quen-mat-khau/index.vue.disabled +136 -0
  63. package/app/layers/solution/app/pages/xac-thuc/index.vue.disabled +320 -0
  64. package/app/layers/solution/app/plugins/reveal.ts +56 -0
  65. package/app/layers/solution/app/public/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
  66. package/app/layers/solution/app/public/images/logo-lvs-1300x330.png +0 -0
  67. package/app/layouts/auth.vue +71 -0
  68. package/app/lib/utils.ts +7 -0
  69. package/app/middleware/.gitkeep +0 -0
  70. package/app/modules/layer-route-priority.ts +60 -0
  71. package/app/pages/index.vue +10 -0
  72. package/app/pages/san-pham-proxmox.vue +8 -0
  73. package/app/plugins/.gitkeep +0 -0
  74. package/app/plugins/sdk.ts +29 -0
  75. package/app/stores/.gitkeep +0 -0
  76. package/app/types/sdk.d.ts +15 -0
  77. package/components.json +24 -0
  78. package/nuxt.config.ts +105 -0
  79. package/package.json +52 -0
  80. package/public/.gitkeep +0 -0
  81. package/server/api/.gitkeep +0 -0
  82. package/server/utils/.gitkeep +0 -0
  83. package/tailwind.config.js +96 -0
  84. package/tsconfig.base.json +18 -0
  85. package/tsconfig.json +18 -0
@@ -0,0 +1,347 @@
1
+ <template>
2
+ <section id="lien-he" class="py-24 bg-white border-t border-slate-200">
3
+ <div class="max-w-7xl mx-auto px-6 lg:px-8">
4
+ <div class="cta-card bg-[#F7F9FB] rounded-3xl flex flex-col md:flex-row shadow-2xl">
5
+ <div
6
+ class="md:w-5/12 bg-gradient-to-br from-[#004AC6] to-[#1D4ED8] p-10 sm:p-12 text-white space-y-8 overflow-hidden rounded-l-3xl">
7
+ <p class="text-xs font-bold uppercase tracking-widest text-blue-100">Bắt đầu</p>
8
+ <h3 class="font-['Montserrat'] font-extrabold text-2xl sm:text-3xl leading-tight">
9
+ Đặt lịch tư vấn khóa học miễn phí, 30 phút
10
+ </h3>
11
+ <p class="text-blue-100/90 leading-relaxed">
12
+ Gửi thông tin hạ tầng hiện tại và mục tiêu học tập, đội ngũ kỹ thuật của Long Vân
13
+ sẽ phản hồi và đề xuất khóa học phù hợp trong vòng 24 giờ.
14
+ </p>
15
+ <div class="flex items-center gap-4">
16
+ <div class="w-10 h-10 rounded-[10px] bg-white/10 flex items-center justify-center">
17
+ <Icon name="lucide:mail" class="text-[18px]" />
18
+ </div>
19
+ <span class="text-sm">tuvan@longvan.net</span>
20
+ </div>
21
+ <div class="flex items-center gap-4">
22
+ <div class="w-10 h-10 rounded-[10px] bg-white/10 flex items-center justify-center">
23
+ <Icon name="lucide:phone" class="text-[18px]" />
24
+ </div>
25
+ <span class="text-sm">1800 6070</span>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="md:w-7/12 p-10 sm:p-12 bg-white rounded-r-3xl">
30
+ <form class="space-y-5" @submit.prevent="handleSubmit" novalidate>
31
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
32
+ <div class="space-y-1.5">
33
+ <label for="cta-name" class="text-xs font-bold uppercase text-slate-500">
34
+ Họ và tên <span class="text-red-500">*</span>
35
+ </label>
36
+ <input id="cta-name" v-model.trim="form.name" type="text" @blur="touch('name')"
37
+ class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
38
+ :class="errors.name && touched.name ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
39
+ placeholder="Nguyễn Văn A" />
40
+ <p v-if="errors.name && touched.name" class="text-red-500 text-xs mt-1">{{ errors.name }}</p>
41
+ </div>
42
+ <div class="space-y-1.5">
43
+ <label for="cta-phone" class="text-xs font-bold uppercase text-slate-500">
44
+ Số điện thoại <span class="text-red-500">*</span>
45
+ </label>
46
+ <input id="cta-phone" v-model.trim="form.phone" type="tel" @blur="touch('phone')"
47
+ class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
48
+ :class="errors.phone && touched.phone ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
49
+ placeholder="0912 345 678" />
50
+ <p v-if="errors.phone && touched.phone" class="text-red-500 text-xs mt-1">{{ errors.phone }}</p>
51
+ </div>
52
+ </div>
53
+ <div class="space-y-1.5">
54
+ <label for="cta-email" class="text-xs font-bold uppercase text-slate-500">Email</label>
55
+ <input id="cta-email" v-model.trim="form.email" type="email" @blur="touch('email')"
56
+ class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
57
+ :class="errors.email && touched.email ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
58
+ placeholder="ban@congty.com" />
59
+ <p v-if="errors.email && touched.email" class="text-red-500 text-xs mt-1">{{ errors.email }}</p>
60
+ </div>
61
+
62
+ <!-- Searchable dropdown -->
63
+ <div class="space-y-1.5" ref="dropdownRef">
64
+ <label class="text-xs font-bold uppercase text-slate-500">
65
+ Khóa học quan tâm <span class="text-red-500">*</span>
66
+ </label>
67
+ <div class="relative">
68
+ <div @click="toggleDropdown"
69
+ class="w-full px-4 py-3 border rounded-[10px] text-sm text-slate-900 cursor-pointer flex items-center justify-between transition-all"
70
+ :class="[
71
+ dropdownOpen ? 'border-[#004AC6] ring-2 ring-blue-200' : 'border-slate-300',
72
+ errors.course && touched.course ? 'border-red-400' : ''
73
+ ]">
74
+ <span :class="selectedCourse ? 'text-slate-900' : 'text-slate-400'">
75
+ {{ selectedCourse ? selectedCourse.name : 'Chọn khóa học...' }}
76
+ </span>
77
+ <Icon name="lucide:chevron-down" class="text-[16px] text-slate-400 transition-transform"
78
+ :class="dropdownOpen ? 'rotate-180' : ''" />
79
+ </div>
80
+
81
+ <Transition enter-active-class="transition ease-out duration-150"
82
+ enter-from-class="opacity-0 -translate-y-1" enter-to-class="opacity-100 translate-y-0"
83
+ leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100 translate-y-0"
84
+ leave-to-class="opacity-0 -translate-y-1">
85
+ <div v-if="dropdownOpen"
86
+ class="absolute z-50 mt-1 w-full bg-white border border-slate-200 rounded-[10px] shadow-lg overflow-hidden">
87
+ <div class="p-2 border-b border-slate-100">
88
+ <div class="relative">
89
+ <Icon name="lucide:search"
90
+ class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-[14px]" />
91
+ <input ref="searchInput" v-model="searchQuery" type="text" placeholder="Tìm khóa học..."
92
+ class="w-full pl-9 pr-3 py-2 text-sm border border-slate-200 rounded-md outline-none focus:border-[#004AC6] focus:ring-1 focus:ring-blue-200"
93
+ @click.stop />
94
+ </div>
95
+ </div>
96
+ <div class="max-h-56 overflow-y-auto">
97
+ <div v-for="plan in filteredCourses" :key="plan.id" @click="selectCourse(plan)"
98
+ class="px-4 py-3 text-sm cursor-pointer flex items-center gap-3 transition-colors"
99
+ :class="selectedCourse?.id === plan.id ? 'bg-blue-50 text-[#004AC6]' : 'hover:bg-slate-50 text-slate-700'">
100
+ <Icon :name="plan.icon" class="text-[16px] shrink-0"
101
+ :class="selectedCourse?.id === plan.id ? 'text-[#004AC6]' : 'text-slate-400'" />
102
+ <div class="flex-1 min-w-0">
103
+ <p class="font-medium truncate">{{ plan.name }}</p>
104
+ <p class="text-xs text-slate-400 truncate">{{ formatLevel(plan.level) }} · {{ plan.duration }}
105
+ </p>
106
+ </div>
107
+ <Icon v-if="selectedCourse?.id === plan.id" name="lucide:check"
108
+ class="text-[16px] text-[#004AC6] shrink-0" />
109
+ </div>
110
+ <div v-if="filteredCourses.length === 0" class="px-4 py-6 text-center text-sm text-slate-400">
111
+ Không tìm thấy khóa học
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </Transition>
116
+ </div>
117
+ <p v-if="errors.course && touched.course" class="text-red-500 text-xs mt-1">{{ errors.course }}</p>
118
+ </div>
119
+
120
+ <button type="submit" :disabled="submitting"
121
+ class="w-full bg-[#004AC6] text-white py-3.5 rounded-[10px] font-bold flex items-center justify-center gap-2 hover:bg-[#1D4ED8] transition-all shadow-lg active:scale-95 disabled:opacity-60 disabled:cursor-not-allowed">
122
+ <template v-if="submitting">
123
+ <span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-[10px] animate-spin"></span>
124
+ Đang gửi...
125
+ </template>
126
+ <template v-else>
127
+ Gửi yêu cầu tư vấn
128
+ <Icon name="lucide:send" class="text-[18px]" />
129
+ </template>
130
+ </button>
131
+
132
+ <Transition enter-active-class="transition ease-out duration-300" enter-from-class="opacity-0 translate-y-2"
133
+ enter-to-class="opacity-100 translate-y-0">
134
+ <div v-if="submitSuccess"
135
+ class="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-[10px] text-green-700 text-sm">
136
+ <Icon name="lucide:check-circle" class="text-[18px] text-green-500 shrink-0" />
137
+ Gửi yêu cầu tư vấn thành công! Chúng tôi sẽ liên hệ bạn trong 24 giờ.
138
+ </div>
139
+ </Transition>
140
+ </form>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </section>
145
+ </template>
146
+
147
+ <script setup>
148
+ useScrollAnimate('.cta-card', 'scale-up')
149
+
150
+ const props = defineProps({
151
+ selectedPlanId: { type: String, default: null },
152
+ // Do DynamicPageSections gom từ CoursesSection / PricingPlansSection trên cùng trang (nếu có)
153
+ courses: { type: Array, default: () => [] },
154
+ pricingPlans: { type: Object, default: null },
155
+ })
156
+
157
+ const { addOpportunity } = useCrmApi()
158
+
159
+ const samplePlans = [
160
+ { id: 'sample-1', name: 'Deployment & Management', icon: 'lucide:graduation-cap', level: 'Nền tảng', duration: '14 giờ' },
161
+ { id: 'sample-2', name: 'Clustering & Shared Storage', icon: 'lucide:network', level: 'Chuyên sâu', duration: '14 giờ' },
162
+ { id: 'sample-3', name: 'Proxmox VE Bundle', icon: 'lucide:package', level: 'Trọn gói', duration: '28 giờ' },
163
+ ]
164
+
165
+ function formatLevel(level) {
166
+ const labels = {
167
+ basic: 'Cơ bản',
168
+ advanced: 'Nâng cao',
169
+ expert: 'Chuyên gia',
170
+ }
171
+
172
+ return labels[level || ''] || level || ''
173
+ }
174
+
175
+ // CtaSection chỉ để tự gửi tư vấn -> chỉ show option KHÔNG có url riêng (option có url đã có nút "Đăng ký ngay" đi thẳng)
176
+ const pricingCategoryMeta = {
177
+ hci: { level: 'Gói thuê hạ tầng', icon: 'lucide:server-cog' },
178
+ workshop: { level: 'Workshop', icon: 'lucide:zap' },
179
+ training: { level: 'Khóa đào tạo', icon: 'lucide:graduation-cap' },
180
+ }
181
+
182
+ // Luôn gộp đủ data từ CoursesSection + PricingPlansSection (nếu trang có các component đó)
183
+ // props.courses / props.pricingPlans[category] đã được DynamicPageSections chuẩn hoá chung shape (normalizePlanItem)
184
+ const formattedPlans = computed(() => {
185
+ const courseOptions = props.courses
186
+ .filter((item) => !item.url)
187
+ .map((item) => ({
188
+ id: item.id,
189
+ name: item.name,
190
+ level: item.level,
191
+ duration: item.duration,
192
+ icon: item.icon,
193
+ }))
194
+
195
+ const pricingOptions = props.pricingPlans
196
+ ? Object.entries(pricingCategoryMeta).flatMap(([category, meta]) =>
197
+ (props.pricingPlans[category] || [])
198
+ .filter((item) => !item.url)
199
+ .map((item) => ({
200
+ id: item.id,
201
+ name: item.name,
202
+ level: meta.level,
203
+ duration: item.duration,
204
+ icon: meta.icon,
205
+ }))
206
+ )
207
+ : []
208
+
209
+ const combined = [...courseOptions, ...pricingOptions]
210
+ return combined.length > 0 ? combined : samplePlans
211
+ })
212
+
213
+ const dropdownPlans = computed(() => formattedPlans.value)
214
+
215
+ // Form state
216
+ const form = reactive({ name: '', phone: '', email: '' })
217
+ const errors = reactive({ name: '', phone: '', email: '', course: '' })
218
+ const touched = reactive({ name: false, phone: false, email: false, course: false })
219
+ const submitting = ref(false)
220
+ const submitSuccess = ref(false)
221
+
222
+ // Dropdown state
223
+ const dropdownOpen = ref(false)
224
+ const searchQuery = ref('')
225
+ const selectedCourse = ref(null)
226
+ const dropdownRef = ref(null)
227
+ const searchInput = ref(null)
228
+
229
+ // Auto-select when selectedPlanId changes
230
+ watch(() => props.selectedPlanId, (id) => {
231
+ if (!id) return
232
+ const found = formattedPlans.value.find(p => p.id === id)
233
+ if (found) {
234
+ selectedCourse.value = found
235
+ touched.course = true
236
+ errors.course = ''
237
+ }
238
+ }, { immediate: true })
239
+
240
+ // Search filter
241
+ const filteredCourses = computed(() => {
242
+ if (!searchQuery.value) return dropdownPlans.value
243
+ const q = searchQuery.value.toLowerCase()
244
+ return dropdownPlans.value.filter(p =>
245
+ p.name.toLowerCase().includes(q) || p.level.toLowerCase().includes(q)
246
+ )
247
+ })
248
+
249
+ function toggleDropdown() {
250
+ dropdownOpen.value = !dropdownOpen.value
251
+ if (dropdownOpen.value) {
252
+ nextTick(() => searchInput.value?.focus())
253
+ }
254
+ }
255
+
256
+ function selectCourse(plan) {
257
+ selectedCourse.value = plan
258
+ errors.course = ''
259
+ dropdownOpen.value = false
260
+ searchQuery.value = ''
261
+ }
262
+
263
+ // Close dropdown on outside click
264
+ function onClickOutside(e) {
265
+ if (dropdownRef.value && !dropdownRef.value.contains(e.target)) {
266
+ dropdownOpen.value = false
267
+ searchQuery.value = ''
268
+ }
269
+ }
270
+
271
+ onMounted(() => document.addEventListener('click', onClickOutside))
272
+ onUnmounted(() => document.removeEventListener('click', onClickOutside))
273
+
274
+ // Validation
275
+ function touch(field) {
276
+ touched[field] = true
277
+ validateField(field)
278
+ }
279
+
280
+ function validateField(field) {
281
+ if (field === 'name') {
282
+ if (!form.name) errors.name = 'Vui lòng nhập họ tên'
283
+ else if (form.name.length < 2) errors.name = 'Họ tên phải có ít nhất 2 ký tự'
284
+ else errors.name = ''
285
+ }
286
+ if (field === 'phone') {
287
+ if (!form.phone) errors.phone = 'Vui lòng nhập số điện thoại'
288
+ else if (!/^(0|\+84)\d{9,10}$/.test(form.phone.replace(/\s/g, ''))) errors.phone = 'Số điện thoại không hợp lệ'
289
+ else errors.phone = ''
290
+ }
291
+ if (field === 'email') {
292
+ if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Email không hợp lệ'
293
+ else errors.email = ''
294
+ }
295
+ if (field === 'course') {
296
+ if (!selectedCourse.value) errors.course = 'Vui lòng chọn khóa học'
297
+ else errors.course = ''
298
+ }
299
+ }
300
+
301
+ function validateAll() {
302
+ ['name', 'phone', 'email', 'course'].forEach(f => { touched[f] = true; validateField(f) })
303
+ return !errors.name && !errors.phone && !errors.email && !errors.course
304
+ }
305
+
306
+ // Submit
307
+ async function handleSubmit() {
308
+ if (!validateAll()) return
309
+ submitting.value = true
310
+ submitSuccess.value = false
311
+
312
+ try {
313
+ const hostname = window.location.origin
314
+
315
+ const description = [
316
+ `<strong>Khóa học:</strong> ${selectedCourse.value?.name || ''}`,
317
+ `<strong>Trình độ:</strong> ${formatLevel(selectedCourse.value?.level)}`,
318
+ `<strong>Thời lượng:</strong> ${selectedCourse.value?.duration || ''}`,
319
+ `<strong>Họ và tên:</strong> ${form.name}`,
320
+ `<strong>Số điện thoại:</strong> ${form.phone}`,
321
+ `<strong>Email:</strong> ${form.email || 'Không cung cấp'}`,
322
+ ].join('<br>')
323
+
324
+ await addOpportunity({
325
+ name: `Yêu cầu tư vấn khóa học ${selectedCourse.value.name}`,
326
+ description: description,
327
+ referName: form.name,
328
+ referPhone: form.phone,
329
+ referEmail: form.email,
330
+ targetUrl: `${hostname}${window.location.pathname}`,
331
+ extSource: 'WEBSITE',
332
+ })
333
+ submitSuccess.value = true
334
+ form.name = ''
335
+ form.phone = ''
336
+ form.email = ''
337
+ selectedCourse.value = null
338
+ Object.keys(touched).forEach(k => touched[k] = false)
339
+ } catch (err) {
340
+ console.error('Error submitting consultation:', err)
341
+ errors.name = 'Gửi yêu cầu thất bại, vui lòng thử lại.'
342
+ touched.name = true
343
+ } finally {
344
+ submitting.value = false
345
+ }
346
+ }
347
+ </script>
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ fieldResumes?: Array<any> | null
4
+ targetCTA?: string
5
+ slug?: string
6
+ }>()
7
+
8
+ const parseValue = (value: unknown) => {
9
+ if (typeof value !== 'string') return value
10
+
11
+ const trimmed = value.trim()
12
+ if (!trimmed) return null
13
+
14
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
15
+ try {
16
+ return JSON.parse(trimmed)
17
+ } catch {
18
+ return value
19
+ }
20
+ }
21
+
22
+ return value
23
+ }
24
+
25
+ const toCamelCase = (key: string) => key.replace(/_([a-z0-9])/gi, (_, c) => c.toUpperCase())
26
+
27
+ // Gom các item_id_0M_groups_0N (M: 1-3) của 1 group cụ thể thành mảng item hợp lệ
28
+ const pickGroupItems = (sectionProps: Record<string, any> | undefined, groupSuffix: string) => {
29
+ if (!sectionProps) return []
30
+ return [1, 2, 3]
31
+ .map((n) => sectionProps[`itemId0${n}Groups${groupSuffix}`])
32
+ .filter((item) => item && typeof item === 'object')
33
+ }
34
+
35
+ const sections = computed(() => {
36
+ const list = (props.fieldResumes || [])
37
+ .filter((group: any) => group?.type === 'FIELD_GROUP' && Array.isArray(group?.children))
38
+ .map((group: any) => {
39
+ const componentProps: Record<string, any> = {}
40
+ let componentName = ''
41
+
42
+ for (const field of group.children) {
43
+ const key = field?.fieldName?.split('@')?.pop()
44
+ if (!key) continue
45
+
46
+ if (key === 'component_name') {
47
+ componentName = field?.value || ''
48
+ continue
49
+ }
50
+
51
+ componentProps[toCamelCase(key)] = parseValue(field?.value)
52
+ }
53
+
54
+ return {
55
+ component: componentName,
56
+ props: {
57
+ targetCTA: props.targetCTA,
58
+ slug: props.slug,
59
+ ...componentProps,
60
+ },
61
+ }
62
+ })
63
+ .filter((section: { component: string }) => section.component)
64
+
65
+ // CtaSection không có data riêng trong CMS, lấy nhờ từ CoursesSection/PricingPlansSection nếu có trên trang
66
+ const coursesSection = list.find((s) => s.component === 'CoursesSection')
67
+ const pricingSection = list.find((s) => s.component === 'PricingPlansSection')
68
+
69
+ const courses = coursesSection
70
+ ? ['01', '02', '03']
71
+ .flatMap((suffix) => pickGroupItems(coursesSection.props, suffix))
72
+ .map(normalizePlanItem)
73
+ .filter(Boolean)
74
+ : []
75
+
76
+ const pricingPlans = pricingSection
77
+ ? {
78
+ hci: pickGroupItems(pricingSection.props, '01').map(normalizePlanItem).filter(Boolean),
79
+ workshop: pickGroupItems(pricingSection.props, '02').map(normalizePlanItem).filter(Boolean),
80
+ training: pickGroupItems(pricingSection.props, '03').map(normalizePlanItem).filter(Boolean),
81
+ }
82
+ : null
83
+
84
+ return list.map((section) => {
85
+ if (section.component !== 'CtaSection') return section
86
+
87
+ return {
88
+ ...section,
89
+ props: {
90
+ ...section.props,
91
+ courses,
92
+ pricingPlans,
93
+ },
94
+ }
95
+ })
96
+ })
97
+ </script>
98
+
99
+ <template>
100
+ <template v-for="(section, index) in sections" :key="index">
101
+ <component :is="section.component" v-bind="section.props" />
102
+ </template>
103
+ </template>
@@ -0,0 +1,67 @@
1
+ <template>
2
+ <section id="giai-phap" class="py-24 bg-white">
3
+ <div class="max-w-7xl mx-auto px-6 lg:px-8">
4
+ <div class="text-center mb-16">
5
+ <h2 class="text-[#004AC6] font-bold uppercase tracking-widest text-sm mb-2">Lợi ích khóa học</h2>
6
+ <h3 class="font-['Montserrat'] font-extrabold text-2xl sm:text-3xl text-slate-900 mb-4">
7
+ Vì sao nên tham gia đào tạo Proxmox VE?
8
+ </h3>
9
+ <p class="text-slate-500 max-w-2xl mx-auto">
10
+ Đầu tư một lần vào đào tạo bài bản không chỉ giúp bạn tối ưu năng lực vận hành, cắt giảm chi phí hạ tầng mà còn tạo bước đệm bứt phá trên con đường sự nghiệp.
11
+ </p>
12
+ </div>
13
+
14
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
15
+ <div
16
+ v-for="(item, i) in features"
17
+ :key="item.title"
18
+ :data-delay="i * 100"
19
+ class="feature-card bg-white p-8 rounded-xl border border-slate-200 hover:shadow-xl hover:border-blue-200 transition-all"
20
+ >
21
+ <div class="w-11 h-11 rounded-[10px] bg-blue-50 flex items-center justify-center mb-6 text-[#004AC6]">
22
+ <Icon :name="item.icon" />
23
+ </div>
24
+ <h4 class="font-bold text-lg text-slate-900 mb-3">{{ item.title }}</h4>
25
+ <p class="text-sm text-slate-500 leading-relaxed">{{ item.desc }}</p>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </section>
30
+ </template>
31
+
32
+ <script setup>
33
+ useScrollAnimate('.feature-card', 'scale-up')
34
+
35
+ const features = [
36
+ {
37
+ icon: 'lucide:server',
38
+ title: 'Làm chủ toàn diện Proxmox VE',
39
+ desc: 'Nắm vững trọn vẹn các khái niệm cốt lõi, từ triển khai ảo hóa cơ bản đến quản trị các tính năng cao cấp của nền tảng.'
40
+ },
41
+ {
42
+ icon: 'lucide:award',
43
+ title: 'Học từ chuyên gia thực chiến',
44
+ desc: 'Tiếp thu kinh nghiệm vận hành ảo hóa trực tiếp từ đội ngũ giảng viên được chứng nhận, đã xử lý hàng ngàn bài toán hệ thống.'
45
+ },
46
+ {
47
+ icon: 'lucide:monitor-play',
48
+ title: 'Thực hành Lab môi trường thật',
49
+ desc: 'Không chỉ học lý thuyết, bạn sẽ trực tiếp thao tác và giải quyết các tình huống giả lập sát với thực tế doanh nghiệp.'
50
+ },
51
+ {
52
+ icon: 'lucide:activity',
53
+ title: 'Troubleshooting thần tốc',
54
+ desc: 'Trang bị kỹ năng nhận diện "bệnh" hệ thống nhạy bén, giúp bạn xử lý sự cố chính xác và dứt điểm hơn bao giờ hết.'
55
+ },
56
+ {
57
+ icon: 'lucide:trending-up',
58
+ title: 'Thăng tiến & Khẳng định vị thế',
59
+ desc: 'Nâng tầm giá trị bản thân, tạo dựng sự tín nhiệm tuyệt đối với cấp trên, đồng nghiệp và đối tác nhờ năng lực chuyên môn vượt trội.'
60
+ },
61
+ {
62
+ icon: 'lucide:file-badge',
63
+ title: 'Chứng nhận chính quy',
64
+ desc: 'Nhận chứng chỉ hoàn thành khóa học danh giá, minh chứng rõ nét cho năng lực thực sự và sự đầu tư nghiêm túc của bạn.'
65
+ }
66
+ ]
67
+ </script>
@@ -0,0 +1,144 @@
1
+ <template>
2
+ <section id="#" class="relative">
3
+ <!-- Hero -->
4
+ <div class="bg-gradient-to-br from-[#004AC6] to-[#1D4ED8] text-white pt-40 pb-24 overflow-hidden relative">
5
+ <!-- Dot grid pattern -->
6
+ <div class="absolute inset-0 opacity-[0.06]"
7
+ style="background-image: radial-gradient(circle, #fff 1px, transparent 1px); background-size: 24px 24px;"></div>
8
+
9
+ <!-- Cluster nodes decoration -->
10
+ <svg class="absolute inset-0 w-full h-full opacity-[0.08]" xmlns="http://www.w3.org/2000/svg">
11
+ <!-- Connection lines -->
12
+ <line x1="15%" y1="25%" x2="35%" y2="45%" stroke="white" stroke-width="1" />
13
+ <line x1="35%" y1="45%" x2="25%" y2="70%" stroke="white" stroke-width="1" />
14
+ <line x1="35%" y1="45%" x2="55%" y2="35%" stroke="white" stroke-width="1" />
15
+ <line x1="55%" y1="35%" x2="75%" y2="55%" stroke="white" stroke-width="1" />
16
+ <line x1="75%" y1="55%" x2="85%" y2="30%" stroke="white" stroke-width="1" />
17
+ <line x1="75%" y1="55%" x2="65%" y2="75%" stroke="white" stroke-width="1" />
18
+ <!-- Nodes -->
19
+ <circle cx="15%" cy="25%" r="4" fill="white" />
20
+ <circle cx="35%" cy="45%" r="5" fill="white" />
21
+ <circle cx="25%" cy="70%" r="3.5" fill="white" />
22
+ <circle cx="55%" cy="35%" r="4.5" fill="white" />
23
+ <circle cx="75%" cy="55%" r="5" fill="white" />
24
+ <circle cx="85%" cy="30%" r="3.5" fill="white" />
25
+ <circle cx="65%" cy="75%" r="4" fill="white" />
26
+ <!-- Pulse rings -->
27
+ <circle cx="35%" cy="45%" r="12" fill="none" stroke="white" stroke-width="0.5" class="hero-pulse" />
28
+ <circle cx="75%" cy="55%" r="14" fill="none" stroke="white" stroke-width="0.5" class="hero-pulse"
29
+ style="animation-delay: 1.5s" />
30
+ </svg>
31
+
32
+ <div class="max-w-7xl mx-auto px-6 lg:px-8 grid lg:grid-cols-2 gap-14 items-center relative z-10">
33
+ <!-- Left -->
34
+ <div>
35
+ <div
36
+ class="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md px-4 py-2 rounded-[10px] border border-white/20 mb-6">
37
+ <Icon name="lucide:headset" class="text-[16px]" />
38
+ <span class="text-xs font-bold uppercase tracking-widest">
39
+ Tư vấn miễn phí · Đào tạo Proxmox Virtual Environment
40
+ </span>
41
+ </div>
42
+
43
+ <h1 class="font-['Montserrat'] font-extrabold text-2xl sm:text-[2rem] leading-[1.15] mb-6">
44
+ Chưa biết nên học khóa nào?<br />
45
+ Được tư vấn đúng lộ trình trong 15 phút.
46
+ </h1>
47
+
48
+ <p class="text-blue-100/90 text-lg leading-relaxed mb-8 max-w-xl">
49
+ Đội ngũ kỹ sư hạ tầng của Long Vân sẽ đánh giá năng lực hiện tại và
50
+ mục tiêu công việc của bạn, từ đó đề xuất khóa học Proxmox, Ceph hoặc
51
+ DevOps phù hợp nhất — thay vì để bạn tự đoán.
52
+ </p>
53
+
54
+ <div class="flex flex-wrap gap-4">
55
+ <NuxtLink to="#lien-he" @click.prevent="scrollTo('lien-he')"
56
+ class="bg-white text-[#004AC6] px-7 py-3.5 rounded-[10px] font-bold flex items-center gap-2 hover:bg-blue-50 transition-colors shadow-lg active:scale-95">
57
+ Đặt lịch tư vấn miễn phí
58
+ <Icon name="lucide:arrow-right" class="text-[18px]" />
59
+ </NuxtLink>
60
+
61
+ <NuxtLink to="#dao-tao" @click.prevent="scrollTo('dao-tao')"
62
+ class="bg-transparent border border-white/40 text-white px-7 py-3.5 rounded-[10px] font-bold hover:bg-white/10 transition-colors">
63
+ Xem danh sách khóa học
64
+ </NuxtLink>
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Right -->
69
+ <div class="relative flex justify-center">
70
+ <div class="relative z-10 rounded-2xl overflow-hidden">
71
+ <!-- <NuxtImg src="/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png"
72
+ alt="Long Vân Proxmox Infrastructure" class="w-full h-full object-contain p-4" /> -->
73
+ <img src="/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png"
74
+ alt="Long Vân Proxmox Infrastructure" class="w-full h-full object-contain p-4" />
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+
80
+ <!-- Background phía dưới hero -->
81
+ <div class="bg-[#F7F9FB]">
82
+ <!-- Stats bar -->
83
+ <div class="relative -mt-8 z-10 max-w-7xl mx-auto px-6 lg:px-8">
84
+ <div class="hero-stats bg-white rounded-xl shadow-xl border border-slate-100 py-8 px-6">
85
+ <div class="grid grid-cols-3 gap-8 divide-x divide-slate-200">
86
+ <div class="text-center">
87
+ <div class="font-['Montserrat'] font-extrabold text-2xl sm:text-3xl text-[#004AC6]">
88
+ 1.200+
89
+ </div>
90
+ <div class="text-slate-500 text-xs sm:text-sm font-medium mt-1">
91
+ Học viên đã được tư vấn
92
+ </div>
93
+ </div>
94
+
95
+ <div class="text-center">
96
+ <div class="font-['Montserrat'] font-extrabold text-2xl sm:text-3xl text-[#004AC6]">
97
+ 96%
98
+ </div>
99
+ <div class="text-slate-500 text-xs sm:text-sm font-medium mt-1">
100
+ Chọn đúng khóa ngay lần đầu
101
+ </div>
102
+ </div>
103
+
104
+ <div class="text-center">
105
+ <div class="font-['Montserrat'] font-extrabold text-2xl sm:text-3xl text-[#004AC6]">
106
+ 15 phút
107
+ </div>
108
+ <div class="text-slate-500 text-xs sm:text-sm font-medium mt-1">
109
+ Thời gian tư vấn trung bình
110
+ </div>
111
+ </div>
112
+ </div>
113
+ </div>
114
+
115
+ <!-- Khoảng cách trước section tiếp theo -->
116
+ <div class="h-16"></div>
117
+ </div>
118
+ </div>
119
+ </section>
120
+ </template>
121
+
122
+ <script setup>
123
+ const { scrollTo } = useScrollToSection()
124
+ useScrollAnimate('.hero-stats', 'fade-up')
125
+ </script>
126
+
127
+ <style scoped>
128
+ @keyframes pulse-ring {
129
+ 0% {
130
+ transform: scale(1);
131
+ opacity: 0.6;
132
+ }
133
+
134
+ 100% {
135
+ transform: scale(2.5);
136
+ opacity: 0;
137
+ }
138
+ }
139
+
140
+ .hero-pulse {
141
+ animation: pulse-ring 3s ease-out infinite;
142
+ transform-origin: center;
143
+ }
144
+ </style>