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,136 @@
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">Quên mật khẩu</h1>
9
+ <p class="text-slate-500 text-sm">Nhập SĐT hoặc tên tài khoản để nhận mã xác thực đặt lại mật khẩu.</p>
10
+ </div>
11
+
12
+ <!-- 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
+ <div>
16
+ <label for="userLoginId" class="block text-sm font-semibold text-slate-700 mb-1.5">
17
+ Tên tài khoản / SĐT
18
+ </label>
19
+ <div class="relative">
20
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
21
+ <Icon name="lucide:user" class="text-[17px]" />
22
+ </span>
23
+ <input
24
+ id="userLoginId"
25
+ v-model="userLoginId"
26
+ type="text"
27
+ autocomplete="username"
28
+ placeholder="Nhập SĐT hoặc tên tài khoản"
29
+ :class="[
30
+ 'w-full pl-10 pr-4 py-3 rounded-[10px] border text-sm outline-none transition-all',
31
+ error
32
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
33
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
34
+ ]"
35
+ />
36
+ </div>
37
+ <p v-if="error" class="mt-1 text-xs text-red-500 flex items-center gap-1">
38
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ error }}
39
+ </p>
40
+ </div>
41
+
42
+ <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">
43
+ <Icon name="lucide:alert-triangle" class="text-[16px] mt-0.5 shrink-0" />
44
+ <span>{{ apiError }}</span>
45
+ </div>
46
+
47
+ <button
48
+ type="submit"
49
+ :disabled="loading"
50
+ 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"
51
+ >
52
+ <span v-if="!loading">Tiếp theo</span>
53
+ <span v-else class="flex items-center gap-2">
54
+ <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
55
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
56
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
57
+ </svg>
58
+ Đang kiểm tra...
59
+ </span>
60
+ </button>
61
+ </form>
62
+ </div>
63
+
64
+ <div class="text-center text-sm text-slate-500 mt-6">
65
+ <NuxtLink :to="{ path: '/dang-nhap', query: { redirectUrl } }" class="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-[#004AC6] transition-colors">
66
+ <Icon name="lucide:arrow-left" class="text-[15px]" />
67
+ Quay lại đăng nhập
68
+ </NuxtLink>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ <TheFooter/>
73
+ </div>
74
+ </template>
75
+
76
+ <script setup lang="ts">
77
+ definePageMeta({ layout: 'default' })
78
+
79
+ useHead({ title: 'Quên mật khẩu — Long Vân' })
80
+
81
+ const { getUserLoginByUserLoginId, getPhoneByPartyId, createResetKey } = useAuthApi()
82
+ const route = useRoute()
83
+ const router = useRouter()
84
+
85
+ const redirectUrl = computed(() => route.query.redirectUrl as string || '/')
86
+ const userLoginId = ref('')
87
+ const error = ref('')
88
+ const apiError = ref('')
89
+ const loading = ref(false)
90
+
91
+ async function onSubmit() {
92
+ error.value = ''
93
+ apiError.value = ''
94
+
95
+ if (!userLoginId.value.trim()) {
96
+ error.value = 'Vui lòng nhập tên tài khoản hoặc SĐT'
97
+ return
98
+ }
99
+
100
+ loading.value = true
101
+
102
+ try {
103
+ const userRes = await getUserLoginByUserLoginId(userLoginId.value.trim())
104
+ if (!userRes?.userLoginId) {
105
+ apiError.value = 'Tài khoản không tồn tại trong hệ thống.'
106
+ return
107
+ }
108
+
109
+ const loginId = userRes.userLoginId
110
+ const partyId = userRes.partyId
111
+
112
+ // Get phone number for OTP
113
+ let phone = loginId
114
+ if (partyId) {
115
+ try {
116
+ const phoneRes = await getPhoneByPartyId(partyId)
117
+ if (phoneRes) {
118
+ phone = phoneRes
119
+ }
120
+ } catch {
121
+ // Use loginId as fallback
122
+ }
123
+ }
124
+
125
+ // Create reset key
126
+ await createResetKey(loginId)
127
+
128
+ // Redirect to OTP verification with userLoginId to indicate forgot password flow
129
+ router.push({ path: '/xac-thuc', query: { userId: phone, userLoginId: loginId, redirectUrl: redirectUrl.value } })
130
+ } catch (err: any) {
131
+ apiError.value = err?.message || 'Đã có lỗi xảy ra. Vui lòng thử lại.'
132
+ } finally {
133
+ loading.value = false
134
+ }
135
+ }
136
+ </script>
@@ -0,0 +1,320 @@
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">Xác thực OTP</h1>
9
+ <p v-if="otpSent" class="text-slate-500 text-sm">
10
+ Nhập mã 6 chữ số đã gửi qua
11
+ <strong class="text-[#004AC6]">{{ selectedMethod === 'zalo' ? 'Zalo' : 'SMS' }}</strong>
12
+ đến <strong class="text-slate-700">{{ phone }}</strong>
13
+ </p>
14
+ <p v-else class="text-slate-500 text-sm">
15
+ Chọn phương thức nhận mã xác thực đến <strong class="text-slate-700">{{ phone }}</strong>
16
+ </p>
17
+ </div>
18
+
19
+ <!-- Card -->
20
+ <div class="bg-white rounded-2xl shadow-sm border border-slate-200/80 p-8">
21
+ <div class="space-y-6">
22
+ <!-- Icon -->
23
+ <div class="text-center">
24
+ <div class="w-14 h-14 rounded-[10px] bg-blue-50 flex items-center justify-center mx-auto mb-3">
25
+ <Icon name="lucide:message-square" class="text-[#004AC6] text-[24px]" />
26
+ </div>
27
+ </div>
28
+
29
+ <!-- OTP inputs -->
30
+ <div class="flex gap-3 justify-center">
31
+ <input
32
+ v-for="(_, i) in otpDigits"
33
+ :key="i"
34
+ :ref="(el) => setOtpRef(el, i)"
35
+ v-model="otpDigits[i]"
36
+ type="text"
37
+ inputmode="numeric"
38
+ maxlength="1"
39
+ @input="onOtpInput(i)"
40
+ @keydown="onOtpKeydown($event, i)"
41
+ @paste.prevent="onOtpPaste($event)"
42
+ :class="[
43
+ 'w-12 h-14 text-center text-xl font-bold rounded-xl border-2 outline-none transition-all',
44
+ otpDigits[i]
45
+ ? 'border-[#004AC6] bg-blue-50 text-[#004AC6]'
46
+ : 'border-slate-200 bg-slate-50 text-slate-900 focus:border-[#004AC6] focus:bg-white focus:ring-2 focus:ring-blue-100'
47
+ ]"
48
+ />
49
+ </div>
50
+
51
+ <!-- Countdown / Resend -->
52
+ <div class="text-center text-sm space-y-2">
53
+ <span v-if="countdown > 0" class="text-slate-400">
54
+ Gửi lại sau <strong class="text-slate-700">{{ countdown }}s</strong>
55
+ </span>
56
+ <div v-else class="flex flex-col items-center gap-2">
57
+ <button
58
+ @click="resendOtp"
59
+ :disabled="loading"
60
+ class="text-[#004AC6] font-semibold hover:underline disabled:opacity-50"
61
+ >
62
+ Gửi lại mã OTP
63
+ </button>
64
+ <button
65
+ @click="showMethodDialog = true"
66
+ class="text-slate-500 hover:text-[#004AC6] transition-colors"
67
+ >
68
+ Chọn phương thức xác thực khác
69
+ </button>
70
+ </div>
71
+ </div>
72
+
73
+ <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">
74
+ <Icon name="lucide:alert-triangle" class="text-[16px] mt-0.5 shrink-0" />
75
+ <span>{{ apiError }}</span>
76
+ </div>
77
+
78
+ <button
79
+ @click="onVerify"
80
+ :disabled="loading || otpCode.length < 6"
81
+ 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"
82
+ >
83
+ <span v-if="!loading">Xác nhận</span>
84
+ <span v-else class="flex items-center gap-2">
85
+ <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
86
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
87
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
88
+ </svg>
89
+ Đang xác thực...
90
+ </span>
91
+ </button>
92
+
93
+ <NuxtLink to="/quen-mat-khau" class="block text-center text-sm text-slate-500 hover:text-[#004AC6]">
94
+ ← Quay lại
95
+ </NuxtLink>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Method selection dialog -->
101
+ <Teleport to="body">
102
+ <Transition
103
+ enter-active-class="transition ease-out duration-200"
104
+ enter-from-class="opacity-0"
105
+ enter-to-class="opacity-100"
106
+ leave-active-class="transition ease-in duration-150"
107
+ leave-from-class="opacity-100"
108
+ leave-to-class="opacity-0"
109
+ >
110
+ <div v-if="showMethodDialog" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
111
+ <div class="absolute inset-0 bg-black/40" @click="showMethodDialog = false" />
112
+ <div class="relative bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-5">
113
+ <h3 class="font-['Montserrat'] font-bold text-lg text-slate-900 text-center">Chọn phương thức nhận OTP</h3>
114
+
115
+ <div class="space-y-3">
116
+ <button
117
+ @click="pendingMethod = 'zalo'"
118
+ :class="[
119
+ 'w-full flex items-center gap-3 p-4 rounded-xl border-2 transition-all',
120
+ pendingMethod === 'zalo'
121
+ ? 'border-[#004AC6] bg-blue-50'
122
+ : 'border-slate-200 hover:border-slate-300'
123
+ ]"
124
+ >
125
+ <span class="w-10 h-10 rounded-[10px] bg-blue-100 flex items-center justify-center">
126
+ <Icon name="lucide:message-circle" class="text-[#004AC6] text-[20px]" />
127
+ </span>
128
+ <div class="text-left">
129
+ <p class="font-semibold text-sm text-slate-900">Tin nhắn Zalo</p>
130
+ <p class="text-xs text-slate-500">Nhận mã qua Zalo</p>
131
+ </div>
132
+ <Icon
133
+ v-if="pendingMethod === 'zalo'"
134
+ name="lucide:check-circle"
135
+ class="text-[#004AC6] text-[20px] ml-auto"
136
+ />
137
+ </button>
138
+
139
+ <button
140
+ @click="pendingMethod = 'sms'"
141
+ :class="[
142
+ 'w-full flex items-center gap-3 p-4 rounded-xl border-2 transition-all',
143
+ pendingMethod === 'sms'
144
+ ? 'border-[#004AC6] bg-blue-50'
145
+ : 'border-slate-200 hover:border-slate-300'
146
+ ]"
147
+ >
148
+ <span class="w-10 h-10 rounded-[10px] bg-emerald-100 flex items-center justify-center">
149
+ <Icon name="lucide:message-square" class="text-emerald-600 text-[20px]" />
150
+ </span>
151
+ <div class="text-left">
152
+ <p class="font-semibold text-sm text-slate-900">Tin nhắn SMS</p>
153
+ <p class="text-xs text-slate-500">Nhận mã qua SMS</p>
154
+ </div>
155
+ <Icon
156
+ v-if="pendingMethod === 'sms'"
157
+ name="lucide:check-circle"
158
+ class="text-[#004AC6] text-[20px] ml-auto"
159
+ />
160
+ </button>
161
+ </div>
162
+
163
+ <div class="flex gap-3">
164
+ <button
165
+ @click="showMethodDialog = false"
166
+ class="flex-1 py-3 rounded-[10px] border border-slate-200 text-slate-600 font-semibold text-sm hover:bg-slate-50 transition-colors"
167
+ >
168
+ Hủy
169
+ </button>
170
+ <button
171
+ @click="confirmMethodSelection"
172
+ :disabled="loading"
173
+ class="flex-1 py-3 rounded-[10px] bg-[#004AC6] text-white font-bold text-sm hover:bg-[#1D4ED8] transition-colors disabled:opacity-60"
174
+ >
175
+ Xác nhận
176
+ </button>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </Transition>
181
+ </Teleport>
182
+ </div>
183
+ <TheFooter/>
184
+ </div>
185
+ </template>
186
+
187
+ <script setup lang="ts">
188
+ definePageMeta({ layout: 'default' })
189
+
190
+ useHead({ title: 'Xác thực OTP — Long Vân' })
191
+
192
+ const { sendOTP, validateOTP, getAccessTokenByOTP } = useAuthApi()
193
+ const route = useRoute()
194
+ const router = useRouter()
195
+
196
+ const phone = computed(() => route.query.userId as string || '')
197
+ const userLoginId = computed(() => route.query.userLoginId as string || '')
198
+ const redirectUrl = computed(() => route.query.redirectUrl as string || '/')
199
+
200
+ // Method selection
201
+ const selectedMethod = ref<'sms' | 'zalo'>('zalo')
202
+ const pendingMethod = ref<'sms' | 'zalo'>('zalo')
203
+ const showMethodDialog = ref(false)
204
+
205
+ const otpDigits = ref(['', '', '', '', '', ''])
206
+ const otpRefs: (HTMLInputElement | null)[] = []
207
+ const otpCode = computed(() => otpDigits.value.join(''))
208
+
209
+ const loading = ref(false)
210
+ const apiError = ref('')
211
+ const countdown = ref(0)
212
+ const otpSent = ref(false)
213
+
214
+ let countdownTimer: ReturnType<typeof setInterval> | null = null
215
+
216
+ function setOtpRef(el: any, i: number) {
217
+ otpRefs[i] = el as HTMLInputElement
218
+ }
219
+
220
+ function onOtpInput(i: number) {
221
+ const val = otpDigits.value[i]
222
+ if (val && i < 5) nextTick(() => otpRefs[i + 1]?.focus())
223
+ }
224
+
225
+ function onOtpKeydown(e: KeyboardEvent, i: number) {
226
+ if (e.key === 'Backspace' && !otpDigits.value[i] && i > 0) {
227
+ otpRefs[i - 1]?.focus()
228
+ }
229
+ }
230
+
231
+ function onOtpPaste(e: ClipboardEvent) {
232
+ const text = e.clipboardData?.getData('text') || ''
233
+ const digits = text.replace(/\D/g, '').slice(0, 6)
234
+ digits.split('').forEach((d, i) => { otpDigits.value[i] = d })
235
+ nextTick(() => otpRefs[Math.min(digits.length, 5)]?.focus())
236
+ }
237
+
238
+ function startCountdown(seconds = 60) {
239
+ countdown.value = seconds
240
+ if (countdownTimer) clearInterval(countdownTimer)
241
+ countdownTimer = setInterval(() => {
242
+ countdown.value--
243
+ if (countdown.value <= 0 && countdownTimer) clearInterval(countdownTimer)
244
+ }, 1000)
245
+ }
246
+
247
+ async function sendOtpCode(method: 'sms' | 'zalo') {
248
+ if (!phone.value) return
249
+ loading.value = true
250
+ apiError.value = ''
251
+
252
+ try {
253
+ await sendOTP(phone.value, method)
254
+ selectedMethod.value = method
255
+ otpSent.value = true
256
+ startCountdown(60)
257
+ nextTick(() => otpRefs[0]?.focus())
258
+ } catch (err: any) {
259
+ if (method === 'zalo') {
260
+ apiError.value = 'Gửi qua Zalo thất bại. Bạn có thể thử gửi qua SMS.'
261
+ pendingMethod.value = 'sms'
262
+ showMethodDialog.value = true
263
+ } else {
264
+ apiError.value = err?.message || 'Không thể gửi OTP.'
265
+ }
266
+ } finally {
267
+ loading.value = false
268
+ }
269
+ }
270
+
271
+ async function confirmMethodSelection() {
272
+ showMethodDialog.value = false
273
+ await sendOtpCode(pendingMethod.value)
274
+ }
275
+
276
+ async function resendOtp() {
277
+ // Show method selection dialog again
278
+ pendingMethod.value = selectedMethod.value
279
+ showMethodDialog.value = true
280
+ }
281
+
282
+ async function onVerify() {
283
+ if (otpCode.value.length < 6) return
284
+ loading.value = true
285
+ apiError.value = ''
286
+
287
+ try {
288
+ const res = await validateOTP(otpCode.value, phone.value, selectedMethod.value)
289
+ if (!res) {
290
+ apiError.value = 'Mã OTP không đúng hoặc đã hết hạn.'
291
+ return
292
+ }
293
+
294
+ // Get access token for password reset
295
+ const tokenRes = await getAccessTokenByOTP(otpCode.value, phone.value, selectedMethod.value)
296
+
297
+ // Redirect to doi-mat-khau with token
298
+ router.push({
299
+ path: '/doi-mat-khau',
300
+ query: { phone: phone.value, token: tokenRes, redirectUrl: redirectUrl.value }
301
+ })
302
+ } catch (err: any) {
303
+ apiError.value = err?.message || 'Xác thực thất bại. Vui lòng thử lại.'
304
+ } finally {
305
+ loading.value = false
306
+ }
307
+ }
308
+
309
+ onMounted(() => {
310
+ if (phone.value) {
311
+ // Show method selection dialog first
312
+ pendingMethod.value = 'zalo'
313
+ showMethodDialog.value = true
314
+ }
315
+ })
316
+
317
+ onUnmounted(() => {
318
+ if (countdownTimer) clearInterval(countdownTimer)
319
+ })
320
+ </script>
@@ -0,0 +1,56 @@
1
+ // Registers `v-reveal` — a lightweight scroll-reveal directive.
2
+ //
3
+ // Usage:
4
+ // <div v-reveal>...</div> fade + slide up
5
+ // <div v-reveal="'left'">...</div> fade + slide from left
6
+ // <div v-reveal="{ type: 'scale', delay: 120 }">...</div> with stagger delay (ms)
7
+
8
+ import { defineNuxtPlugin } from '#app'
9
+
10
+ type RevealBinding =
11
+ | string
12
+ | {
13
+ type?: 'up' | 'left' | 'right' | 'scale' | 'fade'
14
+ delay?: number
15
+ }
16
+
17
+ // IntersectionObserver only exists on the client
18
+ let revealObserver: IntersectionObserver | null = null
19
+
20
+ if (typeof window !== 'undefined') {
21
+ revealObserver = new IntersectionObserver(
22
+ (entries) => {
23
+ for (const entry of entries) {
24
+ if (entry.isIntersecting) {
25
+ entry.target.classList.add('reveal-visible')
26
+ revealObserver?.unobserve(entry.target)
27
+ }
28
+ }
29
+ },
30
+ { threshold: 0.15, rootMargin: '0px 0px -8% 0px' }
31
+ )
32
+ }
33
+
34
+ export default defineNuxtPlugin((nuxtApp) => {
35
+ nuxtApp.vueApp.directive<HTMLElement, RevealBinding>('reveal', {
36
+ // SSR-safe: return empty props so Vue doesn't crash during server render
37
+ getSSRProps() {
38
+ return {}
39
+ },
40
+ mounted(el, binding) {
41
+ const value = binding.value
42
+ const type = typeof value === 'string' ? value : value?.type ?? 'up'
43
+ const delay = typeof value === 'object' ? value?.delay ?? 0 : 0
44
+
45
+ el.setAttribute('data-reveal', type)
46
+ if (delay) {
47
+ el.style.transitionDelay = `${delay}ms`
48
+ }
49
+
50
+ revealObserver?.observe(el)
51
+ },
52
+ unmounted(el) {
53
+ revealObserver?.unobserve(el)
54
+ },
55
+ })
56
+ })
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <div class="min-h-screen flex">
3
+ <!-- Left: Branding Panel (hidden on mobile) -->
4
+ <div
5
+ class="hidden lg:flex lg:w-[45%] xl:w-[40%] bg-gradient-to-br from-[#004AC6] to-[#1D4ED8] flex-col justify-between p-12 relative overflow-hidden"
6
+ >
7
+ <!-- Background decorations -->
8
+ <div class="absolute -top-20 -left-20 w-72 h-72 bg-blue-400/20 blur-3xl rounded-[10px] pointer-events-none" />
9
+ <div class="absolute -bottom-20 -right-10 w-80 h-80 bg-blue-900/30 blur-3xl rounded-[10px] pointer-events-none" />
10
+
11
+ <!-- Logo -->
12
+ <NuxtLink to="/" class="flex items-center gap-3 relative z-10">
13
+ <span
14
+ class="w-11 h-11 rounded-xl bg-white/20 backdrop-blur-sm flex items-center justify-center shadow-lg border border-white/30"
15
+ >
16
+ <Icon name="lucide:network" class="text-white text-[22px]" />
17
+ </span>
18
+ <span class="font-['Montserrat'] font-extrabold text-xl text-white tracking-tight">HyperCore</span>
19
+ </NuxtLink>
20
+
21
+ <!-- Center content -->
22
+ <div class="relative z-10 space-y-8">
23
+ <div>
24
+ <h2 class="font-['Montserrat'] font-extrabold text-3xl xl:text-4xl text-white leading-tight mb-4">
25
+ Nền tảng đào tạo<br />hạ tầng doanh nghiệp
26
+ </h2>
27
+ <p class="text-blue-100/80 text-base leading-relaxed max-w-sm">
28
+ Học Proxmox, Ceph, DevOps cùng đội ngũ kỹ sư hàng đầu. Lộ trình rõ ràng, thực hành thực chiến.
29
+ </p>
30
+ </div>
31
+
32
+ <!-- Stats -->
33
+ <div class="grid grid-cols-2 gap-4">
34
+ <div class="bg-white/10 backdrop-blur-sm rounded-xl p-4 border border-white/20">
35
+ <div class="font-['Montserrat'] font-extrabold text-2xl text-white">1.200+</div>
36
+ <div class="text-blue-100/70 text-sm mt-0.5">Học viên</div>
37
+ </div>
38
+ <div class="bg-white/10 backdrop-blur-sm rounded-xl p-4 border border-white/20">
39
+ <div class="font-['Montserrat'] font-extrabold text-2xl text-white">96%</div>
40
+ <div class="text-blue-100/70 text-sm mt-0.5">Hài lòng</div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Footer text -->
46
+ <p class="text-blue-200/50 text-xs relative z-10">
47
+ © 2024 HyperCore. Tất cả quyền được bảo lưu.
48
+ </p>
49
+ </div>
50
+
51
+ <!-- Right: Content (form area) -->
52
+ <div class="flex-1 flex flex-col min-h-screen bg-[#F7F9FB]">
53
+ <!-- Mobile logo -->
54
+ <div class="lg:hidden px-6 pt-6 pb-2">
55
+ <NuxtLink to="/" class="flex items-center gap-2.5">
56
+ <span class="w-9 h-9 rounded-lg bg-gradient-to-br from-[#2563EB] to-[#1D4ED8] flex items-center justify-center shadow-sm">
57
+ <Icon name="lucide:network" class="text-white text-[18px]" />
58
+ </span>
59
+ <span class="font-['Montserrat'] font-extrabold text-lg text-slate-900">HyperCore</span>
60
+ </NuxtLink>
61
+ </div>
62
+
63
+ <!-- Page content -->
64
+ <div class="flex-1 flex items-center justify-center px-6 py-8 sm:py-12">
65
+ <div class="w-full max-w-md">
66
+ <slot />
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </template>
@@ -0,0 +1,7 @@
1
+ import type { ClassValue } from "clsx"
2
+ import { clsx } from "clsx"
3
+ import { twMerge } from "tailwind-merge"
4
+
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs))
7
+ }
File without changes
@@ -0,0 +1,60 @@
1
+ import { defineNuxtModule } from "@nuxt/kit"
2
+
3
+ export default defineNuxtModule({
4
+ meta: {
5
+ name: "layer-route-priority",
6
+ },
7
+
8
+ setup(_, nuxt) {
9
+
10
+ nuxt.hook("pages:extend", (pages) => {
11
+
12
+ const layerRoutes = pages.filter(page =>
13
+ page.file?.includes("/layers/")
14
+ )
15
+
16
+ const sourceRoutes = pages.filter(page =>
17
+ !page.file?.includes("/layers/")
18
+ )
19
+
20
+
21
+ for (const layerRoute of layerRoutes) {
22
+
23
+ for (const sourceRoute of sourceRoutes) {
24
+
25
+
26
+ // layer:
27
+ // /giai-phap/:slug
28
+ //
29
+ // source:
30
+ // /:slug/:slug2
31
+
32
+
33
+ const layerPath = layerRoute.path
34
+ const sourcePath = sourceRoute.path
35
+
36
+
37
+ if (
38
+ layerPath.split("/").length === sourcePath.split("/").length &&
39
+ sourcePath.includes(":")
40
+ ) {
41
+
42
+ const index = pages.indexOf(sourceRoute)
43
+
44
+
45
+ if(index !== -1){
46
+
47
+ pages.splice(index, 1)
48
+
49
+ }
50
+
51
+ }
52
+
53
+ }
54
+
55
+ }
56
+
57
+ })
58
+
59
+ },
60
+ })
@@ -0,0 +1,9 @@
1
+ <script setup>
2
+ // import GiaiPhapPage from '../layers/solution/app/pages/giai-phap-proxmox/[slug]/index.vue'
3
+ import GiaiPhapPage from '../layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue'
4
+ </script>
5
+
6
+ <template>
7
+ <GiaiPhapPage />
8
+ <!-- <div>d2</div> -->
9
+ </template>
@@ -0,0 +1,8 @@
1
+ <script setup>
2
+ // import SanPhamProxmoxPage from '../layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <!-- <SanPhamProxmoxPage /> -->
7
+ <div>d</div>
8
+ </template>
File without changes