nuxt-layer-core 2.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 (78) 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 +246 -0
  13. package/app/layers/solution/app/components/CtaSection.vue +329 -0
  14. package/app/layers/solution/app/components/FeaturesSection.vue +67 -0
  15. package/app/layers/solution/app/components/HeroSection.vue +144 -0
  16. package/app/layers/solution/app/components/PricingPlansSection.vue +434 -0
  17. package/app/layers/solution/app/components/RegistrationForm.vue +256 -0
  18. package/app/layers/solution/app/components/TestimonialsSection.vue +58 -0
  19. package/app/layers/solution/app/components/TheFooter.vue +61 -0
  20. package/app/layers/solution/app/components/TheHeader.vue +189 -0
  21. package/app/layers/solution/app/components/proxmox/AppIcon.vue +45 -0
  22. package/app/layers/solution/app/components/proxmox/ComparisonSection.vue +54 -0
  23. package/app/layers/solution/app/components/proxmox/FaqSection.vue +58 -0
  24. package/app/layers/solution/app/components/proxmox/HciOverviewSection.vue +37 -0
  25. package/app/layers/solution/app/components/proxmox/HeroSection.vue +74 -0
  26. package/app/layers/solution/app/components/proxmox/TestimonialsSection.vue +44 -0
  27. package/app/layers/solution/app/components/proxmox/TrainingBenefitsSection.vue +37 -0
  28. package/app/layers/solution/app/components/proxmox/VeFeaturesSection.vue +55 -0
  29. package/app/layers/solution/app/composables/useActiveSection.ts +47 -0
  30. package/app/layers/solution/app/composables/useAuthApi.ts +244 -0
  31. package/app/layers/solution/app/composables/useAuthState.ts +16 -0
  32. package/app/layers/solution/app/composables/useCookie.ts +79 -0
  33. package/app/layers/solution/app/composables/useCookieHelper.ts +81 -0
  34. package/app/layers/solution/app/composables/useCrmApi.ts +32 -0
  35. package/app/layers/solution/app/composables/useDynamicCollection.ts +23 -0
  36. package/app/layers/solution/app/composables/useScrollAnimate.ts +40 -0
  37. package/app/layers/solution/app/composables/useScrollToSection.ts +13 -0
  38. package/app/layers/solution/app/composables/useSolutionApi.ts +55 -0
  39. package/app/layers/solution/app/composables/useToast.ts +14 -0
  40. package/app/layers/solution/app/data/proxmox-content.ts +158 -0
  41. package/app/layers/solution/app/images/Proxmox HCI.png +0 -0
  42. package/app/layers/solution/app/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
  43. package/app/layers/solution/app/layouts/default.vue +5 -0
  44. package/app/layers/solution/app/nuxt.config.ts +9 -0
  45. package/app/layers/solution/app/pages/cap-nhat-thong-tin/index.vue +302 -0
  46. package/app/layers/solution/app/pages/dang-ky/index.vue +627 -0
  47. package/app/layers/solution/app/pages/dang-nhap/index.vue +208 -0
  48. package/app/layers/solution/app/pages/doi-mat-khau/index.vue +237 -0
  49. package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/index.vue +37 -0
  50. package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue +45 -0
  51. package/app/layers/solution/app/pages/giai-phap-proxmox/dang-ky-khoa-hoc/[slug].vue +131 -0
  52. package/app/layers/solution/app/pages/quen-mat-khau/index.vue +136 -0
  53. package/app/layers/solution/app/pages/xac-thuc/index.vue +320 -0
  54. package/app/layers/solution/app/plugins/reveal.ts +56 -0
  55. package/app/layers/solution/app/public/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
  56. package/app/layers/solution/app/public/images/logo-lvs-1300x330.png +0 -0
  57. package/app/layouts/auth.vue +71 -0
  58. package/app/lib/utils.ts +7 -0
  59. package/app/middleware/.gitkeep +0 -0
  60. package/app/modules/layer-route-priority.ts +60 -0
  61. package/app/pages/index.vue +9 -0
  62. package/app/pages/san-pham-proxmox.vue +8 -0
  63. package/app/plugins/.gitkeep +0 -0
  64. package/app/plugins/sdk.ts +29 -0
  65. package/app/stores/.gitkeep +0 -0
  66. package/app/types/sdk.d.ts +15 -0
  67. package/components.json +24 -0
  68. package/nuxt.config.ts +104 -0
  69. package/package.json +52 -0
  70. package/public/.gitkeep +0 -0
  71. package/server/api/.gitkeep +0 -0
  72. package/server/utils/.gitkeep +0 -0
  73. package/tailwind.config.js +96 -0
  74. package/tsconfig.base.json +18 -0
  75. package/tsconfig.json +18 -0
  76. package/website-longvan-1.1.6.tgz +0 -0
  77. package/website-longvan-1.1.7.tgz +0 -0
  78. package/website-longvan-1.1.8.tgz +0 -0
