nuxt-layer-core 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +245 -0
- package/app/layers/solution/app/components/CtaSection.vue +347 -0
- package/app/layers/solution/app/components/DynamicPageSections.vue +103 -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 +396 -0
- package/app/layers/solution/app/components/RegistrationForm.vue +283 -0
- package/app/layers/solution/app/components/RegistrationSection.vue +24 -0
- package/app/layers/solution/app/components/RegistrationSidebar.vue +70 -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 +193 -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/useConsultationRequest.ts +8 -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/usePlanNormalizer.ts +45 -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 +73 -0
- package/app/layers/solution/app/composables/useToast.ts +14 -0
- package/app/layers/solution/app/data/pricingPlans.ts +150 -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 +15 -0
- package/app/layers/solution/app/pages/[slug]/[slug2]/[slug3]/index.vue +22 -0
- package/app/layers/solution/app/pages/[slug]/[slug2]/index.vue +23 -0
- package/app/layers/solution/app/pages/[slug]/index.vue +22 -0
- package/app/layers/solution/app/pages/cap-nhat-thong-tin/index.vue.disabled +302 -0
- package/app/layers/solution/app/pages/dang-ky/index.vue.disabled +627 -0
- package/app/layers/solution/app/pages/dang-ky-tu-van/khoa-hoc-proxmox.vue.disabled +39 -0
- package/app/layers/solution/app/pages/dang-nhap/index.vue.disabled +208 -0
- package/app/layers/solution/app/pages/doi-mat-khau/index.vue.disabled +237 -0
- package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/index.vue.disabled +37 -0
- package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue.disabled +45 -0
- package/app/layers/solution/app/pages/giai-phap-proxmox/dang-ky-khoa-hoc/[slug].vue.disabled +56 -0
- package/app/layers/solution/app/pages/quen-mat-khau/index.vue.disabled +136 -0
- package/app/layers/solution/app/pages/xac-thuc/index.vue.disabled +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 +10 -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 +105 -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
|
@@ -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
|
+
})
|
|
Binary file
|
|
Binary file
|
|
@@ -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>
|
package/app/lib/utils.ts
ADDED
|
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,10 @@
|
|
|
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
|
+
import GiaiPhapPage from '../layers/solution/app/pages/[slug]/[slug2]/index.vue'
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<GiaiPhapPage />
|
|
9
|
+
<!-- <div>d2</div> -->
|
|
10
|
+
</template>
|
|
File without changes
|