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,627 @@
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">Tạo tài khoản</h1>
9
+ <p class="text-slate-500 text-sm">Đăng ký để bắt đầu hành trình học tập của bạn.</p>
10
+ </div>
11
+
12
+ <!-- Step indicator -->
13
+ <div class="flex items-center gap-2 mb-8">
14
+ <template v-for="(label, i) in steps" :key="i">
15
+ <div class="flex items-center gap-1.5">
16
+ <div
17
+ :class="[
18
+ 'w-7 h-7 rounded-[10px] flex items-center justify-center text-xs font-bold transition-all',
19
+ step > i + 1
20
+ ? 'bg-emerald-500 text-white'
21
+ : step === i + 1
22
+ ? 'bg-[#004AC6] text-white ring-4 ring-blue-100'
23
+ : 'bg-slate-200 text-slate-500'
24
+ ]"
25
+ >
26
+ <Icon v-if="step > i + 1" name="lucide:check" class="text-[13px]" />
27
+ <span v-else>{{ i + 1 }}</span>
28
+ </div>
29
+ <span :class="['text-xs font-medium hidden sm:block', step === i + 1 ? 'text-[#004AC6]' : 'text-slate-400']">
30
+ {{ label }}
31
+ </span>
32
+ </div>
33
+ <div v-if="i < steps.length - 1" class="flex-1 h-px bg-slate-200 mx-1" />
34
+ </template>
35
+ </div>
36
+
37
+ <!-- Card -->
38
+ <div class="bg-white rounded-2xl shadow-sm border border-slate-200/80 p-8">
39
+
40
+ <!-- Step 1: Enter info -->
41
+ <form v-if="step === 1" @submit.prevent="onSubmit" class="space-y-5">
42
+ <!-- Full name -->
43
+ <div>
44
+ <label for="fullName" class="block text-sm font-semibold text-slate-700 mb-1.5">Họ và tên</label>
45
+ <div class="relative">
46
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
47
+ <Icon name="lucide:user" class="text-[17px]" />
48
+ </span>
49
+ <input
50
+ id="fullName"
51
+ v-model="form.fullName"
52
+ type="text"
53
+ autocomplete="name"
54
+ placeholder="Nhập họ và tên"
55
+ :class="[
56
+ 'w-full pl-10 pr-4 py-3 rounded-[10px] border text-sm outline-none transition-all',
57
+ errors.fullName
58
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
59
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
60
+ ]"
61
+ />
62
+ </div>
63
+ <p v-if="errors.fullName" class="mt-1 text-xs text-red-500 flex items-center gap-1">
64
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.fullName }}
65
+ </p>
66
+ </div>
67
+
68
+ <!-- Phone -->
69
+ <div>
70
+ <label for="phone" class="block text-sm font-semibold text-slate-700 mb-1.5">Số điện thoại</label>
71
+ <div class="relative">
72
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
73
+ <Icon name="lucide:phone" class="text-[17px]" />
74
+ </span>
75
+ <input
76
+ id="phone"
77
+ v-model="form.phone"
78
+ type="tel"
79
+ autocomplete="tel"
80
+ placeholder="VD: 0901234567"
81
+ :class="[
82
+ 'w-full pl-10 pr-4 py-3 rounded-[10px] border text-sm outline-none transition-all',
83
+ errors.phone
84
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
85
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
86
+ ]"
87
+ />
88
+ </div>
89
+ <p v-if="errors.phone" class="mt-1 text-xs text-red-500 flex items-center gap-1">
90
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.phone }}
91
+ </p>
92
+ </div>
93
+
94
+ <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">
95
+ <Icon name="lucide:alert-triangle" class="text-[16px] mt-0.5 shrink-0" />
96
+ <span>{{ apiError }}</span>
97
+ </div>
98
+
99
+ <button
100
+ type="submit"
101
+ :disabled="loading"
102
+ 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"
103
+ >
104
+ <span v-if="!loading">Tiếp theo</span>
105
+ <span v-else class="flex items-center gap-2">
106
+ <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
107
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
108
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
109
+ </svg>
110
+ Đang kiểm tra...
111
+ </span>
112
+ </button>
113
+ </form>
114
+
115
+ <!-- Step 2: OTP Verification -->
116
+ <div v-else-if="step === 2" class="space-y-5">
117
+ <div class="text-center mb-2">
118
+ <div class="w-14 h-14 rounded-[10px] bg-blue-50 flex items-center justify-center mx-auto mb-3">
119
+ <Icon name="lucide:message-square" class="text-[#004AC6] text-[24px]" />
120
+ </div>
121
+ <p class="text-sm text-slate-600">
122
+ Mã OTP đã được gửi qua <strong class="text-[#004AC6]">{{ selectedMethod === 'zalo' ? 'Zalo' : 'SMS' }}</strong> đến <strong class="text-slate-900">{{ form.phone }}</strong>
123
+ </p>
124
+ </div>
125
+
126
+ <!-- OTP inputs -->
127
+ <div class="flex gap-3 justify-center">
128
+ <input
129
+ v-for="(_, i) in otpDigits"
130
+ :key="i"
131
+ :ref="(el) => setOtpRef(el, i)"
132
+ v-model="otpDigits[i]"
133
+ type="text"
134
+ inputmode="numeric"
135
+ maxlength="1"
136
+ @input="onOtpInput(i)"
137
+ @keydown="onOtpKeydown($event, i)"
138
+ @paste.prevent="onOtpPaste($event)"
139
+ :class="[
140
+ 'w-12 h-14 text-center text-xl font-bold rounded-xl border-2 outline-none transition-all',
141
+ otpDigits[i]
142
+ ? 'border-[#004AC6] bg-blue-50 text-[#004AC6]'
143
+ : 'border-slate-200 bg-slate-50 text-slate-900 focus:border-[#004AC6] focus:bg-white focus:ring-2 focus:ring-blue-100'
144
+ ]"
145
+ />
146
+ </div>
147
+
148
+ <!-- Resend & method selection -->
149
+ <div class="text-center text-sm space-y-2">
150
+ <span v-if="countdown > 0" class="text-slate-400">
151
+ Gửi lại mã sau <strong class="text-slate-600">{{ countdown }}s</strong>
152
+ </span>
153
+ <div v-else class="flex flex-col items-center gap-2">
154
+ <button
155
+ @click="resendOtp"
156
+ :disabled="loading"
157
+ class="text-[#004AC6] font-semibold hover:underline disabled:opacity-50"
158
+ >
159
+ Gửi lại mã OTP
160
+ </button>
161
+ <button
162
+ @click="showMethodDialog = true"
163
+ class="text-slate-500 hover:text-[#004AC6] transition-colors"
164
+ >
165
+ Chọn phương thức xác thực khác
166
+ </button>
167
+ </div>
168
+ </div>
169
+
170
+ <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">
171
+ <Icon name="lucide:alert-triangle" class="text-[16px] mt-0.5 shrink-0" />
172
+ <span>{{ apiError }}</span>
173
+ </div>
174
+
175
+ <button
176
+ @click="onVerifyOtp"
177
+ :disabled="loading || otpCode.length < 6"
178
+ 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"
179
+ >
180
+ <span v-if="!loading">Xác thực OTP</span>
181
+ <span v-else class="flex items-center gap-2">
182
+ <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
183
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
184
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
185
+ </svg>
186
+ Đang xác thực...
187
+ </span>
188
+ </button>
189
+
190
+ <button @click="step = 1" class="w-full text-center text-sm text-slate-500 hover:text-slate-700">
191
+ ← Quay lại
192
+ </button>
193
+ </div>
194
+
195
+ <!-- Step 3: Update info -->
196
+ <form v-else-if="step === 3" @submit.prevent="onUpdateInfo" class="space-y-5">
197
+ <div class="text-center mb-2">
198
+ <div class="w-14 h-14 rounded-[10px] bg-emerald-50 flex items-center justify-center mx-auto mb-3">
199
+ <Icon name="lucide:user-check" class="text-emerald-500 text-[24px]" />
200
+ </div>
201
+ <p class="text-sm text-slate-600">Hoàn tất thông tin và đặt mật khẩu</p>
202
+ </div>
203
+
204
+ <!-- Email -->
205
+ <div>
206
+ <label for="regEmail" class="block text-sm font-semibold text-slate-700 mb-1.5">Email <span class="text-slate-400 font-normal">(tùy chọn)</span></label>
207
+ <div class="relative">
208
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
209
+ <Icon name="lucide:mail" class="text-[17px]" />
210
+ </span>
211
+ <input
212
+ id="regEmail"
213
+ v-model="profileForm.email"
214
+ type="email"
215
+ autocomplete="email"
216
+ placeholder="email@example.com"
217
+ class="w-full pl-10 pr-4 py-3 rounded-[10px] border border-slate-200 bg-slate-50 text-sm outline-none focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white transition-all"
218
+ />
219
+ </div>
220
+ </div>
221
+
222
+ <!-- Password -->
223
+ <div>
224
+ <label for="regPassword" class="block text-sm font-semibold text-slate-700 mb-1.5">Mật khẩu</label>
225
+ <div class="relative">
226
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
227
+ <Icon name="lucide:lock" class="text-[17px]" />
228
+ </span>
229
+ <input
230
+ id="regPassword"
231
+ v-model="profileForm.password"
232
+ :type="showPassword ? 'text' : 'password'"
233
+ autocomplete="new-password"
234
+ placeholder="Tối thiểu 6 ký tự"
235
+ :class="[
236
+ 'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
237
+ profileErrors.password
238
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
239
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
240
+ ]"
241
+ />
242
+ <button type="button" @click="showPassword = !showPassword" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors">
243
+ <Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
244
+ </button>
245
+ </div>
246
+ <p v-if="profileErrors.password" class="mt-1 text-xs text-red-500 flex items-center gap-1">
247
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ profileErrors.password }}
248
+ </p>
249
+ </div>
250
+
251
+ <!-- Confirm password -->
252
+ <div>
253
+ <label for="regConfirmPassword" class="block text-sm font-semibold text-slate-700 mb-1.5">Xác nhận mật khẩu</label>
254
+ <div class="relative">
255
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
256
+ <Icon name="lucide:lock" class="text-[17px]" />
257
+ </span>
258
+ <input
259
+ id="regConfirmPassword"
260
+ v-model="profileForm.confirmPassword"
261
+ :type="showConfirm ? 'text' : 'password'"
262
+ autocomplete="new-password"
263
+ placeholder="Nhập lại mật khẩu"
264
+ :class="[
265
+ 'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
266
+ profileErrors.confirmPassword
267
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
268
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
269
+ ]"
270
+ />
271
+ <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">
272
+ <Icon :name="showConfirm ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
273
+ </button>
274
+ </div>
275
+ <p v-if="profileErrors.confirmPassword" class="mt-1 text-xs text-red-500 flex items-center gap-1">
276
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ profileErrors.confirmPassword }}
277
+ </p>
278
+ </div>
279
+
280
+ <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">
281
+ <Icon name="lucide:alert-triangle" class="text-[16px] mt-0.5 shrink-0" />
282
+ <span>{{ apiError }}</span>
283
+ </div>
284
+
285
+ <button
286
+ type="submit"
287
+ :disabled="loading"
288
+ 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"
289
+ >
290
+ <span v-if="!loading">Hoàn tất đăng ký</span>
291
+ <span v-else class="flex items-center gap-2">
292
+ <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
293
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
294
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
295
+ </svg>
296
+ Đang lưu...
297
+ </span>
298
+ </button>
299
+ </form>
300
+ </div>
301
+
302
+ <!-- Login link -->
303
+ <p class="text-center text-sm text-slate-500 mt-6">
304
+ Đã có tài khoản?
305
+ <NuxtLink :to="{ path: '/dang-nhap', query: { redirectUrl } }" class="text-[#004AC6] font-semibold hover:underline ml-1">
306
+ Đăng nhập
307
+ </NuxtLink>
308
+ </p>
309
+ </div>
310
+
311
+ <!-- Method selection dialog -->
312
+ <Teleport to="body">
313
+ <Transition
314
+ enter-active-class="transition ease-out duration-200"
315
+ enter-from-class="opacity-0"
316
+ enter-to-class="opacity-100"
317
+ leave-active-class="transition ease-in duration-150"
318
+ leave-from-class="opacity-100"
319
+ leave-to-class="opacity-0"
320
+ >
321
+ <div v-if="showMethodDialog" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
322
+ <div class="absolute inset-0 bg-black/40" @click="showMethodDialog = false" />
323
+ <div class="relative bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-5">
324
+ <h3 class="font-['Montserrat'] font-bold text-lg text-slate-900 text-center">Chọn phương thức nhận OTP</h3>
325
+
326
+ <div class="space-y-3">
327
+ <button
328
+ @click="pendingMethod = 'zalo'"
329
+ :class="[
330
+ 'w-full flex items-center gap-3 p-4 rounded-xl border-2 transition-all',
331
+ pendingMethod === 'zalo'
332
+ ? 'border-[#004AC6] bg-blue-50'
333
+ : 'border-slate-200 hover:border-slate-300'
334
+ ]"
335
+ >
336
+ <span class="w-10 h-10 rounded-[10px] bg-blue-100 flex items-center justify-center">
337
+ <Icon name="lucide:message-circle" class="text-[#004AC6] text-[20px]" />
338
+ </span>
339
+ <div class="text-left">
340
+ <p class="font-semibold text-sm text-slate-900">Tin nhắn Zalo</p>
341
+ <p class="text-xs text-slate-500">Nhận mã qua Zalo</p>
342
+ </div>
343
+ <Icon
344
+ v-if="pendingMethod === 'zalo'"
345
+ name="lucide:check-circle"
346
+ class="text-[#004AC6] text-[20px] ml-auto"
347
+ />
348
+ </button>
349
+
350
+ <button
351
+ @click="pendingMethod = 'sms'"
352
+ :class="[
353
+ 'w-full flex items-center gap-3 p-4 rounded-xl border-2 transition-all',
354
+ pendingMethod === 'sms'
355
+ ? 'border-[#004AC6] bg-blue-50'
356
+ : 'border-slate-200 hover:border-slate-300'
357
+ ]"
358
+ >
359
+ <span class="w-10 h-10 rounded-[10px] bg-emerald-100 flex items-center justify-center">
360
+ <Icon name="lucide:message-square" class="text-emerald-600 text-[20px]" />
361
+ </span>
362
+ <div class="text-left">
363
+ <p class="font-semibold text-sm text-slate-900">Tin nhắn SMS</p>
364
+ <p class="text-xs text-slate-500">Nhận mã qua SMS</p>
365
+ </div>
366
+ <Icon
367
+ v-if="pendingMethod === 'sms'"
368
+ name="lucide:check-circle"
369
+ class="text-[#004AC6] text-[20px] ml-auto"
370
+ />
371
+ </button>
372
+ </div>
373
+
374
+ <div class="flex gap-3">
375
+ <button
376
+ @click="showMethodDialog = false"
377
+ class="flex-1 py-3 rounded-[10px] border border-slate-200 text-slate-600 font-semibold text-sm hover:bg-slate-50 transition-colors"
378
+ >
379
+ Hủy
380
+ </button>
381
+ <button
382
+ @click="confirmMethodSelection"
383
+ :disabled="loading"
384
+ class="flex-1 py-3 rounded-[10px] bg-[#004AC6] text-white font-bold text-sm hover:bg-[#1D4ED8] transition-colors disabled:opacity-60"
385
+ >
386
+ Xác nhận
387
+ </button>
388
+ </div>
389
+ </div>
390
+ </div>
391
+ </Transition>
392
+ </Teleport>
393
+ </div>
394
+ <TheFooter/>
395
+ </div>
396
+ </template>
397
+
398
+ <script setup lang="ts">
399
+
400
+ definePageMeta({ layout: 'default' })
401
+
402
+ useHead({ title: 'Đăng ký — Long Vân' })
403
+
404
+ const { getUserLoginByUserLoginId, register, sendOTP, validateOTP, getAccessTokenByOTP, updateInfo } = useAuthApi()
405
+ const { setCookie, getCookie } = useCookieHelper()
406
+ const { setUser } = useAuthState()
407
+ const config = useRuntimeConfig()
408
+ const route = useRoute()
409
+ const router = useRouter()
410
+
411
+ const tokenKey = config.public.ACCESSTOKEN_NAME as string
412
+ const redirectUrl = computed(() => route.query.redirectUrl as string || '/')
413
+ const steps = ['Thông tin', 'Xác thực OTP', 'Cập nhật']
414
+ const step = ref(1)
415
+ const loading = ref(false)
416
+ const apiError = ref('')
417
+ const countdown = ref(0)
418
+ let countdownTimer: ReturnType<typeof setInterval> | null = null
419
+
420
+ // Step 1 form
421
+ const form = reactive({ fullName: '', phone: '' })
422
+ const errors = reactive({ fullName: '', phone: '' })
423
+
424
+ // Step 3 form
425
+ const profileForm = reactive({ email: '', password: '', confirmPassword: '' })
426
+ const profileErrors = reactive({ password: '', confirmPassword: '' })
427
+ const showPassword = ref(false)
428
+ const showConfirm = ref(false)
429
+
430
+ // OTP
431
+ const otpDigits = ref(['', '', '', '', '', ''])
432
+ const otpRefs: (HTMLInputElement | null)[] = []
433
+ const otpCode = computed(() => otpDigits.value.join(''))
434
+
435
+ // Method selection
436
+ const selectedMethod = ref<'sms' | 'zalo'>('zalo')
437
+ const pendingMethod = ref<'sms' | 'zalo'>('zalo')
438
+ const showMethodDialog = ref(false)
439
+
440
+ function setOtpRef(el: any, i: number) {
441
+ otpRefs[i] = el as HTMLInputElement
442
+ }
443
+
444
+ function onOtpInput(i: number) {
445
+ const val = otpDigits.value[i]
446
+ if (val && i < 5) nextTick(() => otpRefs[i + 1]?.focus())
447
+ }
448
+
449
+ function onOtpKeydown(e: KeyboardEvent, i: number) {
450
+ if (e.key === 'Backspace' && !otpDigits.value[i] && i > 0) {
451
+ otpRefs[i - 1]?.focus()
452
+ }
453
+ }
454
+
455
+ function onOtpPaste(e: ClipboardEvent) {
456
+ const text = e.clipboardData?.getData('text') || ''
457
+ const digits = text.replace(/\D/g, '').slice(0, 6)
458
+ digits.split('').forEach((d, i) => { otpDigits.value[i] = d })
459
+ nextTick(() => otpRefs[Math.min(digits.length, 5)]?.focus())
460
+ }
461
+
462
+ function startCountdown(seconds = 60) {
463
+ countdown.value = seconds
464
+ if (countdownTimer) clearInterval(countdownTimer)
465
+ countdownTimer = setInterval(() => {
466
+ countdown.value--
467
+ if (countdown.value <= 0 && countdownTimer) clearInterval(countdownTimer)
468
+ }, 1000)
469
+ }
470
+
471
+ // Step 1: Check user & register
472
+ async function onSubmit() {
473
+ errors.fullName = ''
474
+ errors.phone = ''
475
+ apiError.value = ''
476
+
477
+ if (!form.fullName.trim()) {
478
+ errors.fullName = 'Vui lòng nhập họ và tên'
479
+ return
480
+ }
481
+ if (!form.phone.trim()) {
482
+ errors.phone = 'Vui lòng nhập số điện thoại'
483
+ return
484
+ }
485
+ if (!/^(0[3-9])\d{8}$/.test(form.phone.trim())) {
486
+ errors.phone = 'Số điện thoại không hợp lệ'
487
+ return
488
+ }
489
+
490
+ loading.value = true
491
+
492
+ try {
493
+ const phone = form.phone.trim()
494
+ const name = form.fullName.trim()
495
+
496
+ const existingUser = await getUserLoginByUserLoginId(phone)
497
+
498
+ if (existingUser) {
499
+ if (existingUser.status === 'VERIFIED') {
500
+ apiError.value = 'Số điện thoại đã được đăng ký!'
501
+ return
502
+ }
503
+ } else {
504
+ await register({ username: phone, fullName: name })
505
+ }
506
+
507
+ pendingMethod.value = 'zalo'
508
+ showMethodDialog.value = true
509
+ } catch (err: any) {
510
+ if (err?.extensions?.debugInfo?.errorCode === 'USER_LOGIN_NEED_VERIFY_SMS') {
511
+ pendingMethod.value = 'zalo'
512
+ showMethodDialog.value = true
513
+ return
514
+ }
515
+ apiError.value = err?.message || 'Đã có lỗi xảy ra. Vui lòng thử lại.'
516
+ } finally {
517
+ loading.value = false
518
+ }
519
+ }
520
+
521
+ // Confirm method selection & send OTP
522
+ async function confirmMethodSelection() {
523
+ selectedMethod.value = pendingMethod.value
524
+ showMethodDialog.value = false
525
+ loading.value = true
526
+ apiError.value = ''
527
+
528
+ try {
529
+ await sendOTP(form.phone.trim(), selectedMethod.value)
530
+ startCountdown(60)
531
+ step.value = 2
532
+ } catch (err: any) {
533
+ if (selectedMethod.value === 'zalo') {
534
+ apiError.value = 'Gửi qua Zalo thất bại. Bạn có thể thử gửi qua SMS.'
535
+ pendingMethod.value = 'sms'
536
+ showMethodDialog.value = true
537
+ } else {
538
+ apiError.value = err?.message || 'Không thể gửi OTP. Vui lòng thử lại.'
539
+ }
540
+ } finally {
541
+ loading.value = false
542
+ }
543
+ }
544
+
545
+ // Resend OTP - show method selection again
546
+ async function resendOtp() {
547
+ pendingMethod.value = selectedMethod.value
548
+ showMethodDialog.value = true
549
+ }
550
+
551
+ // Step 2: Verify OTP → get access token → move to step 3
552
+ async function onVerifyOtp() {
553
+ if (otpCode.value.length < 6) return
554
+ loading.value = true
555
+ apiError.value = ''
556
+
557
+ try {
558
+ const res = await validateOTP(otpCode.value, form.phone.trim(), selectedMethod.value)
559
+ if (!res) {
560
+ apiError.value = 'Mã OTP không đúng hoặc đã hết hạn.'
561
+ return
562
+ }
563
+
564
+ const tokenRes = await getAccessTokenByOTP(otpCode.value, form.phone.trim(), selectedMethod.value)
565
+ if (tokenRes) {
566
+ setCookie(tokenKey, tokenRes, 30)
567
+ }
568
+
569
+ step.value = 3
570
+ } catch (err: any) {
571
+ apiError.value = err?.message || 'Xác thực thất bại. Vui lòng thử lại.'
572
+ } finally {
573
+ loading.value = false
574
+ }
575
+ }
576
+
577
+ // Step 3: Update info with password
578
+ async function onUpdateInfo() {
579
+ profileErrors.password = ''
580
+ profileErrors.confirmPassword = ''
581
+ apiError.value = ''
582
+
583
+ if (profileForm.password && profileForm.password.length < 6) {
584
+ profileErrors.password = 'Mật khẩu tối thiểu 6 ký tự'
585
+ return
586
+ }
587
+ if (profileForm.password && profileForm.password !== profileForm.confirmPassword) {
588
+ profileErrors.confirmPassword = 'Mật khẩu xác nhận không khớp'
589
+ return
590
+ }
591
+
592
+ loading.value = true
593
+
594
+ try {
595
+ const token = getCookie(tokenKey)
596
+ if (!token) {
597
+ apiError.value = 'Phiên đăng ký đã hết hạn. Vui lòng thử lại.'
598
+ return
599
+ }
600
+
601
+ await updateInfo(
602
+ token,
603
+ {
604
+ fullName: form.fullName.trim(),
605
+ phone: form.phone.trim(),
606
+ email: profileForm.email.trim() || undefined,
607
+ },
608
+ '',
609
+ profileForm.password || ''
610
+ )
611
+
612
+ // Update auth state for Header
613
+ setUser(form.fullName.trim())
614
+
615
+ // Redirect to target page
616
+ router.push(redirectUrl.value)
617
+ } catch (err: any) {
618
+ apiError.value = err?.message || 'Cập nhật thất bại. Vui lòng thử lại.'
619
+ } finally {
620
+ loading.value = false
621
+ }
622
+ }
623
+
624
+ onUnmounted(() => {
625
+ if (countdownTimer) clearInterval(countdownTimer)
626
+ })
627
+ </script>