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,208 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<TheHeader />
|
|
4
|
+
<div class="min-h-[calc(50vh-4rem)] flex items-start justify-center pt-20 pb-16 px-6">
|
|
5
|
+
<div class="w-full max-w-md">
|
|
6
|
+
<!-- Header -->
|
|
7
|
+
<div class="mb-8 text-center">
|
|
8
|
+
<h1 class="font-['Montserrat'] font-extrabold text-2xl text-slate-900 mb-2">Đăng nhập</h1>
|
|
9
|
+
<p class="text-slate-500 text-sm">Chào mừng trở lại! Nhập thông tin để tiếp tục.</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<!-- Form card -->
|
|
13
|
+
<div class="bg-white rounded-2xl shadow-sm border border-slate-200/80 p-8">
|
|
14
|
+
<form @submit.prevent="onSubmit" class="space-y-5">
|
|
15
|
+
<!-- Username -->
|
|
16
|
+
<div>
|
|
17
|
+
<label for="username" class="block text-sm font-semibold text-slate-700 mb-1.5">
|
|
18
|
+
Tên tài khoản
|
|
19
|
+
</label>
|
|
20
|
+
<div class="relative">
|
|
21
|
+
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
|
|
22
|
+
<Icon name="lucide:user" class="text-[17px]" />
|
|
23
|
+
</span>
|
|
24
|
+
<input
|
|
25
|
+
id="username"
|
|
26
|
+
v-model="form.username"
|
|
27
|
+
type="text"
|
|
28
|
+
autocomplete="username"
|
|
29
|
+
placeholder="Nhập SĐT hoặc Email"
|
|
30
|
+
:class="[
|
|
31
|
+
'w-full pl-10 pr-4 py-3 rounded-[10px] border text-sm outline-none transition-all',
|
|
32
|
+
errors.username
|
|
33
|
+
? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
|
|
34
|
+
: 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
|
|
35
|
+
]"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
<p v-if="errors.username" class="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
39
|
+
<Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.username }}
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- Password -->
|
|
44
|
+
<div>
|
|
45
|
+
<div class="flex items-center justify-between mb-1.5">
|
|
46
|
+
<label for="password" class="text-sm font-semibold text-slate-700">Mật khẩu</label>
|
|
47
|
+
<NuxtLink :to="{ path: '/quen-mat-khau', query: { redirectUrl } }" class="text-xs text-[#004AC6] font-medium hover:underline">
|
|
48
|
+
Quên mật khẩu?
|
|
49
|
+
</NuxtLink>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="relative">
|
|
52
|
+
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
|
|
53
|
+
<Icon name="lucide:lock" class="text-[17px]" />
|
|
54
|
+
</span>
|
|
55
|
+
<input
|
|
56
|
+
id="password"
|
|
57
|
+
v-model="form.password"
|
|
58
|
+
:type="showPassword ? 'text' : 'password'"
|
|
59
|
+
autocomplete="current-password"
|
|
60
|
+
placeholder="Nhập mật khẩu"
|
|
61
|
+
:class="[
|
|
62
|
+
'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
|
|
63
|
+
errors.password
|
|
64
|
+
? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
|
|
65
|
+
: 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
|
|
66
|
+
]"
|
|
67
|
+
/>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
@click="showPassword = !showPassword"
|
|
71
|
+
class="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
|
72
|
+
>
|
|
73
|
+
<Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
<p v-if="errors.password" class="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
77
|
+
<Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.password }}
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<!-- Remember me -->
|
|
82
|
+
<div class="flex items-center gap-2.5">
|
|
83
|
+
<input
|
|
84
|
+
id="remember"
|
|
85
|
+
v-model="form.remember"
|
|
86
|
+
type="checkbox"
|
|
87
|
+
class="w-4 h-4 rounded border-slate-300 text-[#004AC6] accent-[#004AC6] cursor-pointer"
|
|
88
|
+
/>
|
|
89
|
+
<label for="remember" class="text-sm text-slate-600 cursor-pointer select-none">Ghi nhớ đăng nhập</label>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Submit -->
|
|
93
|
+
<button
|
|
94
|
+
type="submit"
|
|
95
|
+
:disabled="loading"
|
|
96
|
+
class="w-full flex items-center justify-center gap-2 bg-[#004AC6] hover:bg-[#1D4ED8] active:scale-[.98] text-white font-bold py-3.5 rounded-[10px] transition-all shadow-sm shadow-blue-600/20 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
97
|
+
>
|
|
98
|
+
<span v-if="!loading">Đăng nhập</span>
|
|
99
|
+
<span v-else class="flex items-center gap-2">
|
|
100
|
+
<svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
|
101
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
102
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
|
103
|
+
</svg>
|
|
104
|
+
Đang xử lý...
|
|
105
|
+
</span>
|
|
106
|
+
</button>
|
|
107
|
+
</form>
|
|
108
|
+
|
|
109
|
+
<!-- Divider -->
|
|
110
|
+
<div class="flex items-center gap-3 my-6">
|
|
111
|
+
<div class="flex-1 h-px bg-slate-200" />
|
|
112
|
+
<span class="text-xs text-slate-400 font-medium">hoặc</span>
|
|
113
|
+
<div class="flex-1 h-px bg-slate-200" />
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<!-- Register link -->
|
|
117
|
+
<p class="text-center text-sm text-slate-500">
|
|
118
|
+
Chưa có tài khoản?
|
|
119
|
+
<NuxtLink :to="{ path: '/dang-ky', query: { redirectUrl } }" class="text-[#004AC6] font-semibold hover:underline ml-1">
|
|
120
|
+
Đăng ký ngay
|
|
121
|
+
</NuxtLink>
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<TheFooter/>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|
|
129
|
+
|
|
130
|
+
<script setup lang="ts">
|
|
131
|
+
import { toast } from 'vue-sonner'
|
|
132
|
+
|
|
133
|
+
definePageMeta({ layout: 'default' })
|
|
134
|
+
|
|
135
|
+
useHead({ title: 'Đăng nhập — Long Vân' })
|
|
136
|
+
|
|
137
|
+
const { login, getUserByToken } = useAuthApi()
|
|
138
|
+
const { setCookie } = useCookieHelper()
|
|
139
|
+
const { setUser } = useAuthState()
|
|
140
|
+
const config = useRuntimeConfig()
|
|
141
|
+
const route = useRoute()
|
|
142
|
+
const router = useRouter()
|
|
143
|
+
|
|
144
|
+
const tokenKey = config.public.ACCESSTOKEN_NAME as string
|
|
145
|
+
const redirectUrl = computed(() => route.query.redirectUrl as string || '/')
|
|
146
|
+
|
|
147
|
+
const form = reactive({
|
|
148
|
+
username: '',
|
|
149
|
+
password: '',
|
|
150
|
+
remember: false,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const errors = reactive({ username: '', password: '' })
|
|
154
|
+
const loading = ref(false)
|
|
155
|
+
const showPassword = ref(false)
|
|
156
|
+
|
|
157
|
+
function validate() {
|
|
158
|
+
errors.username = ''
|
|
159
|
+
errors.password = ''
|
|
160
|
+
let valid = true
|
|
161
|
+
if (!form.username.trim()) {
|
|
162
|
+
errors.username = 'Vui lòng nhập tên tài khoản'
|
|
163
|
+
valid = false
|
|
164
|
+
}
|
|
165
|
+
if (!form.password) {
|
|
166
|
+
errors.password = 'Vui lòng nhập mật khẩu'
|
|
167
|
+
valid = false
|
|
168
|
+
} else if (form.password.length < 6) {
|
|
169
|
+
errors.password = 'Mật khẩu tối thiểu 6 ký tự'
|
|
170
|
+
valid = false
|
|
171
|
+
}
|
|
172
|
+
return valid
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function onSubmit() {
|
|
176
|
+
if (!validate()) return
|
|
177
|
+
loading.value = true
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const res = await login({
|
|
181
|
+
username: form.username.trim(),
|
|
182
|
+
password: form.password,
|
|
183
|
+
type: 'PW',
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
if (res?.accessToken) {
|
|
187
|
+
const days = form.remember ? 30 : 1
|
|
188
|
+
setCookie(tokenKey, res.accessToken, days)
|
|
189
|
+
|
|
190
|
+
// Update auth state for Header
|
|
191
|
+
setUser(res.fullName || res.username || form.username.trim())
|
|
192
|
+
|
|
193
|
+
const redirectUrl = route.query.redirectUrl as string
|
|
194
|
+
if (redirectUrl) {
|
|
195
|
+
window.location.assign(redirectUrl)
|
|
196
|
+
} else {
|
|
197
|
+
router.push('/giai-phap-proxmox')
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
toast.error('Tên tài khoản hoặc mật khẩu không đúng.')
|
|
201
|
+
}
|
|
202
|
+
} catch (err: any) {
|
|
203
|
+
toast.error(err?.message || 'Đã có lỗi xảy ra. Vui lòng thử lại.')
|
|
204
|
+
} finally {
|
|
205
|
+
loading.value = false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
</script>
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<TheHeader />
|
|
4
|
+
<div class="min-h-[calc(50vh-4rem)] flex items-start justify-center pt-20 pb-16 px-6">
|
|
5
|
+
<div class="w-full max-w-md">
|
|
6
|
+
<!-- Header -->
|
|
7
|
+
<div class="mb-8 text-center">
|
|
8
|
+
<h1 class="font-['Montserrat'] font-extrabold text-2xl text-slate-900 mb-2">Đổi mật khẩu</h1>
|
|
9
|
+
<p class="text-slate-500 text-sm">Tạo mật khẩu mới bảo mật cho tài khoản của bạn.</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<!-- Card -->
|
|
13
|
+
<div class="bg-white rounded-2xl shadow-sm border border-slate-200/80 p-8">
|
|
14
|
+
|
|
15
|
+
<!-- Invalid/missing token state -->
|
|
16
|
+
<div v-if="!resetKey && !route.query.token" class="text-center space-y-4 py-4">
|
|
17
|
+
<div class="w-14 h-14 rounded-[10px] bg-amber-50 flex items-center justify-center mx-auto">
|
|
18
|
+
<Icon name="lucide:alert-triangle" class="text-amber-500 text-[26px]" />
|
|
19
|
+
</div>
|
|
20
|
+
<p class="text-slate-600 text-sm">Link đặt lại mật khẩu không hợp lệ hoặc đã hết hạn.</p>
|
|
21
|
+
<NuxtLink to="/quen-mat-khau" class="inline-flex items-center gap-2 text-[#004AC6] font-semibold hover:underline">
|
|
22
|
+
<Icon name="lucide:arrow-left" class="text-[15px]" />
|
|
23
|
+
Yêu cầu lại
|
|
24
|
+
</NuxtLink>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Change password form -->
|
|
28
|
+
<form v-else @submit.prevent="onSubmit" class="space-y-5">
|
|
29
|
+
<!-- New password -->
|
|
30
|
+
<div>
|
|
31
|
+
<label for="newPass" class="block text-sm font-semibold text-slate-700 mb-1.5">Mật khẩu mới</label>
|
|
32
|
+
<div class="relative">
|
|
33
|
+
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
|
|
34
|
+
<Icon name="lucide:lock" class="text-[17px]" />
|
|
35
|
+
</span>
|
|
36
|
+
<input
|
|
37
|
+
id="newPass"
|
|
38
|
+
v-model="form.newPassword"
|
|
39
|
+
:type="showNew ? 'text' : 'password'"
|
|
40
|
+
autocomplete="new-password"
|
|
41
|
+
placeholder="Tối thiểu 6 ký tự"
|
|
42
|
+
:class="[
|
|
43
|
+
'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
|
|
44
|
+
errors.newPassword
|
|
45
|
+
? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
|
|
46
|
+
: 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
|
|
47
|
+
]"
|
|
48
|
+
/>
|
|
49
|
+
<button type="button" @click="showNew = !showNew" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors">
|
|
50
|
+
<Icon :name="showNew ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
<p v-if="errors.newPassword" class="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
54
|
+
<Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.newPassword }}
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
<!-- Strength indicator -->
|
|
58
|
+
<div class="mt-2 space-y-1">
|
|
59
|
+
<div class="flex gap-1">
|
|
60
|
+
<div v-for="i in 4" :key="i" :class="['h-1 flex-1 rounded-[10px] transition-all', strengthLevel >= i ? strengthColor : 'bg-slate-200']" />
|
|
61
|
+
</div>
|
|
62
|
+
<p v-if="form.newPassword" class="text-xs" :class="strengthTextColor">{{ strengthLabel }}</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- Confirm password -->
|
|
67
|
+
<div>
|
|
68
|
+
<label for="confirmPass" class="block text-sm font-semibold text-slate-700 mb-1.5">Xác nhận mật khẩu</label>
|
|
69
|
+
<div class="relative">
|
|
70
|
+
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
|
|
71
|
+
<Icon name="lucide:lock" class="text-[17px]" />
|
|
72
|
+
</span>
|
|
73
|
+
<input
|
|
74
|
+
id="confirmPass"
|
|
75
|
+
v-model="form.confirmPassword"
|
|
76
|
+
:type="showConfirm ? 'text' : 'password'"
|
|
77
|
+
autocomplete="new-password"
|
|
78
|
+
placeholder="Nhập lại mật khẩu mới"
|
|
79
|
+
:class="[
|
|
80
|
+
'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
|
|
81
|
+
errors.confirmPassword
|
|
82
|
+
? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
|
|
83
|
+
: 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
|
|
84
|
+
]"
|
|
85
|
+
/>
|
|
86
|
+
<button type="button" @click="showConfirm = !showConfirm" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors">
|
|
87
|
+
<Icon :name="showConfirm ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
<p v-if="errors.confirmPassword" class="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
91
|
+
<Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.confirmPassword }}
|
|
92
|
+
</p>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<!-- Password rules -->
|
|
96
|
+
<ul class="space-y-1.5">
|
|
97
|
+
<li v-for="rule in passwordRules" :key="rule.label" class="flex items-center gap-2 text-xs">
|
|
98
|
+
<Icon
|
|
99
|
+
:name="rule.met ? 'lucide:check-circle' : 'lucide:circle'"
|
|
100
|
+
:class="rule.met ? 'text-emerald-500' : 'text-slate-300'"
|
|
101
|
+
class="text-[14px] shrink-0"
|
|
102
|
+
/>
|
|
103
|
+
<span :class="rule.met ? 'text-emerald-600' : 'text-slate-400'">{{ rule.label }}</span>
|
|
104
|
+
</li>
|
|
105
|
+
</ul>
|
|
106
|
+
|
|
107
|
+
<div v-if="apiError" class="flex items-start gap-2 bg-red-50 border border-red-200 rounded-[10px] px-4 py-3 text-sm text-red-700">
|
|
108
|
+
<Icon name="lucide:alert-triangle" class="text-[16px] mt-0.5 shrink-0" />
|
|
109
|
+
<span>{{ apiError }}</span>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<button
|
|
113
|
+
type="submit"
|
|
114
|
+
:disabled="loading"
|
|
115
|
+
class="w-full flex items-center justify-center gap-2 bg-[#004AC6] hover:bg-[#1D4ED8] active:scale-[.98] text-white font-bold py-3.5 rounded-[10px] transition-all shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
|
|
116
|
+
>
|
|
117
|
+
<span v-if="!loading">Đổi mật khẩu</span>
|
|
118
|
+
<span v-else class="flex items-center gap-2">
|
|
119
|
+
<svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
|
120
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
121
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
|
122
|
+
</svg>
|
|
123
|
+
Đang cập nhật...
|
|
124
|
+
</span>
|
|
125
|
+
</button>
|
|
126
|
+
</form>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<TheFooter/>
|
|
131
|
+
</div>
|
|
132
|
+
</template>
|
|
133
|
+
|
|
134
|
+
<script setup lang="ts">
|
|
135
|
+
definePageMeta({ layout: 'default' })
|
|
136
|
+
|
|
137
|
+
useHead({ title: 'Đổi mật khẩu — Long Vân' })
|
|
138
|
+
|
|
139
|
+
const { checkResetKey, resetPassword } = useAuthApi()
|
|
140
|
+
const route = useRoute()
|
|
141
|
+
const router = useRouter()
|
|
142
|
+
|
|
143
|
+
const redirectUrl = computed(() => route.query.redirectUrl as string || '/')
|
|
144
|
+
const resetKey = ref('')
|
|
145
|
+
const loading = ref(false)
|
|
146
|
+
const apiError = ref('')
|
|
147
|
+
const showNew = ref(false)
|
|
148
|
+
const showConfirm = ref(false)
|
|
149
|
+
|
|
150
|
+
const form = reactive({ newPassword: '', confirmPassword: '' })
|
|
151
|
+
const errors = reactive({ newPassword: '', confirmPassword: '' })
|
|
152
|
+
|
|
153
|
+
// Password strength
|
|
154
|
+
const passwordRules = computed(() => [
|
|
155
|
+
{ label: 'Tối thiểu 6 ký tự', met: form.newPassword.length >= 6 },
|
|
156
|
+
{ label: 'Có chữ hoa (A-Z)', met: /[A-Z]/.test(form.newPassword) },
|
|
157
|
+
{ label: 'Có chữ số (0-9)', met: /\d/.test(form.newPassword) },
|
|
158
|
+
{ label: 'Có ký tự đặc biệt (!@#...)', met: /[!@#$%^&*]/.test(form.newPassword) },
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
const strengthLevel = computed(() => passwordRules.value.filter(r => r.met).length)
|
|
162
|
+
const strengthColor = computed(() => {
|
|
163
|
+
if (strengthLevel.value <= 1) return 'bg-red-400'
|
|
164
|
+
if (strengthLevel.value === 2) return 'bg-amber-400'
|
|
165
|
+
if (strengthLevel.value === 3) return 'bg-blue-400'
|
|
166
|
+
return 'bg-emerald-500'
|
|
167
|
+
})
|
|
168
|
+
const strengthTextColor = computed(() => {
|
|
169
|
+
if (strengthLevel.value <= 1) return 'text-red-500'
|
|
170
|
+
if (strengthLevel.value === 2) return 'text-amber-600'
|
|
171
|
+
if (strengthLevel.value === 3) return 'text-blue-600'
|
|
172
|
+
return 'text-emerald-600'
|
|
173
|
+
})
|
|
174
|
+
const strengthLabel = computed(() => {
|
|
175
|
+
if (strengthLevel.value <= 1) return 'Yếu'
|
|
176
|
+
if (strengthLevel.value === 2) return 'Trung bình'
|
|
177
|
+
if (strengthLevel.value === 3) return 'Khá mạnh'
|
|
178
|
+
return 'Rất mạnh'
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
function validate() {
|
|
182
|
+
errors.newPassword = ''
|
|
183
|
+
errors.confirmPassword = ''
|
|
184
|
+
let valid = true
|
|
185
|
+
if (!form.newPassword) {
|
|
186
|
+
errors.newPassword = 'Vui lòng nhập mật khẩu mới'
|
|
187
|
+
valid = false
|
|
188
|
+
} else if (form.newPassword.length < 6) {
|
|
189
|
+
errors.newPassword = 'Mật khẩu tối thiểu 6 ký tự'
|
|
190
|
+
valid = false
|
|
191
|
+
}
|
|
192
|
+
if (!form.confirmPassword) {
|
|
193
|
+
errors.confirmPassword = 'Vui lòng xác nhận mật khẩu'
|
|
194
|
+
valid = false
|
|
195
|
+
} else if (form.newPassword !== form.confirmPassword) {
|
|
196
|
+
errors.confirmPassword = 'Mật khẩu xác nhận không khớp'
|
|
197
|
+
valid = false
|
|
198
|
+
}
|
|
199
|
+
return valid
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function onSubmit() {
|
|
203
|
+
if (!validate()) return
|
|
204
|
+
loading.value = true
|
|
205
|
+
apiError.value = ''
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const phone = route.query.phone as string
|
|
209
|
+
const token = route.query.token as string
|
|
210
|
+
|
|
211
|
+
if (token && phone) {
|
|
212
|
+
// Reset password using OTP token
|
|
213
|
+
await resetPassword(phone, form.newPassword, token)
|
|
214
|
+
router.push('/dang-nhap')
|
|
215
|
+
} else if (resetKey.value) {
|
|
216
|
+
// Validate reset key first
|
|
217
|
+
const keyRes = await checkResetKey(resetKey.value)
|
|
218
|
+
if (!keyRes?.accessToken) {
|
|
219
|
+
apiError.value = 'Link đặt lại mật khẩu đã hết hạn.'
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
await resetPassword(keyRes.userLoginId, form.newPassword, keyRes.accessToken)
|
|
223
|
+
router.push('/dang-nhap')
|
|
224
|
+
} else {
|
|
225
|
+
apiError.value = 'Thiếu thông tin xác thực. Vui lòng thử lại.'
|
|
226
|
+
}
|
|
227
|
+
} catch (err: any) {
|
|
228
|
+
apiError.value = err?.message || 'Đã có lỗi xảy ra. Vui lòng thử lại.'
|
|
229
|
+
} finally {
|
|
230
|
+
loading.value = false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
onMounted(() => {
|
|
235
|
+
resetKey.value = route.query.key as string || ''
|
|
236
|
+
})
|
|
237
|
+
</script>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="bg-ink text-cloud font-body antialiased">
|
|
3
|
+
<TheHeader :target-c-t-a="targetCTA" :slug="slug" />
|
|
4
|
+
<HeroSection />
|
|
5
|
+
<FeaturesSection />
|
|
6
|
+
<CoursesSection :plans="data?.resumeData" :target-c-t-a="targetCTA" :slug="slug" />
|
|
7
|
+
<TestimonialsSection />
|
|
8
|
+
<CtaSection v-if="targetCTA === 'CONSULTANT'" :plans="data?.resumeData" />
|
|
9
|
+
<TheFooter/>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup>
|
|
14
|
+
definePageMeta({
|
|
15
|
+
layout: 'default',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const route = useRoute()
|
|
19
|
+
const { getSolutionDetail } = useSolutionApi()
|
|
20
|
+
|
|
21
|
+
const { data, error } = await useAsyncData(
|
|
22
|
+
`solution-${route.params.slug}`,
|
|
23
|
+
() => getSolutionDetail(route.params.slug, 'giai-phap'),
|
|
24
|
+
{ server: true }
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if (error.value || !data.value) {
|
|
28
|
+
throw createError({ statusCode: 404, statusMessage: 'Không tìm thấy giải pháp', fatal: true })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const targetCTA = computed(() => data.value?.productData?.[0]?.targetCTA || '')
|
|
32
|
+
const slug = computed(() => data.value?.productData?.[0]?.slug || String(route.params.slug))
|
|
33
|
+
|
|
34
|
+
useHead({
|
|
35
|
+
title: computed(() => data.value?.productData?.product?.title || 'Giới thiệu giải pháp Proxmox HCI'),
|
|
36
|
+
})
|
|
37
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({
|
|
3
|
+
layout: 'default',
|
|
4
|
+
})
|
|
5
|
+
|
|
6
|
+
const route = useRoute()
|
|
7
|
+
const { getSolutionDetail } = useSolutionApi()
|
|
8
|
+
|
|
9
|
+
const { data, error } = await useAsyncData(
|
|
10
|
+
`solution-${route.params.slug}`,
|
|
11
|
+
() => getSolutionDetail(String(route.params.slug), 'giai-phap'),
|
|
12
|
+
{ server: true }
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if (error.value || !data.value) {
|
|
16
|
+
throw createError({ statusCode: 404, statusMessage: 'Không tìm thấy giải pháp', fatal: true })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const targetCTA = computed(() => data.value?.productData?.[0]?.targetCTA || '')
|
|
20
|
+
const slug = computed(() => data.value?.productData?.[0]?.slug || String(route.params.slug))
|
|
21
|
+
|
|
22
|
+
useHead({
|
|
23
|
+
title: computed(() => data.value?.productData?.product?.title || 'Giới thiệu sản phẩm Proxmox HCI'),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div>
|
|
30
|
+
<main class="min-h-screen bg-white text-slate-900">
|
|
31
|
+
<TheHeader :target-c-t-a="targetCTA" :slug="slug" />
|
|
32
|
+
<ProxmoxHeroSection />
|
|
33
|
+
<ProxmoxVeFeaturesSection />
|
|
34
|
+
<ProxmoxHciOverviewSection />
|
|
35
|
+
<PricingPlansSection />
|
|
36
|
+
<ProxmoxTrainingBenefitsSection />
|
|
37
|
+
<ProxmoxComparisonSection />
|
|
38
|
+
<CoursesSection :plans="data?.resumeData" :target-c-t-a="targetCTA" :slug="slug" />
|
|
39
|
+
<ProxmoxTestimonialsSection />
|
|
40
|
+
<ProxmoxFaqSection />
|
|
41
|
+
<CtaSection v-if="targetCTA === 'CONSULTANT'" :plans="data?.resumeData" />
|
|
42
|
+
<TheFooter />
|
|
43
|
+
</main>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({ layout: 'default' })
|
|
3
|
+
|
|
4
|
+
const route = useRoute()
|
|
5
|
+
const { getSolutionDetail } = useSolutionApi()
|
|
6
|
+
|
|
7
|
+
const slug = computed(() => route.params.slug as string)
|
|
8
|
+
|
|
9
|
+
// SSR data fetch
|
|
10
|
+
const { data, error } = await useAsyncData(
|
|
11
|
+
`dang-ky-${slug.value}`,
|
|
12
|
+
() => getSolutionDetail(slug.value, 'giai-phap'),
|
|
13
|
+
{ server: true }
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if (error.value || !data.value) {
|
|
17
|
+
throw createError({ statusCode: 404, statusMessage: 'Không tìm thấy khóa học', fatal: true })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Format plans from resumeData
|
|
21
|
+
const formatPlans = (resumeData: any) => {
|
|
22
|
+
const fieldResumes = resumeData?.fieldResumes || []
|
|
23
|
+
return fieldResumes
|
|
24
|
+
?.filter((group: any) => group?.type === 'FIELD_GROUP' && Array.isArray(group?.children))
|
|
25
|
+
?.map((group: any) => {
|
|
26
|
+
const fields = Object.fromEntries(
|
|
27
|
+
group.children?.map((field: any) => [field?.fieldName?.split('@')?.pop(), field?.value]) || []
|
|
28
|
+
)
|
|
29
|
+
return {
|
|
30
|
+
id: fields.productid || group.fieldName,
|
|
31
|
+
name: fields.title || '',
|
|
32
|
+
level: fields.level || '',
|
|
33
|
+
duration: fields.time || '',
|
|
34
|
+
icon: fields.icon || 'lucide:server',
|
|
35
|
+
}
|
|
36
|
+
}) || []
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const plans = computed(() => formatPlans(data.value?.resumeData))
|
|
40
|
+
const targetCTA = computed(() => data.value?.productData?.[0]?.targetCTA || '')
|
|
41
|
+
const selectedPlanId = computed(() => (route.query.plan as string) || '')
|
|
42
|
+
|
|
43
|
+
useHead({
|
|
44
|
+
title: computed(() => `Đăng ký khóa học - ${data.value?.productData?.[0]?.product?.title || 'Proxmox HCI'}`),
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="bg-ink text-cloud font-body antialiased">
|
|
50
|
+
<TheHeader :target-c-t-a="targetCTA" :slug="slug" />
|
|
51
|
+
|
|
52
|
+
<div class="min-h-screen bg-[#f4f5f9] px-6 py-[7rem] font-sans text-slate-900">
|
|
53
|
+
<div class="mx-auto grid max-w-[1000px] grid-cols-1 items-start gap-6 md:grid-cols-[340px_1fr]">
|
|
54
|
+
<!-- Left column -->
|
|
55
|
+
<aside class="flex flex-col gap-4">
|
|
56
|
+
<div class="rounded-2xl border border-[#e7e9f0] bg-white p-7">
|
|
57
|
+
<h2 class="mb-5 text-[17px] font-bold text-slate-900">Vì sao chọn chúng tôi</h2>
|
|
58
|
+
<ul class="flex flex-col gap-[22px]">
|
|
59
|
+
<li class="flex items-start gap-3">
|
|
60
|
+
<span
|
|
61
|
+
class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
|
|
62
|
+
<svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
63
|
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
64
|
+
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
65
|
+
<circle cx="9" cy="7" r="4" />
|
|
66
|
+
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
67
|
+
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
68
|
+
</svg>
|
|
69
|
+
</span>
|
|
70
|
+
<span class="flex flex-col gap-[3px]">
|
|
71
|
+
<span class="text-sm font-semibold text-slate-900">Lớp nhỏ, kèm sát</span>
|
|
72
|
+
<span class="text-[13px] leading-relaxed text-gray-500">Sĩ số giới hạn, giảng viên hỗ trợ trực tiếp
|
|
73
|
+
từng học viên.</span>
|
|
74
|
+
</span>
|
|
75
|
+
</li>
|
|
76
|
+
<li class="flex items-start gap-3">
|
|
77
|
+
<span
|
|
78
|
+
class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
|
|
79
|
+
<svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
80
|
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
81
|
+
<path d="M22 10 12 5 2 10l10 5 10-5Z" />
|
|
82
|
+
<path d="M6 12v5c0 1.66 2.69 3 6 3s6-1.34 6-3v-5" />
|
|
83
|
+
</svg>
|
|
84
|
+
</span>
|
|
85
|
+
<span class="flex flex-col gap-[3px]">
|
|
86
|
+
<span class="text-sm font-semibold text-slate-900">Thực hành hệ thống thật</span>
|
|
87
|
+
<span class="text-[13px] leading-relaxed text-gray-500">Thao tác trên cụm Proxmox HCI thực tế, không
|
|
88
|
+
chỉ lý thuyết.</span>
|
|
89
|
+
</span>
|
|
90
|
+
</li>
|
|
91
|
+
<li class="flex items-start gap-3">
|
|
92
|
+
<span
|
|
93
|
+
class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
|
|
94
|
+
<svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
95
|
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
96
|
+
<circle cx="12" cy="12" r="10" />
|
|
97
|
+
<path d="m9 12 2 2 4-4" />
|
|
98
|
+
</svg>
|
|
99
|
+
</span>
|
|
100
|
+
<span class="flex flex-col gap-[3px]">
|
|
101
|
+
<span class="text-sm font-semibold text-slate-900">Chứng nhận hoàn thành</span>
|
|
102
|
+
<span class="text-[13px] leading-relaxed text-gray-500">Cấp chứng nhận và hỗ trợ tư vấn nghề nghiệp
|
|
103
|
+
sau khóa học.</span>
|
|
104
|
+
</span>
|
|
105
|
+
</li>
|
|
106
|
+
</ul>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div class="rounded-2xl border border-[#dbe4fd] bg-[#eef2ff] px-6 py-[22px]">
|
|
110
|
+
<p class="mb-2 flex items-center gap-[9px] text-sm font-bold text-[#2f5fe0]">
|
|
111
|
+
<svg class="h-[17px] w-[17px] flex-none" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
112
|
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
113
|
+
<path d="M23 7 16 12l7 5V7Z" />
|
|
114
|
+
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
|
115
|
+
</svg>
|
|
116
|
+
Khai giảng linh hoạt
|
|
117
|
+
</p>
|
|
118
|
+
<p class="text-[13px] leading-relaxed text-gray-600">
|
|
119
|
+
Lịch học buổi tối và cuối tuần, phù hợp cho cả người đi làm. Hỗ trợ ghi hình buổi học để xem lại.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
</aside>
|
|
123
|
+
|
|
124
|
+
<!-- Right column: form -->
|
|
125
|
+
<RegistrationForm :plans="plans" :selected-plan-id="selectedPlanId" />
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<TheFooter />
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|