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,208 @@
1
+ <template>
2
+ <div>
3
+ <TheHeader />
4
+ <div class="min-h-[calc(50vh-4rem)] flex items-start justify-center pt-20 pb-16 px-6">
5
+ <div class="w-full max-w-md">
6
+ <!-- Header -->
7
+ <div class="mb-8 text-center">
8
+ <h1 class="font-['Montserrat'] font-extrabold text-2xl text-slate-900 mb-2">Đăng nhập</h1>
9
+ <p class="text-slate-500 text-sm">Chào mừng trở lại! Nhập thông tin để tiếp tục.</p>
10
+ </div>
11
+
12
+ <!-- Form card -->
13
+ <div class="bg-white rounded-2xl shadow-sm border border-slate-200/80 p-8">
14
+ <form @submit.prevent="onSubmit" class="space-y-5">
15
+ <!-- Username -->
16
+ <div>
17
+ <label for="username" class="block text-sm font-semibold text-slate-700 mb-1.5">
18
+ Tên tài khoản
19
+ </label>
20
+ <div class="relative">
21
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
22
+ <Icon name="lucide:user" class="text-[17px]" />
23
+ </span>
24
+ <input
25
+ id="username"
26
+ v-model="form.username"
27
+ type="text"
28
+ autocomplete="username"
29
+ placeholder="Nhập SĐT hoặc Email"
30
+ :class="[
31
+ 'w-full pl-10 pr-4 py-3 rounded-[10px] border text-sm outline-none transition-all',
32
+ errors.username
33
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
34
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
35
+ ]"
36
+ />
37
+ </div>
38
+ <p v-if="errors.username" class="mt-1 text-xs text-red-500 flex items-center gap-1">
39
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.username }}
40
+ </p>
41
+ </div>
42
+
43
+ <!-- Password -->
44
+ <div>
45
+ <div class="flex items-center justify-between mb-1.5">
46
+ <label for="password" class="text-sm font-semibold text-slate-700">Mật khẩu</label>
47
+ <NuxtLink :to="{ path: '/quen-mat-khau', query: { redirectUrl } }" class="text-xs text-[#004AC6] font-medium hover:underline">
48
+ Quên mật khẩu?
49
+ </NuxtLink>
50
+ </div>
51
+ <div class="relative">
52
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
53
+ <Icon name="lucide:lock" class="text-[17px]" />
54
+ </span>
55
+ <input
56
+ id="password"
57
+ v-model="form.password"
58
+ :type="showPassword ? 'text' : 'password'"
59
+ autocomplete="current-password"
60
+ placeholder="Nhập mật khẩu"
61
+ :class="[
62
+ 'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
63
+ errors.password
64
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
65
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
66
+ ]"
67
+ />
68
+ <button
69
+ type="button"
70
+ @click="showPassword = !showPassword"
71
+ class="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
72
+ >
73
+ <Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
74
+ </button>
75
+ </div>
76
+ <p v-if="errors.password" class="mt-1 text-xs text-red-500 flex items-center gap-1">
77
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.password }}
78
+ </p>
79
+ </div>
80
+
81
+ <!-- Remember me -->
82
+ <div class="flex items-center gap-2.5">
83
+ <input
84
+ id="remember"
85
+ v-model="form.remember"
86
+ type="checkbox"
87
+ class="w-4 h-4 rounded border-slate-300 text-[#004AC6] accent-[#004AC6] cursor-pointer"
88
+ />
89
+ <label for="remember" class="text-sm text-slate-600 cursor-pointer select-none">Ghi nhớ đăng nhập</label>
90
+ </div>
91
+
92
+ <!-- Submit -->
93
+ <button
94
+ type="submit"
95
+ :disabled="loading"
96
+ class="w-full flex items-center justify-center gap-2 bg-[#004AC6] hover:bg-[#1D4ED8] active:scale-[.98] text-white font-bold py-3.5 rounded-[10px] transition-all shadow-sm shadow-blue-600/20 disabled:opacity-60 disabled:cursor-not-allowed"
97
+ >
98
+ <span v-if="!loading">Đăng nhập</span>
99
+ <span v-else class="flex items-center gap-2">
100
+ <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
101
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
102
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
103
+ </svg>
104
+ Đang xử lý...
105
+ </span>
106
+ </button>
107
+ </form>
108
+
109
+ <!-- Divider -->
110
+ <div class="flex items-center gap-3 my-6">
111
+ <div class="flex-1 h-px bg-slate-200" />
112
+ <span class="text-xs text-slate-400 font-medium">hoặc</span>
113
+ <div class="flex-1 h-px bg-slate-200" />
114
+ </div>
115
+
116
+ <!-- Register link -->
117
+ <p class="text-center text-sm text-slate-500">
118
+ Chưa có tài khoản?
119
+ <NuxtLink :to="{ path: '/dang-ky', query: { redirectUrl } }" class="text-[#004AC6] font-semibold hover:underline ml-1">
120
+ Đăng ký ngay
121
+ </NuxtLink>
122
+ </p>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ <TheFooter/>
127
+ </div>
128
+ </template>
129
+
130
+ <script setup lang="ts">
131
+ import { toast } from 'vue-sonner'
132
+
133
+ definePageMeta({ layout: 'default' })
134
+
135
+ useHead({ title: 'Đăng nhập — Long Vân' })
136
+
137
+ const { login, getUserByToken } = useAuthApi()
138
+ const { setCookie } = useCookieHelper()
139
+ const { setUser } = useAuthState()
140
+ const config = useRuntimeConfig()
141
+ const route = useRoute()
142
+ const router = useRouter()
143
+
144
+ const tokenKey = config.public.ACCESSTOKEN_NAME as string
145
+ const redirectUrl = computed(() => route.query.redirectUrl as string || '/')
146
+
147
+ const form = reactive({
148
+ username: '',
149
+ password: '',
150
+ remember: false,
151
+ })
152
+
153
+ const errors = reactive({ username: '', password: '' })
154
+ const loading = ref(false)
155
+ const showPassword = ref(false)
156
+
157
+ function validate() {
158
+ errors.username = ''
159
+ errors.password = ''
160
+ let valid = true
161
+ if (!form.username.trim()) {
162
+ errors.username = 'Vui lòng nhập tên tài khoản'
163
+ valid = false
164
+ }
165
+ if (!form.password) {
166
+ errors.password = 'Vui lòng nhập mật khẩu'
167
+ valid = false
168
+ } else if (form.password.length < 6) {
169
+ errors.password = 'Mật khẩu tối thiểu 6 ký tự'
170
+ valid = false
171
+ }
172
+ return valid
173
+ }
174
+
175
+ async function onSubmit() {
176
+ if (!validate()) return
177
+ loading.value = true
178
+
179
+ try {
180
+ const res = await login({
181
+ username: form.username.trim(),
182
+ password: form.password,
183
+ type: 'PW',
184
+ })
185
+
186
+ if (res?.accessToken) {
187
+ const days = form.remember ? 30 : 1
188
+ setCookie(tokenKey, res.accessToken, days)
189
+
190
+ // Update auth state for Header
191
+ setUser(res.fullName || res.username || form.username.trim())
192
+
193
+ const redirectUrl = route.query.redirectUrl as string
194
+ if (redirectUrl) {
195
+ window.location.assign(redirectUrl)
196
+ } else {
197
+ router.push('/giai-phap-proxmox')
198
+ }
199
+ } else {
200
+ toast.error('Tên tài khoản hoặc mật khẩu không đúng.')
201
+ }
202
+ } catch (err: any) {
203
+ toast.error(err?.message || 'Đã có lỗi xảy ra. Vui lòng thử lại.')
204
+ } finally {
205
+ loading.value = false
206
+ }
207
+ }
208
+ </script>
@@ -0,0 +1,237 @@
1
+ <template>
2
+ <div>
3
+ <TheHeader />
4
+ <div class="min-h-[calc(50vh-4rem)] flex items-start justify-center pt-20 pb-16 px-6">
5
+ <div class="w-full max-w-md">
6
+ <!-- Header -->
7
+ <div class="mb-8 text-center">
8
+ <h1 class="font-['Montserrat'] font-extrabold text-2xl text-slate-900 mb-2">Đổi mật khẩu</h1>
9
+ <p class="text-slate-500 text-sm">Tạo mật khẩu mới bảo mật cho tài khoản của bạn.</p>
10
+ </div>
11
+
12
+ <!-- Card -->
13
+ <div class="bg-white rounded-2xl shadow-sm border border-slate-200/80 p-8">
14
+
15
+ <!-- Invalid/missing token state -->
16
+ <div v-if="!resetKey && !route.query.token" class="text-center space-y-4 py-4">
17
+ <div class="w-14 h-14 rounded-[10px] bg-amber-50 flex items-center justify-center mx-auto">
18
+ <Icon name="lucide:alert-triangle" class="text-amber-500 text-[26px]" />
19
+ </div>
20
+ <p class="text-slate-600 text-sm">Link đặt lại mật khẩu không hợp lệ hoặc đã hết hạn.</p>
21
+ <NuxtLink to="/quen-mat-khau" class="inline-flex items-center gap-2 text-[#004AC6] font-semibold hover:underline">
22
+ <Icon name="lucide:arrow-left" class="text-[15px]" />
23
+ Yêu cầu lại
24
+ </NuxtLink>
25
+ </div>
26
+
27
+ <!-- Change password form -->
28
+ <form v-else @submit.prevent="onSubmit" class="space-y-5">
29
+ <!-- New password -->
30
+ <div>
31
+ <label for="newPass" class="block text-sm font-semibold text-slate-700 mb-1.5">Mật khẩu mới</label>
32
+ <div class="relative">
33
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
34
+ <Icon name="lucide:lock" class="text-[17px]" />
35
+ </span>
36
+ <input
37
+ id="newPass"
38
+ v-model="form.newPassword"
39
+ :type="showNew ? 'text' : 'password'"
40
+ autocomplete="new-password"
41
+ placeholder="Tối thiểu 6 ký tự"
42
+ :class="[
43
+ 'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
44
+ errors.newPassword
45
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
46
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
47
+ ]"
48
+ />
49
+ <button type="button" @click="showNew = !showNew" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors">
50
+ <Icon :name="showNew ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
51
+ </button>
52
+ </div>
53
+ <p v-if="errors.newPassword" class="mt-1 text-xs text-red-500 flex items-center gap-1">
54
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.newPassword }}
55
+ </p>
56
+
57
+ <!-- Strength indicator -->
58
+ <div class="mt-2 space-y-1">
59
+ <div class="flex gap-1">
60
+ <div v-for="i in 4" :key="i" :class="['h-1 flex-1 rounded-[10px] transition-all', strengthLevel >= i ? strengthColor : 'bg-slate-200']" />
61
+ </div>
62
+ <p v-if="form.newPassword" class="text-xs" :class="strengthTextColor">{{ strengthLabel }}</p>
63
+ </div>
64
+ </div>
65
+
66
+ <!-- Confirm password -->
67
+ <div>
68
+ <label for="confirmPass" class="block text-sm font-semibold text-slate-700 mb-1.5">Xác nhận mật khẩu</label>
69
+ <div class="relative">
70
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
71
+ <Icon name="lucide:lock" class="text-[17px]" />
72
+ </span>
73
+ <input
74
+ id="confirmPass"
75
+ v-model="form.confirmPassword"
76
+ :type="showConfirm ? 'text' : 'password'"
77
+ autocomplete="new-password"
78
+ placeholder="Nhập lại mật khẩu mới"
79
+ :class="[
80
+ 'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
81
+ errors.confirmPassword
82
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
83
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
84
+ ]"
85
+ />
86
+ <button type="button" @click="showConfirm = !showConfirm" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors">
87
+ <Icon :name="showConfirm ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
88
+ </button>
89
+ </div>
90
+ <p v-if="errors.confirmPassword" class="mt-1 text-xs text-red-500 flex items-center gap-1">
91
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.confirmPassword }}
92
+ </p>
93
+ </div>
94
+
95
+ <!-- Password rules -->
96
+ <ul class="space-y-1.5">
97
+ <li v-for="rule in passwordRules" :key="rule.label" class="flex items-center gap-2 text-xs">
98
+ <Icon
99
+ :name="rule.met ? 'lucide:check-circle' : 'lucide:circle'"
100
+ :class="rule.met ? 'text-emerald-500' : 'text-slate-300'"
101
+ class="text-[14px] shrink-0"
102
+ />
103
+ <span :class="rule.met ? 'text-emerald-600' : 'text-slate-400'">{{ rule.label }}</span>
104
+ </li>
105
+ </ul>
106
+
107
+ <div v-if="apiError" class="flex items-start gap-2 bg-red-50 border border-red-200 rounded-[10px] px-4 py-3 text-sm text-red-700">
108
+ <Icon name="lucide:alert-triangle" class="text-[16px] mt-0.5 shrink-0" />
109
+ <span>{{ apiError }}</span>
110
+ </div>
111
+
112
+ <button
113
+ type="submit"
114
+ :disabled="loading"
115
+ class="w-full flex items-center justify-center gap-2 bg-[#004AC6] hover:bg-[#1D4ED8] active:scale-[.98] text-white font-bold py-3.5 rounded-[10px] transition-all shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
116
+ >
117
+ <span v-if="!loading">Đổi mật khẩu</span>
118
+ <span v-else class="flex items-center gap-2">
119
+ <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
120
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
121
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
122
+ </svg>
123
+ Đang cập nhật...
124
+ </span>
125
+ </button>
126
+ </form>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ <TheFooter/>
131
+ </div>
132
+ </template>
133
+
134
+ <script setup lang="ts">
135
+ definePageMeta({ layout: 'default' })
136
+
137
+ useHead({ title: 'Đổi mật khẩu — Long Vân' })
138
+
139
+ const { checkResetKey, resetPassword } = useAuthApi()
140
+ const route = useRoute()
141
+ const router = useRouter()
142
+
143
+ const redirectUrl = computed(() => route.query.redirectUrl as string || '/')
144
+ const resetKey = ref('')
145
+ const loading = ref(false)
146
+ const apiError = ref('')
147
+ const showNew = ref(false)
148
+ const showConfirm = ref(false)
149
+
150
+ const form = reactive({ newPassword: '', confirmPassword: '' })
151
+ const errors = reactive({ newPassword: '', confirmPassword: '' })
152
+
153
+ // Password strength
154
+ const passwordRules = computed(() => [
155
+ { label: 'Tối thiểu 6 ký tự', met: form.newPassword.length >= 6 },
156
+ { label: 'Có chữ hoa (A-Z)', met: /[A-Z]/.test(form.newPassword) },
157
+ { label: 'Có chữ số (0-9)', met: /\d/.test(form.newPassword) },
158
+ { label: 'Có ký tự đặc biệt (!@#...)', met: /[!@#$%^&*]/.test(form.newPassword) },
159
+ ])
160
+
161
+ const strengthLevel = computed(() => passwordRules.value.filter(r => r.met).length)
162
+ const strengthColor = computed(() => {
163
+ if (strengthLevel.value <= 1) return 'bg-red-400'
164
+ if (strengthLevel.value === 2) return 'bg-amber-400'
165
+ if (strengthLevel.value === 3) return 'bg-blue-400'
166
+ return 'bg-emerald-500'
167
+ })
168
+ const strengthTextColor = computed(() => {
169
+ if (strengthLevel.value <= 1) return 'text-red-500'
170
+ if (strengthLevel.value === 2) return 'text-amber-600'
171
+ if (strengthLevel.value === 3) return 'text-blue-600'
172
+ return 'text-emerald-600'
173
+ })
174
+ const strengthLabel = computed(() => {
175
+ if (strengthLevel.value <= 1) return 'Yếu'
176
+ if (strengthLevel.value === 2) return 'Trung bình'
177
+ if (strengthLevel.value === 3) return 'Khá mạnh'
178
+ return 'Rất mạnh'
179
+ })
180
+
181
+ function validate() {
182
+ errors.newPassword = ''
183
+ errors.confirmPassword = ''
184
+ let valid = true
185
+ if (!form.newPassword) {
186
+ errors.newPassword = 'Vui lòng nhập mật khẩu mới'
187
+ valid = false
188
+ } else if (form.newPassword.length < 6) {
189
+ errors.newPassword = 'Mật khẩu tối thiểu 6 ký tự'
190
+ valid = false
191
+ }
192
+ if (!form.confirmPassword) {
193
+ errors.confirmPassword = 'Vui lòng xác nhận mật khẩu'
194
+ valid = false
195
+ } else if (form.newPassword !== form.confirmPassword) {
196
+ errors.confirmPassword = 'Mật khẩu xác nhận không khớp'
197
+ valid = false
198
+ }
199
+ return valid
200
+ }
201
+
202
+ async function onSubmit() {
203
+ if (!validate()) return
204
+ loading.value = true
205
+ apiError.value = ''
206
+
207
+ try {
208
+ const phone = route.query.phone as string
209
+ const token = route.query.token as string
210
+
211
+ if (token && phone) {
212
+ // Reset password using OTP token
213
+ await resetPassword(phone, form.newPassword, token)
214
+ router.push('/dang-nhap')
215
+ } else if (resetKey.value) {
216
+ // Validate reset key first
217
+ const keyRes = await checkResetKey(resetKey.value)
218
+ if (!keyRes?.accessToken) {
219
+ apiError.value = 'Link đặt lại mật khẩu đã hết hạn.'
220
+ return
221
+ }
222
+ await resetPassword(keyRes.userLoginId, form.newPassword, keyRes.accessToken)
223
+ router.push('/dang-nhap')
224
+ } else {
225
+ apiError.value = 'Thiếu thông tin xác thực. Vui lòng thử lại.'
226
+ }
227
+ } catch (err: any) {
228
+ apiError.value = err?.message || 'Đã có lỗi xảy ra. Vui lòng thử lại.'
229
+ } finally {
230
+ loading.value = false
231
+ }
232
+ }
233
+
234
+ onMounted(() => {
235
+ resetKey.value = route.query.key as string || ''
236
+ })
237
+ </script>
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <div class="bg-ink text-cloud font-body antialiased">
3
+ <TheHeader :target-c-t-a="targetCTA" :slug="slug" />
4
+ <HeroSection />
5
+ <FeaturesSection />
6
+ <CoursesSection :plans="data?.resumeData" :target-c-t-a="targetCTA" :slug="slug" />
7
+ <TestimonialsSection />
8
+ <CtaSection v-if="targetCTA === 'CONSULTANT'" :plans="data?.resumeData" />
9
+ <TheFooter/>
10
+ </div>
11
+ </template>
12
+
13
+ <script setup>
14
+ definePageMeta({
15
+ layout: 'default',
16
+ })
17
+
18
+ const route = useRoute()
19
+ const { getSolutionDetail } = useSolutionApi()
20
+
21
+ const { data, error } = await useAsyncData(
22
+ `solution-${route.params.slug}`,
23
+ () => getSolutionDetail(route.params.slug, 'giai-phap'),
24
+ { server: true }
25
+ )
26
+
27
+ if (error.value || !data.value) {
28
+ throw createError({ statusCode: 404, statusMessage: 'Không tìm thấy giải pháp', fatal: true })
29
+ }
30
+
31
+ const targetCTA = computed(() => data.value?.productData?.[0]?.targetCTA || '')
32
+ const slug = computed(() => data.value?.productData?.[0]?.slug || String(route.params.slug))
33
+
34
+ useHead({
35
+ title: computed(() => data.value?.productData?.product?.title || 'Giới thiệu giải pháp Proxmox HCI'),
36
+ })
37
+ </script>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({
3
+ layout: 'default',
4
+ })
5
+
6
+ const route = useRoute()
7
+ const { getSolutionDetail } = useSolutionApi()
8
+
9
+ const { data, error } = await useAsyncData(
10
+ `solution-${route.params.slug}`,
11
+ () => getSolutionDetail(String(route.params.slug), 'giai-phap'),
12
+ { server: true }
13
+ )
14
+
15
+ if (error.value || !data.value) {
16
+ throw createError({ statusCode: 404, statusMessage: 'Không tìm thấy giải pháp', fatal: true })
17
+ }
18
+
19
+ const targetCTA = computed(() => data.value?.productData?.[0]?.targetCTA || '')
20
+ const slug = computed(() => data.value?.productData?.[0]?.slug || String(route.params.slug))
21
+
22
+ useHead({
23
+ title: computed(() => data.value?.productData?.product?.title || 'Giới thiệu sản phẩm Proxmox HCI'),
24
+ })
25
+
26
+ </script>
27
+
28
+ <template>
29
+ <div>
30
+ <main class="min-h-screen bg-white text-slate-900">
31
+ <TheHeader :target-c-t-a="targetCTA" :slug="slug" />
32
+ <ProxmoxHeroSection />
33
+ <ProxmoxVeFeaturesSection />
34
+ <ProxmoxHciOverviewSection />
35
+ <PricingPlansSection />
36
+ <ProxmoxTrainingBenefitsSection />
37
+ <ProxmoxComparisonSection />
38
+ <CoursesSection :plans="data?.resumeData" :target-c-t-a="targetCTA" :slug="slug" />
39
+ <ProxmoxTestimonialsSection />
40
+ <ProxmoxFaqSection />
41
+ <CtaSection v-if="targetCTA === 'CONSULTANT'" :plans="data?.resumeData" />
42
+ <TheFooter />
43
+ </main>
44
+ </div>
45
+ </template>
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'default' })
3
+
4
+ const route = useRoute()
5
+ const { getSolutionDetail } = useSolutionApi()
6
+
7
+ const slug = computed(() => route.params.slug as string)
8
+
9
+ // SSR data fetch
10
+ const { data, error } = await useAsyncData(
11
+ `dang-ky-${slug.value}`,
12
+ () => getSolutionDetail(slug.value, 'giai-phap'),
13
+ { server: true }
14
+ )
15
+
16
+ if (error.value || !data.value) {
17
+ throw createError({ statusCode: 404, statusMessage: 'Không tìm thấy khóa học', fatal: true })
18
+ }
19
+
20
+ // Format plans from resumeData
21
+ const formatPlans = (resumeData: any) => {
22
+ const fieldResumes = resumeData?.fieldResumes || []
23
+ return fieldResumes
24
+ ?.filter((group: any) => group?.type === 'FIELD_GROUP' && Array.isArray(group?.children))
25
+ ?.map((group: any) => {
26
+ const fields = Object.fromEntries(
27
+ group.children?.map((field: any) => [field?.fieldName?.split('@')?.pop(), field?.value]) || []
28
+ )
29
+ return {
30
+ id: fields.productid || group.fieldName,
31
+ name: fields.title || '',
32
+ level: fields.level || '',
33
+ duration: fields.time || '',
34
+ icon: fields.icon || 'lucide:server',
35
+ }
36
+ }) || []
37
+ }
38
+
39
+ const plans = computed(() => formatPlans(data.value?.resumeData))
40
+ const targetCTA = computed(() => data.value?.productData?.[0]?.targetCTA || '')
41
+ const selectedPlanId = computed(() => (route.query.plan as string) || '')
42
+
43
+ useHead({
44
+ title: computed(() => `Đăng ký khóa học - ${data.value?.productData?.[0]?.product?.title || 'Proxmox HCI'}`),
45
+ })
46
+ </script>
47
+
48
+ <template>
49
+ <div class="bg-ink text-cloud font-body antialiased">
50
+ <TheHeader :target-c-t-a="targetCTA" :slug="slug" />
51
+
52
+ <RegistrationSection :plans="plans" :selected-plan-id="selectedPlanId" />
53
+
54
+ <TheFooter />
55
+ </div>
56
+ </template>