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,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,131 @@
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
+ <div class="min-h-screen bg-[#f4f5f9] px-6 py-[7rem] font-sans text-slate-900">
53
+ <div class="mx-auto grid max-w-[1000px] grid-cols-1 items-start gap-6 md:grid-cols-[340px_1fr]">
54
+ <!-- Left column -->
55
+ <aside class="flex flex-col gap-4">
56
+ <div class="rounded-2xl border border-[#e7e9f0] bg-white p-7">
57
+ <h2 class="mb-5 text-[17px] font-bold text-slate-900">Vì sao chọn chúng tôi</h2>
58
+ <ul class="flex flex-col gap-[22px]">
59
+ <li class="flex items-start gap-3">
60
+ <span
61
+ class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
62
+ <svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor"
63
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
64
+ <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
65
+ <circle cx="9" cy="7" r="4" />
66
+ <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
67
+ <path d="M16 3.13a4 4 0 0 1 0 7.75" />
68
+ </svg>
69
+ </span>
70
+ <span class="flex flex-col gap-[3px]">
71
+ <span class="text-sm font-semibold text-slate-900">Lớp nhỏ, kèm sát</span>
72
+ <span class="text-[13px] leading-relaxed text-gray-500">Sĩ số giới hạn, giảng viên hỗ trợ trực tiếp
73
+ từng học viên.</span>
74
+ </span>
75
+ </li>
76
+ <li class="flex items-start gap-3">
77
+ <span
78
+ class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
79
+ <svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor"
80
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
81
+ <path d="M22 10 12 5 2 10l10 5 10-5Z" />
82
+ <path d="M6 12v5c0 1.66 2.69 3 6 3s6-1.34 6-3v-5" />
83
+ </svg>
84
+ </span>
85
+ <span class="flex flex-col gap-[3px]">
86
+ <span class="text-sm font-semibold text-slate-900">Thực hành hệ thống thật</span>
87
+ <span class="text-[13px] leading-relaxed text-gray-500">Thao tác trên cụm Proxmox HCI thực tế, không
88
+ chỉ lý thuyết.</span>
89
+ </span>
90
+ </li>
91
+ <li class="flex items-start gap-3">
92
+ <span
93
+ class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
94
+ <svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor"
95
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
96
+ <circle cx="12" cy="12" r="10" />
97
+ <path d="m9 12 2 2 4-4" />
98
+ </svg>
99
+ </span>
100
+ <span class="flex flex-col gap-[3px]">
101
+ <span class="text-sm font-semibold text-slate-900">Chứng nhận hoàn thành</span>
102
+ <span class="text-[13px] leading-relaxed text-gray-500">Cấp chứng nhận và hỗ trợ tư vấn nghề nghiệp
103
+ sau khóa học.</span>
104
+ </span>
105
+ </li>
106
+ </ul>
107
+ </div>
108
+
109
+ <div class="rounded-2xl border border-[#dbe4fd] bg-[#eef2ff] px-6 py-[22px]">
110
+ <p class="mb-2 flex items-center gap-[9px] text-sm font-bold text-[#2f5fe0]">
111
+ <svg class="h-[17px] w-[17px] flex-none" viewBox="0 0 24 24" fill="none" stroke="currentColor"
112
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
113
+ <path d="M23 7 16 12l7 5V7Z" />
114
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
115
+ </svg>
116
+ Khai giảng linh hoạt
117
+ </p>
118
+ <p class="text-[13px] leading-relaxed text-gray-600">
119
+ Lịch học buổi tối và cuối tuần, phù hợp cho cả người đi làm. Hỗ trợ ghi hình buổi học để xem lại.
120
+ </p>
121
+ </div>
122
+ </aside>
123
+
124
+ <!-- Right column: form -->
125
+ <RegistrationForm :plans="plans" :selected-plan-id="selectedPlanId" />
126
+ </div>
127
+ </div>
128
+
129
+ <TheFooter />
130
+ </div>
131
+ </template>