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.
Files changed (85) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/app/app.config.ts +5 -0
  3. package/app/app.vue +11 -0
  4. package/app/assets/css/main.css +113 -0
  5. package/app/components/.gitkeep +0 -0
  6. package/app/components/ui/sonner/Sonner.vue +62 -0
  7. package/app/components/ui/sonner/index.ts +1 -0
  8. package/app/composables/.gitkeep +0 -0
  9. package/app/layers/solution/app/assets/proxmox-css/main.css +89 -0
  10. package/app/layers/solution/app/assets/proxmox-css/proxmox-utils.css +68 -0
  11. package/app/layers/solution/app/components/ClusterDiagram.vue +72 -0
  12. package/app/layers/solution/app/components/CoursesSection.vue +245 -0
  13. package/app/layers/solution/app/components/CtaSection.vue +347 -0
  14. package/app/layers/solution/app/components/DynamicPageSections.vue +103 -0
  15. package/app/layers/solution/app/components/FeaturesSection.vue +67 -0
  16. package/app/layers/solution/app/components/HeroSection.vue +144 -0
  17. package/app/layers/solution/app/components/PricingPlansSection.vue +396 -0
  18. package/app/layers/solution/app/components/RegistrationForm.vue +283 -0
  19. package/app/layers/solution/app/components/RegistrationSection.vue +24 -0
  20. package/app/layers/solution/app/components/RegistrationSidebar.vue +70 -0
  21. package/app/layers/solution/app/components/TestimonialsSection.vue +58 -0
  22. package/app/layers/solution/app/components/TheFooter.vue +61 -0
  23. package/app/layers/solution/app/components/TheHeader.vue +193 -0
  24. package/app/layers/solution/app/components/proxmox/AppIcon.vue +45 -0
  25. package/app/layers/solution/app/components/proxmox/ComparisonSection.vue +54 -0
  26. package/app/layers/solution/app/components/proxmox/FaqSection.vue +58 -0
  27. package/app/layers/solution/app/components/proxmox/HciOverviewSection.vue +37 -0
  28. package/app/layers/solution/app/components/proxmox/HeroSection.vue +74 -0
  29. package/app/layers/solution/app/components/proxmox/TestimonialsSection.vue +44 -0
  30. package/app/layers/solution/app/components/proxmox/TrainingBenefitsSection.vue +37 -0
  31. package/app/layers/solution/app/components/proxmox/VeFeaturesSection.vue +55 -0
  32. package/app/layers/solution/app/composables/useActiveSection.ts +47 -0
  33. package/app/layers/solution/app/composables/useAuthApi.ts +244 -0
  34. package/app/layers/solution/app/composables/useAuthState.ts +16 -0
  35. package/app/layers/solution/app/composables/useConsultationRequest.ts +8 -0
  36. package/app/layers/solution/app/composables/useCookie.ts +79 -0
  37. package/app/layers/solution/app/composables/useCookieHelper.ts +81 -0
  38. package/app/layers/solution/app/composables/useCrmApi.ts +32 -0
  39. package/app/layers/solution/app/composables/useDynamicCollection.ts +23 -0
  40. package/app/layers/solution/app/composables/usePlanNormalizer.ts +45 -0
  41. package/app/layers/solution/app/composables/useScrollAnimate.ts +40 -0
  42. package/app/layers/solution/app/composables/useScrollToSection.ts +13 -0
  43. package/app/layers/solution/app/composables/useSolutionApi.ts +73 -0
  44. package/app/layers/solution/app/composables/useToast.ts +14 -0
  45. package/app/layers/solution/app/data/pricingPlans.ts +150 -0
  46. package/app/layers/solution/app/data/proxmox-content.ts +158 -0
  47. package/app/layers/solution/app/images/Proxmox HCI.png +0 -0
  48. package/app/layers/solution/app/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
  49. package/app/layers/solution/app/layouts/default.vue +5 -0
  50. package/app/layers/solution/app/nuxt.config.ts +15 -0
  51. package/app/layers/solution/app/pages/[slug]/[slug2]/[slug3]/index.vue +22 -0
  52. package/app/layers/solution/app/pages/[slug]/[slug2]/index.vue +23 -0
  53. package/app/layers/solution/app/pages/[slug]/index.vue +22 -0
  54. package/app/layers/solution/app/pages/cap-nhat-thong-tin/index.vue.disabled +302 -0
  55. package/app/layers/solution/app/pages/dang-ky/index.vue.disabled +627 -0
  56. package/app/layers/solution/app/pages/dang-ky-tu-van/khoa-hoc-proxmox.vue.disabled +39 -0
  57. package/app/layers/solution/app/pages/dang-nhap/index.vue.disabled +208 -0
  58. package/app/layers/solution/app/pages/doi-mat-khau/index.vue.disabled +237 -0
  59. package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/index.vue.disabled +37 -0
  60. package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue.disabled +45 -0
  61. package/app/layers/solution/app/pages/giai-phap-proxmox/dang-ky-khoa-hoc/[slug].vue.disabled +56 -0
  62. package/app/layers/solution/app/pages/quen-mat-khau/index.vue.disabled +136 -0
  63. package/app/layers/solution/app/pages/xac-thuc/index.vue.disabled +320 -0
  64. package/app/layers/solution/app/plugins/reveal.ts +56 -0
  65. package/app/layers/solution/app/public/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
  66. package/app/layers/solution/app/public/images/logo-lvs-1300x330.png +0 -0
  67. package/app/layouts/auth.vue +71 -0
  68. package/app/lib/utils.ts +7 -0
  69. package/app/middleware/.gitkeep +0 -0
  70. package/app/modules/layer-route-priority.ts +60 -0
  71. package/app/pages/index.vue +10 -0
  72. package/app/pages/san-pham-proxmox.vue +8 -0
  73. package/app/plugins/.gitkeep +0 -0
  74. package/app/plugins/sdk.ts +29 -0
  75. package/app/stores/.gitkeep +0 -0
  76. package/app/types/sdk.d.ts +15 -0
  77. package/components.json +24 -0
  78. package/nuxt.config.ts +105 -0
  79. package/package.json +52 -0
  80. package/public/.gitkeep +0 -0
  81. package/server/api/.gitkeep +0 -0
  82. package/server/utils/.gitkeep +0 -0
  83. package/tailwind.config.js +96 -0
  84. package/tsconfig.base.json +18 -0
  85. package/tsconfig.json +18 -0
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'default' })
3
+
4
+ const route = useRoute()
5
+ const { getSlugData } = useSolutionApi()
6
+
7
+ const slug3 = computed(() => route.params.slug3 as string)
8
+
9
+ const { data } = await useAsyncData(
10
+ `slug3-${slug3.value}`,
11
+ () => getSlugData(slug3.value),
12
+ { server: true }
13
+ )
14
+ </script>
15
+
16
+ <template>
17
+ <DynamicPageSections
18
+ :field-resumes="data?.resumeData?.fieldResumes"
19
+ :target-c-t-a="data?.productData?.targetCTA"
20
+ :slug="data?.productData?.slug || slug3"
21
+ />
22
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'default' })
3
+
4
+ const route = useRoute()
5
+ const { getSlugData } = useSolutionApi()
6
+
7
+ const slug2 = computed(() => route.params.slug2 as string)
8
+
9
+ const { data } = await useAsyncData(
10
+ `slug2-${slug2.value}`,
11
+ () => getSlugData(slug2.value),
12
+ { server: true }
13
+ )
14
+ </script>
15
+
16
+ <template>
17
+ <DynamicPageSections
18
+ :field-resumes="data?.resumeData?.fieldResumes"
19
+ :target-c-t-a="data?.productData?.targetCTA"
20
+ :slug="data?.productData?.slug || slug2"
21
+ />
22
+ <!-- {{ data }} -->
23
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'default' })
3
+
4
+ const route = useRoute()
5
+ const { getSlugData } = useSolutionApi()
6
+
7
+ const slug = computed(() => route.params.slug as string)
8
+
9
+ const { data } = await useAsyncData(
10
+ `slug-${slug.value}`,
11
+ () => getSlugData(slug.value),
12
+ { server: true }
13
+ )
14
+ </script>
15
+
16
+ <template>
17
+ <DynamicPageSections
18
+ :field-resumes="data?.resumeData?.fieldResumes"
19
+ :target-c-t-a="data?.productData?.targetCTA"
20
+ :slug="data?.productData?.slug || slug"
21
+ />
22
+ </template>
@@ -0,0 +1,302 @@
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-lg">
6
+ <!-- Header -->
7
+ <div class="mb-8 text-center">
8
+ <h1 class="font-['Montserrat'] font-extrabold text-2xl text-slate-900 mb-2">Thông tin cá nhân</h1>
9
+ <p class="text-slate-500 text-sm">Cập nhật thông tin tài khoản và đặt mật khẩu.</p>
10
+ </div>
11
+
12
+ <!-- Loading skeleton -->
13
+ <div v-if="pageLoading" class="bg-white rounded-2xl shadow-sm border border-slate-200/80 p-8 space-y-5">
14
+ <div v-for="i in 4" :key="i" class="space-y-1.5">
15
+ <div class="h-4 w-24 bg-slate-200 rounded animate-pulse" />
16
+ <div class="h-11 bg-slate-100 rounded-[10px] animate-pulse" />
17
+ </div>
18
+ </div>
19
+
20
+ <!-- Form card -->
21
+ <div v-else class="bg-white rounded-2xl shadow-sm border border-slate-200/80 p-8">
22
+
23
+ <!-- Not logged in -->
24
+ <div v-if="!accessToken" class="text-center space-y-4 py-4">
25
+ <div class="w-14 h-14 rounded-[10px] bg-amber-50 flex items-center justify-center mx-auto">
26
+ <Icon name="lucide:lock" class="text-amber-500 text-[24px]" />
27
+ </div>
28
+ <p class="text-slate-600 text-sm">Bạn cần đăng nhập để cập nhật thông tin.</p>
29
+ <NuxtLink to="/dang-nhap"
30
+ class="inline-flex items-center gap-2 bg-[#004AC6] hover:bg-[#1D4ED8] text-white font-bold px-6 py-3 rounded-[10px] transition-all">
31
+ Đăng nhập ngay
32
+ </NuxtLink>
33
+ </div>
34
+
35
+ <!-- Success state -->
36
+ <div v-else-if="success" class="text-center space-y-4 py-4">
37
+ <div class="w-16 h-16 rounded-[10px] bg-emerald-50 flex items-center justify-center mx-auto">
38
+ <Icon name="lucide:check-circle" class="text-emerald-500 text-[32px]" />
39
+ </div>
40
+ <h3 class="font-['Montserrat'] font-bold text-lg text-slate-900">Cập nhật thành công!</h3>
41
+ <p class="text-slate-500 text-sm">Thông tin tài khoản đã được lưu.</p>
42
+ <NuxtLink to="/"
43
+ class="inline-flex items-center gap-2 bg-[#004AC6] hover:bg-[#1D4ED8] text-white font-bold px-6 py-3 rounded-[10px] transition-all">
44
+ Về trang chủ
45
+ </NuxtLink>
46
+ </div>
47
+
48
+ <!-- Update form -->
49
+ <form v-else @submit.prevent="onSubmit" class="space-y-5">
50
+ <!-- Avatar placeholder -->
51
+ <div class="flex items-center gap-4 pb-4 border-b border-slate-100">
52
+ <div
53
+ class="w-14 h-14 rounded-[10px] bg-gradient-to-br from-[#004AC6] to-[#1D4ED8] flex items-center justify-center text-white font-bold text-xl shrink-0">
54
+ {{ avatarInitial }}
55
+ </div>
56
+ <div>
57
+ <p class="font-semibold text-slate-900 text-sm">{{ form.fullName || 'Chưa cập nhật' }}</p>
58
+ <p class="text-xs text-slate-400 mt-0.5">{{ form.phone }}</p>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- Full name -->
63
+ <div>
64
+ <label for="fullName" class="block text-sm font-semibold text-slate-700 mb-1.5">Họ và tên</label>
65
+ <div class="relative">
66
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
67
+ <Icon name="lucide:user" class="text-[17px]" />
68
+ </span>
69
+ <input id="fullName" v-model="form.fullName" type="text" autocomplete="name" placeholder="Nhập họ và tên"
70
+ :class="[
71
+ 'w-full pl-10 pr-4 py-3 rounded-[10px] border text-sm outline-none transition-all',
72
+ errors.fullName
73
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
74
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
75
+ ]" />
76
+ </div>
77
+ <p v-if="errors.fullName" class="mt-1 text-xs text-red-500 flex items-center gap-1">
78
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.fullName }}
79
+ </p>
80
+ </div>
81
+
82
+ <!-- Email -->
83
+ <div>
84
+ <label for="email" class="block text-sm font-semibold text-slate-700 mb-1.5">Email</label>
85
+ <div class="relative">
86
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
87
+ <Icon name="lucide:mail" class="text-[17px]" />
88
+ </span>
89
+ <input id="email" v-model="form.email" type="email" autocomplete="email" placeholder="email@example.com"
90
+ :class="[
91
+ 'w-full pl-10 pr-4 py-3 rounded-[10px] border text-sm outline-none transition-all',
92
+ errors.email
93
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
94
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
95
+ ]" />
96
+ </div>
97
+ <p v-if="errors.email" class="mt-1 text-xs text-red-500 flex items-center gap-1">
98
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.email }}
99
+ </p>
100
+ </div>
101
+
102
+ <!-- Phone (readonly) -->
103
+ <div>
104
+ <label for="phone" class="block text-sm font-semibold text-slate-700 mb-1.5">Số điện thoại</label>
105
+ <div class="relative">
106
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
107
+ <Icon name="lucide:phone" class="text-[17px]" />
108
+ </span>
109
+ <input id="phone" :value="form.phone" type="tel" readonly
110
+ class="w-full pl-10 pr-4 py-3 rounded-[10px] border border-slate-200 bg-slate-100 text-slate-500 text-sm cursor-not-allowed" />
111
+ </div>
112
+ </div>
113
+
114
+ <!-- Password -->
115
+ <div class="pt-2 border-t border-slate-100">
116
+ <p class="text-xs text-slate-400 mb-4">Đặt mật khẩu để đăng nhập lần sau</p>
117
+
118
+ <div class="space-y-5">
119
+ <div>
120
+ <label for="password" class="block text-sm font-semibold text-slate-700 mb-1.5">Mật khẩu</label>
121
+ <div class="relative">
122
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
123
+ <Icon name="lucide:lock" class="text-[17px]" />
124
+ </span>
125
+ <input
126
+ id="password"
127
+ v-model="form.password"
128
+ :type="showPassword ? 'text' : 'password'"
129
+ autocomplete="new-password"
130
+ placeholder="Tối thiểu 6 ký tự"
131
+ :class="[
132
+ 'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
133
+ errors.password
134
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
135
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
136
+ ]"
137
+ />
138
+ <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">
139
+ <Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
140
+ </button>
141
+ </div>
142
+ <p v-if="errors.password" class="mt-1 text-xs text-red-500 flex items-center gap-1">
143
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.password }}
144
+ </p>
145
+ </div>
146
+
147
+ <div>
148
+ <label for="confirmPassword" class="block text-sm font-semibold text-slate-700 mb-1.5">Xác nhận mật khẩu</label>
149
+ <div class="relative">
150
+ <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">
151
+ <Icon name="lucide:lock" class="text-[17px]" />
152
+ </span>
153
+ <input
154
+ id="confirmPassword"
155
+ v-model="form.confirmPassword"
156
+ :type="showConfirm ? 'text' : 'password'"
157
+ autocomplete="new-password"
158
+ placeholder="Nhập lại mật khẩu"
159
+ :class="[
160
+ 'w-full pl-10 pr-11 py-3 rounded-[10px] border text-sm outline-none transition-all',
161
+ errors.confirmPassword
162
+ ? 'border-red-400 bg-red-50 focus:border-red-500 focus:ring-2 focus:ring-red-100'
163
+ : 'border-slate-200 bg-slate-50 focus:border-[#004AC6] focus:ring-2 focus:ring-blue-100 focus:bg-white'
164
+ ]"
165
+ />
166
+ <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">
167
+ <Icon :name="showConfirm ? 'lucide:eye-off' : 'lucide:eye'" class="text-[17px]" />
168
+ </button>
169
+ </div>
170
+ <p v-if="errors.confirmPassword" class="mt-1 text-xs text-red-500 flex items-center gap-1">
171
+ <Icon name="lucide:alert-circle" class="text-[13px]" /> {{ errors.confirmPassword }}
172
+ </p>
173
+ </div>
174
+ </div>
175
+ </div>
176
+
177
+ <div v-if="apiError"
178
+ class="flex items-start gap-2 bg-red-50 border border-red-200 rounded-[10px] px-4 py-3 text-sm text-red-700">
179
+ <Icon name="lucide:alert-triangle" class="text-[16px] mt-0.5 shrink-0" />
180
+ <span>{{ apiError }}</span>
181
+ </div>
182
+
183
+ <!-- Submit -->
184
+ <button type="submit" :disabled="loading"
185
+ 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">
186
+ <span v-if="!loading">Cập nhật thông tin</span>
187
+ <span v-else class="flex items-center gap-2">
188
+ <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
189
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
190
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
191
+ </svg>
192
+ Đang lưu...
193
+ </span>
194
+ </button>
195
+ </form>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ <TheFooter/>
200
+ </div>
201
+ </template>
202
+
203
+ <script setup lang="ts">
204
+ definePageMeta({ layout: 'default' })
205
+
206
+ useHead({ title: 'Cập nhật thông tin — Long Vân' })
207
+
208
+ const { updateInfo, getUserByToken } = useAuthApi()
209
+ const { getCookie } = useCookieHelper()
210
+ const config = useRuntimeConfig()
211
+
212
+ const tokenKey = config.public.ACCESSTOKEN_NAME as string
213
+ const accessToken = ref('')
214
+ const pageLoading = ref(true)
215
+ const loading = ref(false)
216
+ const success = ref(false)
217
+ const apiError = ref('')
218
+ const showPassword = ref(false)
219
+ const showConfirm = ref(false)
220
+
221
+ const form = reactive({ fullName: '', email: '', phone: '', password: '', confirmPassword: '' })
222
+ const errors = reactive({ fullName: '', email: '', phone: '', password: '', confirmPassword: '' })
223
+
224
+ const avatarInitial = computed(() => {
225
+ const name = form.fullName.trim()
226
+ if (!name) return '?'
227
+ return name.charAt(0).toUpperCase()
228
+ })
229
+
230
+ function validate() {
231
+ errors.fullName = ''
232
+ errors.email = ''
233
+ errors.password = ''
234
+ errors.confirmPassword = ''
235
+ let valid = true
236
+
237
+ if (!form.fullName.trim()) {
238
+ errors.fullName = 'Vui lòng nhập họ và tên'
239
+ valid = false
240
+ }
241
+ if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
242
+ errors.email = 'Email không hợp lệ'
243
+ valid = false
244
+ }
245
+ if (form.password && form.password.length < 6) {
246
+ errors.password = 'Mật khẩu tối thiểu 6 ký tự'
247
+ valid = false
248
+ }
249
+ if (form.password && form.password !== form.confirmPassword) {
250
+ errors.confirmPassword = 'Mật khẩu xác nhận không khớp'
251
+ valid = false
252
+ }
253
+ return valid
254
+ }
255
+
256
+ async function onSubmit() {
257
+ if (!validate()) return
258
+ loading.value = true
259
+ apiError.value = ''
260
+
261
+ try {
262
+ await updateInfo(
263
+ accessToken.value,
264
+ {
265
+ fullName: form.fullName.trim(),
266
+ phone: form.phone,
267
+ email: form.email.trim() || undefined,
268
+ },
269
+ '',
270
+ form.password || ''
271
+ )
272
+ success.value = true
273
+ } catch (err: any) {
274
+ apiError.value = err?.message || 'Cập nhật thất bại. Vui lòng thử lại.'
275
+ } finally {
276
+ loading.value = false
277
+ }
278
+ }
279
+
280
+ onMounted(async () => {
281
+ const token = getCookie(tokenKey)
282
+ if (!token) {
283
+ pageLoading.value = false
284
+ return
285
+ }
286
+
287
+ accessToken.value = token
288
+
289
+ try {
290
+ const res = await getUserByToken(token)
291
+ if (res) {
292
+ form.fullName = res.fullName || ''
293
+ form.email = res.email || ''
294
+ form.phone = res.phone || ''
295
+ }
296
+ } catch (e) {
297
+ // Token expired or invalid
298
+ } finally {
299
+ pageLoading.value = false
300
+ }
301
+ })
302
+ </script>