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.
- package/.claude/settings.local.json +8 -0
- package/app/app.config.ts +5 -0
- package/app/app.vue +11 -0
- package/app/assets/css/main.css +113 -0
- package/app/components/.gitkeep +0 -0
- package/app/components/ui/sonner/Sonner.vue +62 -0
- package/app/components/ui/sonner/index.ts +1 -0
- package/app/composables/.gitkeep +0 -0
- package/app/layers/solution/app/assets/proxmox-css/main.css +89 -0
- package/app/layers/solution/app/assets/proxmox-css/proxmox-utils.css +68 -0
- package/app/layers/solution/app/components/ClusterDiagram.vue +72 -0
- package/app/layers/solution/app/components/CoursesSection.vue +246 -0
- package/app/layers/solution/app/components/CtaSection.vue +329 -0
- package/app/layers/solution/app/components/FeaturesSection.vue +67 -0
- package/app/layers/solution/app/components/HeroSection.vue +144 -0
- package/app/layers/solution/app/components/PricingPlansSection.vue +434 -0
- package/app/layers/solution/app/components/RegistrationForm.vue +256 -0
- package/app/layers/solution/app/components/TestimonialsSection.vue +58 -0
- package/app/layers/solution/app/components/TheFooter.vue +61 -0
- package/app/layers/solution/app/components/TheHeader.vue +189 -0
- package/app/layers/solution/app/components/proxmox/AppIcon.vue +45 -0
- package/app/layers/solution/app/components/proxmox/ComparisonSection.vue +54 -0
- package/app/layers/solution/app/components/proxmox/FaqSection.vue +58 -0
- package/app/layers/solution/app/components/proxmox/HciOverviewSection.vue +37 -0
- package/app/layers/solution/app/components/proxmox/HeroSection.vue +74 -0
- package/app/layers/solution/app/components/proxmox/TestimonialsSection.vue +44 -0
- package/app/layers/solution/app/components/proxmox/TrainingBenefitsSection.vue +37 -0
- package/app/layers/solution/app/components/proxmox/VeFeaturesSection.vue +55 -0
- package/app/layers/solution/app/composables/useActiveSection.ts +47 -0
- package/app/layers/solution/app/composables/useAuthApi.ts +244 -0
- package/app/layers/solution/app/composables/useAuthState.ts +16 -0
- package/app/layers/solution/app/composables/useCookie.ts +79 -0
- package/app/layers/solution/app/composables/useCookieHelper.ts +81 -0
- package/app/layers/solution/app/composables/useCrmApi.ts +32 -0
- package/app/layers/solution/app/composables/useDynamicCollection.ts +23 -0
- package/app/layers/solution/app/composables/useScrollAnimate.ts +40 -0
- package/app/layers/solution/app/composables/useScrollToSection.ts +13 -0
- package/app/layers/solution/app/composables/useSolutionApi.ts +55 -0
- package/app/layers/solution/app/composables/useToast.ts +14 -0
- package/app/layers/solution/app/data/proxmox-content.ts +158 -0
- package/app/layers/solution/app/images/Proxmox HCI.png +0 -0
- package/app/layers/solution/app/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
- package/app/layers/solution/app/layouts/default.vue +5 -0
- package/app/layers/solution/app/nuxt.config.ts +9 -0
- package/app/layers/solution/app/pages/cap-nhat-thong-tin/index.vue +302 -0
- package/app/layers/solution/app/pages/dang-ky/index.vue +627 -0
- package/app/layers/solution/app/pages/dang-nhap/index.vue +208 -0
- package/app/layers/solution/app/pages/doi-mat-khau/index.vue +237 -0
- package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/index.vue +37 -0
- package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue +45 -0
- package/app/layers/solution/app/pages/giai-phap-proxmox/dang-ky-khoa-hoc/[slug].vue +131 -0
- package/app/layers/solution/app/pages/quen-mat-khau/index.vue +136 -0
- package/app/layers/solution/app/pages/xac-thuc/index.vue +320 -0
- package/app/layers/solution/app/plugins/reveal.ts +56 -0
- package/app/layers/solution/app/public/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
- package/app/layers/solution/app/public/images/logo-lvs-1300x330.png +0 -0
- package/app/layouts/auth.vue +71 -0
- package/app/lib/utils.ts +7 -0
- package/app/middleware/.gitkeep +0 -0
- package/app/modules/layer-route-priority.ts +60 -0
- package/app/pages/index.vue +9 -0
- package/app/pages/san-pham-proxmox.vue +8 -0
- package/app/plugins/.gitkeep +0 -0
- package/app/plugins/sdk.ts +29 -0
- package/app/stores/.gitkeep +0 -0
- package/app/types/sdk.d.ts +15 -0
- package/components.json +24 -0
- package/nuxt.config.ts +104 -0
- package/package.json +52 -0
- package/public/.gitkeep +0 -0
- package/server/api/.gitkeep +0 -0
- package/server/utils/.gitkeep +0 -0
- package/tailwind.config.js +96 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +18 -0
- package/website-longvan-1.1.6.tgz +0 -0
- package/website-longvan-1.1.7.tgz +0 -0
- 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>
|