@@ -0,0 +1,256 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ plans: Array<{
4
+ id: string
5
+ name: string
6
+ level: string
7
+ duration: string
8
+ icon: string
9
+ }>
10
+ selectedPlanId?: string
11
+ }>()
12
+
13
+ const { addOpportunity } = useCrmApi()
14
+
15
+ // Form state
16
+ const selectedCourseId = ref(props.selectedPlanId || '')
17
+ const form = reactive({ name: '', phone: '', email: '', note: '' })
18
+ const studyMode = ref('online')
19
+ const errors = reactive({ name: '', phone: '', email: '', course: '' })
20
+ const touched = reactive({ name: false, phone: false, email: false, course: false })
21
+ const submitting = ref(false)
22
+ const submitSuccess = ref(false)
23
+
24
+ // Auto-select first plan if none selected
25
+ watch(() => props.plans, (list: any) => {
26
+ if (!selectedCourseId.value && list.length > 0) {
27
+ selectedCourseId.value = list[0].id
28
+ }
29
+ }, { immediate: true })
30
+
31
+ // Sync when selectedPlanId prop changes
32
+ watch(() => props.selectedPlanId, (id) => {
33
+ if (id) selectedCourseId.value = id
34
+ })
35
+
36
+ const selectedPlan = computed(() => props.plans.find((p) => p.id === selectedCourseId.value))
37
+
38
+ // Validation
39
+ function touch(field: keyof typeof touched) {
40
+ touched[field] = true
41
+ validateField(field)
42
+ }
43
+
44
+ function validateField(field: keyof typeof touched) {
45
+ if (field === 'name') {
46
+ if (!form.name) errors.name = 'Vui lòng nhập họ tên'
47
+ else if (form.name.length < 2) errors.name = 'Họ tên phải có ít nhất 2 ký tự'
48
+ else errors.name = ''
49
+ }
50
+ if (field === 'phone') {
51
+ if (!form.phone) errors.phone = 'Vui lòng nhập số điện thoại'
52
+ 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ệ'
53
+ else errors.phone = ''
54
+ }
55
+ if (field === 'email') {
56
+ if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Email không hợp lệ'
57
+ else errors.email = ''
58
+ }
59
+ if (field === 'course') {
60
+ if (!selectedCourseId.value) errors.course = 'Vui lòng chọn khóa học'
61
+ else errors.course = ''
62
+ }
63
+ }
64
+
65
+ function formatLevel(level?: string) {
66
+ const labels: Record<string, string> = {
67
+ basic: 'Cơ bản',
68
+ advanced: 'Nâng cao',
69
+ expert: 'Chuyên gia',
70
+ }
71
+
72
+ return labels[level || ''] || level || ''
73
+ }
74
+
75
+ function validateAll() {
76
+ ; (['name', 'phone', 'email', 'course'] as const).forEach((f) => {
77
+ touched[f] = true
78
+ validateField(f)
79
+ })
80
+ return !errors.name && !errors.phone && !errors.email && !errors.course
81
+ }
82
+
83
+ // Submit
84
+ async function handleSubmit() {
85
+ if (!validateAll()) return
86
+ submitting.value = true
87
+ submitSuccess.value = false
88
+
89
+ try {
90
+ const hostname = window.location.origin
91
+ const studyModeLabel =
92
+ studyMode.value === 'online'
93
+ ? 'Học trực tuyến (Online)'
94
+ : studyMode.value === 'offline'
95
+ ? 'Học tại lớp (Offline)'
96
+ : 'Đào tạo doanh nghiệp'
97
+
98
+ const levelLabel =
99
+ selectedPlan.value?.level === 'basic'
100
+ ? 'Cơ bản'
101
+ : selectedPlan.value?.level === 'advanced'
102
+ ? 'Nâng cao'
103
+ : selectedPlan.value?.level === 'expert'
104
+ ? 'Chuyên gia'
105
+ : selectedPlan.value?.level || ''
106
+
107
+
108
+ const description = [
109
+ `<strong>Khóa học:</strong> ${selectedPlan.value?.name || ''}`,
110
+ `<strong>Trình độ:</strong> ${levelLabel}`,
111
+ `<strong>Thời lượng:</strong> ${selectedPlan.value?.duration || ''}`,
112
+ `<strong>Hình thức học:</strong> ${studyModeLabel}`,
113
+ `<strong>Họ và tên:</strong> ${form.name}`,
114
+ `<strong>Số điện thoại:</strong> ${form.phone}`,
115
+ `<strong>Email:</strong> ${form.email || 'Không cung cấp'}`,
116
+ `<strong>Ghi chú:</strong> ${form.note || 'Không có'}`,
117
+ ].join('<br>')
118
+
119
+ await addOpportunity({
120
+ name: `Yêu cầu đăng ký khóa học ${selectedPlan.value?.name}`,
121
+ description: description,
122
+ referName: form.name,
123
+ referPhone: form.phone,
124
+ referEmail: form.email,
125
+ targetUrl: `${hostname}${window.location.pathname}`,
126
+ extSource: 'WEBSITE',
127
+ })
128
+ submitSuccess.value = true
129
+
130
+ setTimeout(() => {
131
+ submitSuccess.value = false
132
+ }, 2000)
133
+ form.name = ''
134
+ form.phone = ''
135
+ form.email = ''
136
+ form.note = ''
137
+ Object.keys(touched).forEach((k) => (touched[k as keyof typeof touched] = false))
138
+ } catch (err) {
139
+ console.error('Error submitting registration:', err)
140
+ errors.name = 'Gửi đăng ký thất bại, vui lòng thử lại.'
141
+ touched.name = true
142
+ } finally {
143
+ submitting.value = false
144
+ }
145
+ }
146
+ </script>
147
+
148
+ <template>
149
+ <section class="rounded-2xl border border-[#e7e9f0] bg-white p-8">
150
+ <h2 class="mb-5 text-[17px] font-bold text-slate-900">Thông tin đăng ký</h2>
151
+
152
+ <form @submit.prevent="handleSubmit">
153
+ <!-- Course selection -->
154
+ <div class="mb-5">
155
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700">Chọn khóa học</label>
156
+ <div class="flex flex-col gap-[10px]">
157
+ <label v-for="course in plans" :key="course.id"
158
+ class="relative flex cursor-pointer items-center gap-3 rounded-[10px] border px-4 py-[14px] transition-colors"
159
+ :class="selectedCourseId === course.id ? 'border-[#2f5fe0] bg-[#eef3ff]' : 'border-[#e2e5ee] bg-white'">
160
+ <input type="radio" name="course" class="absolute h-0 w-0 opacity-0" :value="course.id"
161
+ v-model="selectedCourseId" @change="touch('course')">
162
+ <span class="relative h-[18px] w-[18px] flex-none rounded-[10px] border-2 transition-colors"
163
+ :class="selectedCourseId === course.id ? 'border-[#2f5fe0]' : 'border-[#cbd2e1]'">
164
+ <span v-if="selectedCourseId === course.id" class="absolute inset-[3px] rounded-[10px] bg-[#2f5fe0]" />
165
+ </span>
166
+ <span class="flex flex-col gap-[2px]">
167
+ <span class="text-sm font-semibold text-slate-900">{{ course.name }}</span>
168
+ <span class="text-[12.5px] text-gray-500">{{ formatLevel(course.level) }} · {{ course.duration }}</span>
169
+ </span>
170
+ </label>
171
+ </div>
172
+ <p v-if="touched.course && errors.course" class="mt-1 text-[12px] text-red-500">{{ errors.course }}</p>
173
+ </div>
174
+
175
+ <!-- Name & Phone -->
176
+ <div class="mb-5 grid grid-cols-1 gap-4 sm:grid-cols-2">
177
+ <div>
178
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700" for="fullname">Họ và tên</label>
179
+ <input id="fullname" v-model="form.name" type="text"
180
+ class="w-full rounded-[10px] border px-[14px] py-[11px] text-sm text-slate-900 placeholder:text-gray-400 focus:outline-none focus:ring-[3px]"
181
+ :class="touched.name && errors.name ? 'border-red-400 focus:border-red-400 focus:ring-red-400/15' : 'border-[#e2e5ee] focus:border-[#2f5fe0] focus:ring-[#2f5fe0]/15'"
182
+ placeholder="Nguyễn Văn A" @blur="touch('name')">
183
+ <p v-if="touched.name && errors.name" class="mt-1 text-[12px] text-red-500">{{ errors.name }}</p>
184
+ </div>
185
+ <div>
186
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700" for="phone">Số điện thoại</label>
187
+ <input id="phone" v-model="form.phone" type="tel"
188
+ class="w-full rounded-[10px] border px-[14px] py-[11px] text-sm text-slate-900 placeholder:text-gray-400 focus:outline-none focus:ring-[3px]"
189
+ :class="touched.phone && errors.phone ? 'border-red-400 focus:border-red-400 focus:ring-red-400/15' : 'border-[#e2e5ee] focus:border-[#2f5fe0] focus:ring-[#2f5fe0]/15'"
190
+ placeholder="0912 345 678" @blur="touch('phone')">
191
+ <p v-if="touched.phone && errors.phone" class="mt-1 text-[12px] text-red-500">{{ errors.phone }}</p>
192
+ </div>
193
+ </div>
194
+
195
+ <!-- Email -->
196
+ <div class="mb-5">
197
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700" for="email">Email</label>
198
+ <input id="email" v-model="form.email" type="email"
199
+ class="w-full rounded-[10px] border px-[14px] py-[11px] text-sm text-slate-900 placeholder:text-gray-400 focus:outline-none focus:ring-[3px]"
200
+ :class="touched.email && errors.email ? 'border-red-400 focus:border-red-400 focus:ring-red-400/15' : 'border-[#e2e5ee] focus:border-[#2f5fe0] focus:ring-[#2f5fe0]/15'"
201
+ placeholder="example@congty.vn" @blur="touch('email')">
202
+ <p v-if="touched.email && errors.email" class="mt-1 text-[12px] text-red-500">{{ errors.email }}</p>
203
+ </div>
204
+
205
+ <!-- Study mode -->
206
+ <div class="mb-5">
207
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700">Hình thức học</label>
208
+ <div class="flex flex-wrap gap-[10px]">
209
+ <button type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
210
+ :class="studyMode === 'online' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
211
+ @click="studyMode = 'online'">
212
+ Học trực tuyến (Online)
213
+ </button>
214
+ <button type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
215
+ :class="studyMode === 'offline' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
216
+ @click="studyMode = 'offline'">
217
+ Học tại lớp (Offline)
218
+ </button>
219
+ <button type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
220
+ :class="studyMode === 'enterprise' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
221
+ @click="studyMode = 'enterprise'">
222
+ Đào tạo doanh nghiệp
223
+ </button>
224
+ </div>
225
+ </div>
226
+
227
+ <!-- Notes -->
228
+ <div class="mb-5">
229
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700" for="note">Ghi chú (tùy chọn)</label>
230
+ <textarea id="note" v-model="form.note" rows="3"
231
+ class="w-full min-h-[84px] resize-y rounded-[10px] border border-[#e2e5ee] bg-[#fafafb] px-[14px] py-[11px] text-sm text-slate-900 placeholder:text-gray-400 focus:border-[#2f5fe0] focus:outline-none focus:ring-[3px] focus:ring-[#2f5fe0]/15"
232
+ placeholder="Kinh nghiệm hiện tại, mục tiêu học tập..." />
233
+ </div>
234
+
235
+ <!-- Submit -->
236
+ <button type="submit" :disabled="submitting"
237
+ class="flex w-full items-center justify-center gap-2 rounded-[10px] bg-[#2f5fe0] px-5 py-[13px] text-[14.5px] font-semibold text-white transition-colors hover:bg-[#274ec2] disabled:opacity-60">
238
+ <span v-if="submitting">Đang gửi...</span>
239
+ <template v-else>
240
+ Gửi đăng ký
241
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
242
+ stroke-linecap="round" stroke-linejoin="round">
243
+ <line x1="22" y1="2" x2="11" y2="13" />
244
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
245
+ </svg>
246
+ </template>
247
+ </button>
248
+ </form>
249
+
250
+ <!-- Success message -->
251
+ <div v-if="submitSuccess"
252
+ class="mb-5 mt-5 rounded-[10px] border border-green-200 bg-green-50 p-4 text-sm text-green-700">
253
+ Đăng ký thành công! Chúng tôi sẽ liên hệ với bạn trong thời gian sớm nhất.
254
+ </div>
255
+ </section>
256
+ </template>
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <section id="khach-hang" 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="text-center mb-16">
5
+ <h2 class="text-[#004AC6] font-bold uppercase tracking-widest text-sm mb-2">Học viên nói gì</h2>
6
+ <h3 class="font-['Montserrat'] font-extrabold text-2xl sm:text-3xl text-slate-900">
7
+ Được tư vấn đúng khóa, học đúng thứ cần dùng
8
+ </h3>
9
+ </div>
10
+
11
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
12
+ <blockquote
13
+ v-for="(t, i) in testimonials"
14
+ :key="t.name"
15
+ :data-delay="i * 150"
16
+ class="testimonial-card bg-white p-8 rounded-xl border border-slate-200 relative hover:shadow-lg transition-shadow"
17
+ >
18
+ <Icon name="lucide:quote" class="text-blue-100 text-4xl absolute top-6 right-6" />
19
+ <p class="text-slate-700 leading-relaxed mb-6 relative z-10">{{ t.quote }}</p>
20
+ <footer class="flex items-center gap-3">
21
+ <div class="w-10 h-10 rounded-[10px] bg-gradient-to-br from-[#2563EB] to-[#1D4ED8] flex items-center justify-center text-xs font-bold text-white">
22
+ {{ t.initials }}
23
+ </div>
24
+ <div class="text-sm">
25
+ <p class="font-bold text-slate-900">{{ t.name }}</p>
26
+ <p class="text-slate-500">{{ t.role }}</p>
27
+ </div>
28
+ </footer>
29
+ </blockquote>
30
+ </div>
31
+ </div>
32
+ </section>
33
+ </template>
34
+
35
+ <script setup>
36
+ useScrollAnimate('.testimonial-card', 'slide-left')
37
+
38
+ const testimonials = [
39
+ {
40
+ initials: 'TH',
41
+ name: 'Trần Hải',
42
+ role: 'IT Manager, chuỗi bán lẻ 20 chi nhánh',
43
+ quote: 'Ban đầu định đăng ký gói cao nhất, nhưng buổi tư vấn cho thấy đội em chỉ cần gói Nền tảng. Tiết kiệm được kha khá chi phí mà vẫn đạt mục tiêu.'
44
+ },
45
+ {
46
+ initials: 'NL',
47
+ name: 'Nguyễn Lâm',
48
+ role: 'DevOps Lead, công ty fintech',
49
+ quote: 'Người tư vấn hỏi rất kỹ về hạ tầng hiện tại trước khi gợi ý khóa học, nên nội dung học sát với công việc thật, không lan man.'
50
+ },
51
+ {
52
+ initials: 'PA',
53
+ name: 'Phạm Anh',
54
+ role: 'CTO, startup logistics',
55
+ quote: 'Nhờ được tư vấn đúng lộ trình, công ty chuyển từ VMware sang cluster HCI trên Proxmox và cắt gần 60% chi phí license hàng năm.'
56
+ }
57
+ ]
58
+ </script>
@@ -0,0 +1,61 @@
1
+ <template>
2
+ <footer class="bg-white border-t border-slate-200 pt-16 pb-10">
3
+ <div class="max-w-7xl mx-auto px-6 lg:px-8 grid grid-cols-1 md:grid-cols-4 gap-10">
4
+ <div class="space-y-4">
5
+ <div class="flex items-center">
6
+ <img src="/images/logo-lvs-1300x330.png" alt="Long Vân" class="h-8 w-auto" />
7
+ </div>
8
+ <p class="text-slate-500 text-sm leading-relaxed">
9
+ Tư vấn và đào tạo hạ tầng siêu hội tụ Proxmox. Đồng hành cùng bạn chọn đúng khóa học,
10
+ đúng lộ trình nghề nghiệp.
11
+ </p>
12
+ </div>
13
+
14
+ <div>
15
+ <h5 class="font-bold text-slate-900 mb-5 text-sm">Khóa học</h5>
16
+ <ul class="space-y-3">
17
+ <li><NuxtLink to="#dao-tao" @click.prevent="scrollTo('dao-tao')" class="text-slate-500 hover:text-[#004AC6] transition-colors text-sm">Proxmox nền tảng</NuxtLink></li>
18
+ <li><NuxtLink to="#dao-tao" @click.prevent="scrollTo('dao-tao')" class="text-slate-500 hover:text-[#004AC6] transition-colors text-sm">Vận hành production</NuxtLink></li>
19
+ <li><NuxtLink to="#dao-tao" @click.prevent="scrollTo('dao-tao')" class="text-slate-500 hover:text-[#004AC6] transition-colors text-sm">Đào tạo doanh nghiệp</NuxtLink></li>
20
+ </ul>
21
+ </div>
22
+
23
+ <div>
24
+ <h5 class="font-bold text-slate-900 mb-5 text-sm">Tư vấn</h5>
25
+ <ul class="space-y-3">
26
+ <li><NuxtLink to="#giai-phap" @click.prevent="scrollTo('giai-phap')" class="text-slate-500 hover:text-[#004AC6] transition-colors text-sm">Quy trình tư vấn</NuxtLink></li>
27
+ <li><NuxtLink to="#khach-hang" @click.prevent="scrollTo('khach-hang')" class="text-slate-500 hover:text-[#004AC6] transition-colors text-sm">Học viên nói gì</NuxtLink></li>
28
+ <li><NuxtLink to="#lien-he" @click.prevent="scrollTo('lien-he')" class="text-slate-500 hover:text-[#004AC6] transition-colors text-sm">Đặt lịch tư vấn</NuxtLink></li>
29
+ </ul>
30
+ </div>
31
+
32
+ <div>
33
+ <h5 class="font-bold text-slate-900 mb-5 text-sm">Liên hệ</h5>
34
+ <ul class="space-y-3 text-sm text-slate-500">
35
+ <li class="flex items-center gap-2">
36
+ <Icon name="lucide:mail" class="text-[16px] text-[#004AC6]" />
37
+ tuvan@longvan.net
38
+ </li>
39
+ <li class="flex items-center gap-2">
40
+ <Icon name="lucide:phone" class="text-[16px] text-[#004AC6]" />
41
+ 1800 6070
42
+ </li>
43
+ </ul>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="max-w-7xl mx-auto px-6 lg:px-8 mt-12 pt-8 border-t border-slate-200 flex flex-col sm:flex-row justify-between items-center gap-4">
48
+ <p class="text-slate-400 text-xs">© {{ year }} Long Vân Cloud Solution. Bảo lưu mọi quyền.</p>
49
+ <div class="flex gap-6 text-xs text-slate-500">
50
+ <NuxtLink to="#giai-phap" @click.prevent="scrollTo('giai-phap')" class="hover:text-[#004AC6] transition-colors">Vì sao chọn chúng tôi</NuxtLink>
51
+ <NuxtLink to="#dao-tao" @click.prevent="scrollTo('dao-tao')" class="hover:text-[#004AC6] transition-colors">Khóa học</NuxtLink>
52
+ <NuxtLink to="#lien-he" @click.prevent="scrollTo('lien-he')" class="hover:text-[#004AC6] transition-colors">Tư vấn</NuxtLink>
53
+ </div>
54
+ </div>
55
+ </footer>
56
+ </template>
57
+
58
+ <script setup>
59
+ const { scrollTo } = useScrollToSection()
60
+ const year = new Date().getFullYear()
61
+ </script>
@@ -0,0 +1,189 @@
1
+ <template>
2
+ <header class="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-md border-b border-slate-200/70 shadow-sm">
3
+ <div class="max-w-7xl mx-auto px-6 lg:px-8 flex items-center justify-between h-16">
4
+ <NuxtLink :to="`/giai-phap-proxmox/${slug}`" class="flex items-center gap-2.5">
5
+ <img src="/images/logo-lvs-1300x330.png" alt="Long Vân" class="h-8 w-auto" />
6
+ </NuxtLink>
7
+
8
+ <nav class="hidden md:flex items-center gap-8 text-sm font-medium text-slate-600">
9
+ <template v-for="link in navLinks" :key="link.id">
10
+ <template v-if="link.path">
11
+ <NuxtLink v-if="link.path && link.path != route.path" :to="`${link.path}`" :class="[
12
+ 'pb-1 border-b-2 transition-all duration-200 cursor-pointer',
13
+ activeSection === link.id
14
+ ? 'text-[#004AC6] font-bold border-[#004AC6]'
15
+ : 'border-transparent hover:text-[#004AC6]'
16
+ ]">
17
+ {{ link.label }}
18
+ </NuxtLink>
19
+ <a v-else :href="`/giai-phap-proxmox/${slug}#${link.id}`" @click.prevent="handleAnchorClick(link)" :class="[
20
+ 'pb-1 border-b-2 transition-all duration-200 cursor-pointer',
21
+ activeSection === link.id
22
+ ? 'text-[#004AC6] font-bold border-[#004AC6]'
23
+ : 'border-transparent hover:text-[#004AC6]'
24
+ ]">
25
+ {{ link.label }}
26
+ </a>
27
+ </template>
28
+ <a v-else :href="`/giai-phap-proxmox/${slug}#${link.id}`" @click.prevent="handleAnchorClick(link)" :class="[
29
+ 'pb-1 border-b-2 transition-all duration-200 cursor-pointer',
30
+ activeSection === link.id
31
+ ? 'text-[#004AC6] font-bold border-[#004AC6]'
32
+ : 'border-transparent hover:text-[#004AC6]'
33
+ ]">
34
+ {{ link.label }}
35
+ </a>
36
+ </template>
37
+ </nav>
38
+
39
+ <!-- Logged in -->
40
+ <div v-if="user" class="relative">
41
+ <button @click="showMenu = !showMenu"
42
+ class="flex items-center gap-2.5 text-sm font-medium text-slate-700 hover:text-[#004AC6] transition-colors">
43
+ <span
44
+ class="w-8 h-8 rounded-[10px] bg-gradient-to-br from-[#004AC6] to-[#1D4ED8] flex items-center justify-center text-white text-xs font-bold">
45
+ {{ avatarInitial }}
46
+ </span>
47
+ <span class="hidden sm:block">Xin chào, <strong>{{ user.fullName || user.username }}</strong></span>
48
+ <Icon name="lucide:chevron-down" class="text-[14px] text-slate-400" />
49
+ </button>
50
+
51
+ <!-- Dropdown -->
52
+ <Transition enter-active-class="transition ease-out duration-100" enter-from-class="opacity-0 scale-95"
53
+ enter-to-class="opacity-100 scale-100" leave-active-class="transition ease-in duration-75"
54
+ leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
55
+ <div v-if="showMenu"
56
+ class="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-slate-200/80 py-1.5 z-50">
57
+ <button @click="logout"
58
+ class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 transition-colors">
59
+ <Icon name="lucide:log-out" class="text-[15px]" />
60
+ Đăng xuất
61
+ </button>
62
+ </div>
63
+ </Transition>
64
+ </div>
65
+
66
+ <!-- Not logged in -->
67
+ <div v-else class="flex items-center gap-3">
68
+ <a v-if="isConsultant" :href="`/giai-phap-proxmox/${slug}#lien-he`" @click.prevent="scrollTo('lien-he')"
69
+ class="inline-flex items-center gap-1.5 text-sm font-bold px-5 py-2.5 rounded-[10px] border border-[#004AC6] text-[#004AC6] hover:bg-blue-50 transition-all active:scale-95 cursor-pointer">
70
+ Liên hệ tư vấn
71
+ <Icon name="lucide:headset" class="text-[16px]" />
72
+ </a>
73
+ <NuxtLink to="/dang-nhap"
74
+ class="inline-flex items-center gap-1.5 text-sm font-bold px-5 py-2.5 rounded-[10px] bg-[#004AC6] text-white hover:bg-[#1D4ED8] transition-all active:scale-95 shadow-sm shadow-blue-600/20">
75
+ Đăng nhập
76
+ <Icon name="lucide:log-in" class="text-[16px]" />
77
+ </NuxtLink>
78
+ </div>
79
+ </div>
80
+ </header>
81
+ </template>
82
+
83
+ <script setup lang="ts">
84
+ const props = defineProps({
85
+ targetCTA: { type: String, default: '' },
86
+ slug: { type: String, default: '' },
87
+ })
88
+
89
+ const isConsultant = computed(() => props.targetCTA === 'CONSULTANT')
90
+
91
+ const { getCookie, deleteCookie } = useCookieHelper()
92
+ const { getUserByToken } = useAuthApi()
93
+ const { isLoggedIn, userName, setUser, clearUser } = useAuthState()
94
+ const config = useRuntimeConfig()
95
+ const router = useRouter()
96
+ const route = useRoute()
97
+
98
+ const tokenKey = config.public.ACCESSTOKEN_NAME as string
99
+
100
+ const navLinks = computed(() => [
101
+ { id: 'san-pham', label: 'Sản phẩm', path: `/giai-phap-proxmox/${props.slug}/proxmox-hci` },
102
+ { id: 'giai-phap', label: 'Vì sao chọn chúng tôi' },
103
+ { id: 'chi-phi', label: 'Học phí' },
104
+ { id: 'khach-hang', label: 'Học viên nói gì' },
105
+ ])
106
+
107
+ const { scrollTo } = useScrollToSection()
108
+
109
+ const activeSection = useActiveSection(navLinks.value.map(l => l.id))
110
+ const user = ref<{ fullName?: string; username?: string } | null>(null)
111
+ const showMenu = ref(false)
112
+
113
+ function handleNavClick(e: Event, link: any) {
114
+ if (route.path === link.path) {
115
+ e.preventDefault()
116
+ scrollTo(link.id)
117
+ }
118
+ }
119
+
120
+ function handleAnchorClick(link: any) {
121
+ const el = document.getElementById(link.id)
122
+ if (el) {
123
+ scrollTo(link.id)
124
+ } else {
125
+ router.push(`/giai-phap-proxmox/${props.slug}#${link.id}`)
126
+ }
127
+ }
128
+
129
+ const avatarInitial = computed(() => {
130
+ const name = user.value?.fullName || user.value?.username || ''
131
+ return name.charAt(0).toUpperCase() || '?'
132
+ })
133
+
134
+ async function loadUser() {
135
+ const token = getCookie(tokenKey)
136
+ if (!token) return
137
+
138
+ try {
139
+ const res = await getUserByToken(token)
140
+ if (res) {
141
+ user.value = {
142
+ fullName: res.fullName,
143
+ username: res.username || res.userLoginId,
144
+ }
145
+ setUser(res.fullName || res.username || '')
146
+ }
147
+ } catch {
148
+ deleteCookie(tokenKey)
149
+ clearUser()
150
+ }
151
+ }
152
+
153
+ // Watch auth state changes (when login/register updates state)
154
+ watch(isLoggedIn, (loggedIn) => {
155
+ if (loggedIn && userName.value) {
156
+ user.value = {
157
+ fullName: userName.value,
158
+ username: userName.value,
159
+ }
160
+ } else if (!loggedIn) {
161
+ user.value = null
162
+ }
163
+ })
164
+
165
+ function logout() {
166
+ deleteCookie(tokenKey)
167
+ user.value = null
168
+ clearUser()
169
+ showMenu.value = false
170
+ router.push('/giai-phap-proxmox')
171
+ }
172
+
173
+ // Close menu on outside click
174
+ function onClickOutside(e: MouseEvent) {
175
+ const target = e.target as HTMLElement
176
+ if (!target.closest('.relative')) {
177
+ showMenu.value = false
178
+ }
179
+ }
180
+
181
+ onMounted(() => {
182
+ loadUser()
183
+ document.addEventListener('click', onClickOutside)
184
+ })
185
+
186
+ onUnmounted(() => {
187
+ document.removeEventListener('click', onClickOutside)
188
+ })
189
+ </script>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ name: string
4
+ class?: string
5
+ }>()
6
+
7
+ const paths: Record<string, string> = {
8
+ cpu: "M9 3v2M15 3v2M9 19v2M15 19v2M3 9h2M3 15h2M19 9h2M19 15h2M7 7h10v10H7z M10 10h4v4h-4z",
9
+ database:
10
+ "M12 5c4.418 0 8-1.12 8-2.5S16.418 0 12 0 4 1.12 4 2.5 7.582 5 12 5zM4 2.5V19.5C4 20.88 7.582 22 12 22s8-1.12 8-2.5V2.5M4 8.5C4 9.88 7.582 11 12 11s8-1.12 8-2.5M4 14.5c0 1.38 3.582 2.5 8 2.5s8-1.12 8-2.5",
11
+ share: "M6 12a3 3 0 100-6 3 3 0 000 6zM18 6a3 3 0 100-6 3 3 0 000 6zM18 24a3 3 0 100-6 3 3 0 000 6zM8.5 10.5l7-4M8.5 13.5l7 4",
12
+ cluster: "M6 9a3 3 0 100-6 3 3 0 000 6zM18 9a3 3 0 100-6 3 3 0 000 6zM12 21a3 3 0 100-6 3 3 0 000 6zM6 9v3a3 3 0 003 3M18 9v3a3 3 0 01-3 3",
13
+ shield: "M12 2l8 3.5v5.5c0 5-3.4 8.9-8 11-4.6-2.1-8-6-8-11V5.5L12 2z",
14
+ bolt: "M13 2L3 14h7l-1 8 11-14h-8l1-6z",
15
+ chart: "M4 20V10M10 20V4M16 20v-7M22 20H2",
16
+ layers: "M12 2l9 5-9 5-9-5 9-5zM3 12l9 5 9-5M3 17l9 5 9-5",
17
+ book: "M4 4.5A2.5 2.5 0 016.5 2H20v17H6.5A2.5 2.5 0 004 21.5V4.5zM4 19.5A2.5 2.5 0 016.5 17H20",
18
+ users: "M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75",
19
+ trending: "M23 6l-9.5 9.5-5-5L1 18M17 6h6v6",
20
+ award: "M12 15a6 6 0 100-12 6 6 0 000 12zM8.21 13.89L7 23l5-3 5 3-1.21-9.12",
21
+ globe: "M12 22a10 10 0 100-20 10 10 0 000 20zM2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z",
22
+ activity: "M22 12h-4l-3 9L9 3l-3 9H2",
23
+ check: "M20 6L9 17l-5-5",
24
+ x: "M18 6L6 18M6 6l12 12",
25
+ star: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z",
26
+ cap: "M22 10L12 5 2 10l10 5 10-5zM6 12v5c0 1.66 2.69 3 6 3s6-1.34 6-3v-5",
27
+ calendar: "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z",
28
+ arrowRight: "M5 12h14M12 5l7 7-7 7",
29
+ chevronDown: "M6 9l6 6 6-6"
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <svg
35
+ :class="$props.class"
36
+ viewBox="0 0 24 24"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ stroke-width="2"
40
+ stroke-linecap="round"
41
+ stroke-linejoin="round"
42
+ >
43
+ <path :d="paths[name] ?? paths.check" />
44
+ </svg>
45
+ </template>
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ import { comparisonRows } from "../../data/proxmox-content"
3
+ </script>
4
+
5
+ <template>
6
+ <section class="bg-white py-20 sm:py-28">
7
+ <div class="mx-auto max-w-4xl px-6 lg:px-8">
8
+ <div class="text-center">
9
+ <div v-reveal class="inline-flex items-center rounded-full bg-blue-50 px-4 py-1.5 text-sm font-medium text-blue-600 ring-1 ring-inset ring-blue-100">
10
+ So sánh
11
+ </div>
12
+ <h2 v-reveal="{ delay: 60 }" class="mt-5 text-3xl font-extrabold tracking-tight text-slate-900 sm:text-4xl">
13
+ <span class="text-slate-400">Proxmox HCI vs</span>
14
+ Hạ tầng truyền thống
15
+ </h2>
16
+ <p v-reveal="{ delay: 120 }" class="mt-5 leading-relaxed text-slate-600">
17
+ Đặt lên bàn cân để thấy sự khác biệt về hiệu quả đầu tư và khả năng vận hành lâu dài.
18
+ </p>
19
+ </div>
20
+
21
+ <div v-reveal="{ delay: 180 }" class="mt-12 overflow-hidden rounded-2xl border border-slate-200 shadow-sm">
22
+ <table class="w-full border-collapse text-left text-sm">
23
+ <thead>
24
+ <tr class="bg-blue-600 text-white">
25
+ <th class="px-6 py-4 font-semibold">Tiêu chí</th>
26
+ <th class="px-6 py-4 text-center font-semibold">Proxmox HCI</th>
27
+ <th class="px-6 py-4 text-center font-semibold">Hạ tầng truyền thống</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody class="divide-y divide-slate-200 bg-white">
31
+ <tr v-for="row in comparisonRows" :key="row.label">
32
+ <td class="px-6 py-4 font-medium text-slate-700">{{ row.label }}</td>
33
+ <td class="px-6 py-4">
34
+ <span class="mx-auto flex w-fit items-center gap-1.5 font-medium text-emerald-600">
35
+ <ProxmoxAppIcon name="check" class="h-4 w-4 rounded-full bg-emerald-50 p-0.5" />
36
+ {{ row.hci }}
37
+ </span>
38
+ </td>
39
+ <td class="px-6 py-4">
40
+ <span
41
+ class="mx-auto flex w-fit items-center gap-1.5 font-medium"
42
+ :class="row.legacyOk ? 'text-emerald-600' : 'text-rose-500'"
43
+ >
44
+ <ProxmoxAppIcon :name="row.legacyOk ? 'check' : 'x'" class="h-4 w-4 rounded-full p-0.5" :class="row.legacyOk ? 'bg-emerald-50' : 'bg-rose-50'" />
45
+ {{ row.legacy }}
46
+ </span>
47
+ </td>
48
+ </tr>
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+ </div>
53
+ </section>
54
+ </template>