nuxt-layer-core 2.0.2 → 2.0.3
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/app/layers/solution/app/components/CoursesSection.vue +42 -43
- package/app/layers/solution/app/components/CtaSection.vue +40 -78
- package/app/layers/solution/app/components/DynamicPageSections.vue +103 -0
- package/app/layers/solution/app/components/PricingPlansSection.vue +86 -16
- package/app/layers/solution/app/components/RegistrationForm.vue +33 -6
- 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/TheHeader.vue +11 -7
- package/app/layers/solution/app/composables/usePlanNormalizer.ts +45 -0
- package/app/layers/solution/app/composables/useSolutionApi.ts +18 -0
- package/app/layers/solution/app/nuxt.config.ts +6 -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/dang-ky-tu-van/khoa-hoc-proxmox.vue.disabled +39 -0
- package/app/layers/solution/app/pages/giai-phap-proxmox/dang-ky-khoa-hoc/[slug].vue.disabled +56 -0
- package/app/pages/index.vue +2 -1
- package/nuxt.config.ts +1 -0
- package/package.json +1 -1
- package/app/layers/solution/app/pages/giai-phap-proxmox/dang-ky-khoa-hoc/[slug].vue +0 -131
- 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
- /package/app/layers/solution/app/pages/cap-nhat-thong-tin/{index.vue → index.vue.disabled} +0 -0
- /package/app/layers/solution/app/pages/dang-ky/{index.vue → index.vue.disabled} +0 -0
- /package/app/layers/solution/app/pages/dang-nhap/{index.vue → index.vue.disabled} +0 -0
- /package/app/layers/solution/app/pages/doi-mat-khau/{index.vue → index.vue.disabled} +0 -0
- /package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/{index.vue → index.vue.disabled} +0 -0
- /package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/{index.vue → index.vue.disabled} +0 -0
- /package/app/layers/solution/app/pages/quen-mat-khau/{index.vue → index.vue.disabled} +0 -0
- /package/app/layers/solution/app/pages/xac-thuc/{index.vue → index.vue.disabled} +0 -0
|
@@ -120,9 +120,17 @@
|
|
|
120
120
|
useScrollAnimate('.course-card', 'fade-up')
|
|
121
121
|
|
|
122
122
|
const props = defineProps({
|
|
123
|
-
plans: { type: Object, default: null },
|
|
124
123
|
targetCTA: { type: String, default: '' },
|
|
125
124
|
slug: { type: String, default: '' },
|
|
125
|
+
itemId01Groups01: { type: Object, default: null },
|
|
126
|
+
itemId02Groups01: { type: Object, default: null },
|
|
127
|
+
itemId03Groups01: { type: Object, default: null },
|
|
128
|
+
itemId01Groups02: { type: Object, default: null },
|
|
129
|
+
itemId02Groups02: { type: Object, default: null },
|
|
130
|
+
itemId03Groups02: { type: Object, default: null },
|
|
131
|
+
itemId01Groups03: { type: Object, default: null },
|
|
132
|
+
itemId02Groups03: { type: Object, default: null },
|
|
133
|
+
itemId03Groups03: { type: Object, default: null },
|
|
126
134
|
})
|
|
127
135
|
|
|
128
136
|
const isConsultant = computed(() => props.targetCTA === 'CONSULTANT')
|
|
@@ -130,8 +138,14 @@ const router = useRouter()
|
|
|
130
138
|
|
|
131
139
|
function handleSelectPlan(plan) {
|
|
132
140
|
router.push({
|
|
133
|
-
path: `/
|
|
134
|
-
query: {
|
|
141
|
+
path: `/dang-ky-tu-van/khoa-hoc-proxmox`,
|
|
142
|
+
query: {
|
|
143
|
+
plan: plan.id,
|
|
144
|
+
name: plan.name,
|
|
145
|
+
level: plan.level,
|
|
146
|
+
duration: plan.duration,
|
|
147
|
+
description: plan.description || '',
|
|
148
|
+
},
|
|
135
149
|
})
|
|
136
150
|
}
|
|
137
151
|
|
|
@@ -200,47 +214,32 @@ const samplePlans = [
|
|
|
200
214
|
}
|
|
201
215
|
]
|
|
202
216
|
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
group.children?.map((field) => [
|
|
215
|
-
field?.fieldName?.split('@')?.pop(),
|
|
216
|
-
field?.value
|
|
217
|
-
]) || []
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
// const included = (fields.config_product || '')
|
|
221
|
-
// .replace(/<br\s*\/?>/gi, '\n')
|
|
222
|
-
// .replace(/<\/p>/gi, '\n')
|
|
223
|
-
// .replace(/<[^>]*>/g, '')
|
|
224
|
-
// .split('✓')
|
|
225
|
-
// .map((item) => item.trim())
|
|
226
|
-
// .filter(Boolean)
|
|
227
|
-
|
|
228
|
-
return {
|
|
229
|
-
id: fields.productid || group.fieldName,
|
|
230
|
-
name: fields.title || '',
|
|
231
|
-
level: fields.level || '',
|
|
232
|
-
duration: fields.time || '',
|
|
233
|
-
description: fields.short_description || '',
|
|
234
|
-
included: fields.config_product,
|
|
235
|
-
icon: fields.icon || 'lucide:server',
|
|
236
|
-
highlight: index === 1,
|
|
237
|
-
outcome: fields.outcome || ''
|
|
238
|
-
}
|
|
239
|
-
}) || []
|
|
240
|
-
}
|
|
217
|
+
const rawItems = computed(() => [
|
|
218
|
+
props.itemId01Groups01,
|
|
219
|
+
props.itemId02Groups01,
|
|
220
|
+
props.itemId03Groups01,
|
|
221
|
+
props.itemId01Groups02,
|
|
222
|
+
props.itemId02Groups02,
|
|
223
|
+
props.itemId03Groups02,
|
|
224
|
+
props.itemId01Groups03,
|
|
225
|
+
props.itemId02Groups03,
|
|
226
|
+
props.itemId03Groups03,
|
|
227
|
+
])
|
|
241
228
|
|
|
242
229
|
const formattedPlans = computed(() => {
|
|
243
|
-
const
|
|
244
|
-
|
|
230
|
+
const items = rawItems.value.map(normalizePlanItem).filter(Boolean)
|
|
231
|
+
if (!items.length) return samplePlans
|
|
232
|
+
|
|
233
|
+
return items.map((item, index) => ({
|
|
234
|
+
id: item.id || `plan-${index}`,
|
|
235
|
+
name: item.name,
|
|
236
|
+
level: item.level,
|
|
237
|
+
duration: item.duration,
|
|
238
|
+
description: item.description,
|
|
239
|
+
included: item.features,
|
|
240
|
+
icon: item.icon,
|
|
241
|
+
highlight: index === 1,
|
|
242
|
+
outcome: '',
|
|
243
|
+
}))
|
|
245
244
|
})
|
|
246
245
|
</script>
|
|
@@ -61,19 +61,9 @@
|
|
|
61
61
|
|
|
62
62
|
<!-- Searchable dropdown -->
|
|
63
63
|
<div class="space-y-1.5" ref="dropdownRef">
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
</label>
|
|
68
|
-
<button v-if="consultationRequest" type="button" @click="clearContext"
|
|
69
|
-
class="flex items-center gap-1 text-[11px] font-medium text-[#004AC6] hover:underline cursor-pointer">
|
|
70
|
-
<Icon name="lucide:x" class="w-3 h-3" />
|
|
71
|
-
Xem tất cả
|
|
72
|
-
</button>
|
|
73
|
-
</div>
|
|
74
|
-
<p v-if="consultationRequest" class="text-[11px] text-slate-400 -mt-1">
|
|
75
|
-
Đã lọc theo mục bạn vừa chọn: {{ categoryLabels[consultationRequest.category] }}
|
|
76
|
-
</p>
|
|
64
|
+
<label class="text-xs font-bold uppercase text-slate-500">
|
|
65
|
+
Khóa học quan tâm <span class="text-red-500">*</span>
|
|
66
|
+
</label>
|
|
77
67
|
<div class="relative">
|
|
78
68
|
<div @click="toggleDropdown"
|
|
79
69
|
class="w-full px-4 py-3 border rounded-[10px] text-sm text-slate-900 cursor-pointer flex items-center justify-between transition-all"
|
|
@@ -155,48 +145,17 @@
|
|
|
155
145
|
</template>
|
|
156
146
|
|
|
157
147
|
<script setup>
|
|
158
|
-
import { hciPlans, workshopPlans, trainingPlans } from '../data/pricingPlans'
|
|
159
|
-
|
|
160
148
|
useScrollAnimate('.cta-card', 'scale-up')
|
|
161
149
|
|
|
162
150
|
const props = defineProps({
|
|
163
|
-
plans: { type: Object, default: null },
|
|
164
151
|
selectedPlanId: { type: String, default: null },
|
|
152
|
+
// Do DynamicPageSections gom từ CoursesSection / PricingPlansSection trên cùng trang (nếu có)
|
|
153
|
+
courses: { type: Array, default: () => [] },
|
|
154
|
+
pricingPlans: { type: Object, default: null },
|
|
165
155
|
})
|
|
166
156
|
|
|
167
|
-
const consultationRequest = useConsultationRequest()
|
|
168
|
-
|
|
169
|
-
const categoryLabels = {
|
|
170
|
-
hci: 'Proxmox HCI',
|
|
171
|
-
workshop: 'Workshop 1 ngày',
|
|
172
|
-
training: 'Đào tạo Proxmox',
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Items relevant to whichever plan the visitor just picked in PricingPlansSection
|
|
176
|
-
const contextPlans = computed(() => {
|
|
177
|
-
const category = consultationRequest.value?.category
|
|
178
|
-
if (category === 'hci') {
|
|
179
|
-
return hciPlans.map((p) => ({ id: p.id, name: p.name, level: 'Gói thuê hạ tầng', duration: p.unit, icon: 'lucide:server-cog' }))
|
|
180
|
-
}
|
|
181
|
-
if (category === 'workshop') {
|
|
182
|
-
return workshopPlans.map((p) => ({ id: p.id, name: p.name, level: 'Workshop', duration: p.tags?.[0] || '', icon: 'lucide:zap' }))
|
|
183
|
-
}
|
|
184
|
-
if (category === 'training') {
|
|
185
|
-
return trainingPlans.map((p) => ({ id: p.id, name: p.name, level: 'Khóa đào tạo', duration: p.tags?.[0] || '', icon: 'lucide:graduation-cap' }))
|
|
186
|
-
}
|
|
187
|
-
return null
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
function clearContext() {
|
|
191
|
-
consultationRequest.value = null
|
|
192
|
-
selectedCourse.value = null
|
|
193
|
-
touched.course = false
|
|
194
|
-
errors.course = ''
|
|
195
|
-
}
|
|
196
|
-
|
|
197
157
|
const { addOpportunity } = useCrmApi()
|
|
198
158
|
|
|
199
|
-
// Format plans from resumeData (same logic as CoursesSection)
|
|
200
159
|
const samplePlans = [
|
|
201
160
|
{ id: 'sample-1', name: 'Deployment & Management', icon: 'lucide:graduation-cap', level: 'Nền tảng', duration: '14 giờ' },
|
|
202
161
|
{ id: 'sample-2', name: 'Clustering & Shared Storage', icon: 'lucide:network', level: 'Chuyên sâu', duration: '14 giờ' },
|
|
@@ -213,30 +172,45 @@ function formatLevel(level) {
|
|
|
213
172
|
return labels[level || ''] || level || ''
|
|
214
173
|
}
|
|
215
174
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const fields = Object.fromEntries(
|
|
222
|
-
group.children?.map((field) => [field?.fieldName?.split('@')?.pop(), field?.value]) || []
|
|
223
|
-
)
|
|
224
|
-
return {
|
|
225
|
-
id: fields.productid || group.fieldName,
|
|
226
|
-
name: fields.title || '',
|
|
227
|
-
level: fields.level || '',
|
|
228
|
-
duration: fields.time || '',
|
|
229
|
-
icon: fields.icon || 'lucide:server',
|
|
230
|
-
}
|
|
231
|
-
}) || []
|
|
175
|
+
// CtaSection chỉ để tự gửi tư vấn -> chỉ show option KHÔNG có url riêng (option có url đã có nút "Đăng ký ngay" đi thẳng)
|
|
176
|
+
const pricingCategoryMeta = {
|
|
177
|
+
hci: { level: 'Gói thuê hạ tầng', icon: 'lucide:server-cog' },
|
|
178
|
+
workshop: { level: 'Workshop', icon: 'lucide:zap' },
|
|
179
|
+
training: { level: 'Khóa đào tạo', icon: 'lucide:graduation-cap' },
|
|
232
180
|
}
|
|
233
181
|
|
|
182
|
+
// Luôn gộp đủ data từ CoursesSection + PricingPlansSection (nếu trang có các component đó)
|
|
183
|
+
// props.courses / props.pricingPlans[category] đã được DynamicPageSections chuẩn hoá chung shape (normalizePlanItem)
|
|
234
184
|
const formattedPlans = computed(() => {
|
|
235
|
-
const
|
|
236
|
-
|
|
185
|
+
const courseOptions = props.courses
|
|
186
|
+
.filter((item) => !item.url)
|
|
187
|
+
.map((item) => ({
|
|
188
|
+
id: item.id,
|
|
189
|
+
name: item.name,
|
|
190
|
+
level: item.level,
|
|
191
|
+
duration: item.duration,
|
|
192
|
+
icon: item.icon,
|
|
193
|
+
}))
|
|
194
|
+
|
|
195
|
+
const pricingOptions = props.pricingPlans
|
|
196
|
+
? Object.entries(pricingCategoryMeta).flatMap(([category, meta]) =>
|
|
197
|
+
(props.pricingPlans[category] || [])
|
|
198
|
+
.filter((item) => !item.url)
|
|
199
|
+
.map((item) => ({
|
|
200
|
+
id: item.id,
|
|
201
|
+
name: item.name,
|
|
202
|
+
level: meta.level,
|
|
203
|
+
duration: item.duration,
|
|
204
|
+
icon: meta.icon,
|
|
205
|
+
}))
|
|
206
|
+
)
|
|
207
|
+
: []
|
|
208
|
+
|
|
209
|
+
const combined = [...courseOptions, ...pricingOptions]
|
|
210
|
+
return combined.length > 0 ? combined : samplePlans
|
|
237
211
|
})
|
|
238
212
|
|
|
239
|
-
const dropdownPlans = computed(() =>
|
|
213
|
+
const dropdownPlans = computed(() => formattedPlans.value)
|
|
240
214
|
|
|
241
215
|
// Form state
|
|
242
216
|
const form = reactive({ name: '', phone: '', email: '' })
|
|
@@ -263,18 +237,6 @@ watch(() => props.selectedPlanId, (id) => {
|
|
|
263
237
|
}
|
|
264
238
|
}, { immediate: true })
|
|
265
239
|
|
|
266
|
-
// Auto-select + activate the item the visitor just picked in PricingPlansSection
|
|
267
|
-
watch(consultationRequest, (request) => {
|
|
268
|
-
if (!request) return
|
|
269
|
-
const found = dropdownPlans.value.find(p => p.id === request.planId)
|
|
270
|
-
|| dropdownPlans.value.find(p => p.name === request.planName)
|
|
271
|
-
if (found) {
|
|
272
|
-
selectedCourse.value = found
|
|
273
|
-
touched.course = true
|
|
274
|
-
errors.course = ''
|
|
275
|
-
}
|
|
276
|
-
}, { immediate: true })
|
|
277
|
-
|
|
278
240
|
// Search filter
|
|
279
241
|
const filteredCourses = computed(() => {
|
|
280
242
|
if (!searchQuery.value) return dropdownPlans.value
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
fieldResumes?: Array<any> | null
|
|
4
|
+
targetCTA?: string
|
|
5
|
+
slug?: string
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const parseValue = (value: unknown) => {
|
|
9
|
+
if (typeof value !== 'string') return value
|
|
10
|
+
|
|
11
|
+
const trimmed = value.trim()
|
|
12
|
+
if (!trimmed) return null
|
|
13
|
+
|
|
14
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(trimmed)
|
|
17
|
+
} catch {
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const toCamelCase = (key: string) => key.replace(/_([a-z0-9])/gi, (_, c) => c.toUpperCase())
|
|
26
|
+
|
|
27
|
+
// Gom các item_id_0M_groups_0N (M: 1-3) của 1 group cụ thể thành mảng item hợp lệ
|
|
28
|
+
const pickGroupItems = (sectionProps: Record<string, any> | undefined, groupSuffix: string) => {
|
|
29
|
+
if (!sectionProps) return []
|
|
30
|
+
return [1, 2, 3]
|
|
31
|
+
.map((n) => sectionProps[`itemId0${n}Groups${groupSuffix}`])
|
|
32
|
+
.filter((item) => item && typeof item === 'object')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sections = computed(() => {
|
|
36
|
+
const list = (props.fieldResumes || [])
|
|
37
|
+
.filter((group: any) => group?.type === 'FIELD_GROUP' && Array.isArray(group?.children))
|
|
38
|
+
.map((group: any) => {
|
|
39
|
+
const componentProps: Record<string, any> = {}
|
|
40
|
+
let componentName = ''
|
|
41
|
+
|
|
42
|
+
for (const field of group.children) {
|
|
43
|
+
const key = field?.fieldName?.split('@')?.pop()
|
|
44
|
+
if (!key) continue
|
|
45
|
+
|
|
46
|
+
if (key === 'component_name') {
|
|
47
|
+
componentName = field?.value || ''
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
componentProps[toCamelCase(key)] = parseValue(field?.value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
component: componentName,
|
|
56
|
+
props: {
|
|
57
|
+
targetCTA: props.targetCTA,
|
|
58
|
+
slug: props.slug,
|
|
59
|
+
...componentProps,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.filter((section: { component: string }) => section.component)
|
|
64
|
+
|
|
65
|
+
// CtaSection không có data riêng trong CMS, lấy nhờ từ CoursesSection/PricingPlansSection nếu có trên trang
|
|
66
|
+
const coursesSection = list.find((s) => s.component === 'CoursesSection')
|
|
67
|
+
const pricingSection = list.find((s) => s.component === 'PricingPlansSection')
|
|
68
|
+
|
|
69
|
+
const courses = coursesSection
|
|
70
|
+
? ['01', '02', '03']
|
|
71
|
+
.flatMap((suffix) => pickGroupItems(coursesSection.props, suffix))
|
|
72
|
+
.map(normalizePlanItem)
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
: []
|
|
75
|
+
|
|
76
|
+
const pricingPlans = pricingSection
|
|
77
|
+
? {
|
|
78
|
+
hci: pickGroupItems(pricingSection.props, '01').map(normalizePlanItem).filter(Boolean),
|
|
79
|
+
workshop: pickGroupItems(pricingSection.props, '02').map(normalizePlanItem).filter(Boolean),
|
|
80
|
+
training: pickGroupItems(pricingSection.props, '03').map(normalizePlanItem).filter(Boolean),
|
|
81
|
+
}
|
|
82
|
+
: null
|
|
83
|
+
|
|
84
|
+
return list.map((section) => {
|
|
85
|
+
if (section.component !== 'CtaSection') return section
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
...section,
|
|
89
|
+
props: {
|
|
90
|
+
...section.props,
|
|
91
|
+
courses,
|
|
92
|
+
pricingPlans,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<template v-for="(section, index) in sections" :key="index">
|
|
101
|
+
<component :is="section.component" v-bind="section.props" />
|
|
102
|
+
</template>
|
|
103
|
+
</template>
|
|
@@ -186,18 +186,49 @@
|
|
|
186
186
|
<script setup>
|
|
187
187
|
import { hciPlans, workshopPlans, trainingPlans } from '../data/pricingPlans'
|
|
188
188
|
|
|
189
|
+
const props = defineProps({
|
|
190
|
+
groupsItem01: { type: String, default: '' },
|
|
191
|
+
groupsItem02: { type: String, default: '' },
|
|
192
|
+
groupsItem03: { type: String, default: '' },
|
|
193
|
+
itemId01Groups01: { type: Object, default: null },
|
|
194
|
+
itemId02Groups01: { type: Object, default: null },
|
|
195
|
+
itemId03Groups01: { type: Object, default: null },
|
|
196
|
+
itemId01Groups02: { type: Object, default: null },
|
|
197
|
+
itemId02Groups02: { type: Object, default: null },
|
|
198
|
+
itemId03Groups02: { type: Object, default: null },
|
|
199
|
+
itemId01Groups03: { type: Object, default: null },
|
|
200
|
+
itemId02Groups03: { type: Object, default: null },
|
|
201
|
+
itemId03Groups03: { type: Object, default: null },
|
|
202
|
+
})
|
|
203
|
+
|
|
189
204
|
useScrollAnimate('.pricing-header', 'fade-up')
|
|
190
205
|
|
|
191
|
-
const
|
|
192
|
-
|
|
206
|
+
const router = useRouter()
|
|
207
|
+
|
|
208
|
+
// Không có url riêng -> mở trang đăng ký tư vấn, mang theo thông tin gói qua query (giống CoursesSection.handleSelectPlan)
|
|
209
|
+
const categoryLevelLabels = {
|
|
210
|
+
hci: 'Gói thuê hạ tầng',
|
|
211
|
+
workshop: 'Workshop',
|
|
212
|
+
training: 'Khóa đào tạo',
|
|
213
|
+
}
|
|
193
214
|
|
|
194
215
|
function handleContactClick(plan) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
216
|
+
const priceText = plan.priceLabel || typeof plan.price === 'number'
|
|
217
|
+
? `${plan.priceLabel || ''}${formatPrice(plan.price)}đ${plan.unit ? ' ' + plan.unit : ''}`
|
|
218
|
+
: ''
|
|
219
|
+
|
|
220
|
+
router.push({
|
|
221
|
+
path: `/dang-ky-tu-van/khoa-hoc-proxmox`,
|
|
222
|
+
query: {
|
|
223
|
+
plan: plan.id || plan.name,
|
|
224
|
+
name: plan.name,
|
|
225
|
+
level: categoryLevelLabels[activeTab.value] || '',
|
|
226
|
+
// plan.unit là hậu tố giá ("/tháng", "/học viên"), không phải thời lượng -> ưu tiên tags[0] ("1 ngày · 8 giờ")
|
|
227
|
+
duration: plan.tags?.[0] || '',
|
|
228
|
+
price: priceText,
|
|
229
|
+
description: plan.desc || '',
|
|
230
|
+
},
|
|
231
|
+
})
|
|
201
232
|
}
|
|
202
233
|
|
|
203
234
|
const sectionRoot = ref(null)
|
|
@@ -223,11 +254,13 @@ onMounted(() => {
|
|
|
223
254
|
const activeTab = ref('hci')
|
|
224
255
|
const activePeriod = ref(36)
|
|
225
256
|
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
{ id: '
|
|
230
|
-
|
|
257
|
+
const groupLabel = (value, fallback) => (typeof value === 'string' && value.trim()) || fallback
|
|
258
|
+
|
|
259
|
+
const tabs = computed(() => [
|
|
260
|
+
{ id: 'hci', label: groupLabel(props.groupsItem01, 'Proxmox HCI'), icon: 'lucide:server-cog' },
|
|
261
|
+
{ id: 'workshop', label: groupLabel(props.groupsItem02, 'Workshop 1 ngày'), icon: 'lucide:zap' },
|
|
262
|
+
{ id: 'training', label: groupLabel(props.groupsItem03, 'Đào tạo Proxmox'), icon: 'lucide:layers' },
|
|
263
|
+
])
|
|
231
264
|
|
|
232
265
|
const billingPeriods = [
|
|
233
266
|
{ value: 12, label: 'Thuê 12 tháng' },
|
|
@@ -270,11 +303,48 @@ const tabContent = {
|
|
|
270
303
|
|
|
271
304
|
const activeTabData = computed(() => tabContent[activeTab.value])
|
|
272
305
|
|
|
306
|
+
// CMS đổ item theo shape chung (title/time/short_description/config_product/productId, giống CoursesSection)
|
|
307
|
+
// -> chuẩn hoá qua normalizePlanItem rồi map lại đúng field template pricing card đang cần (desc/unit/tags/features)
|
|
308
|
+
const toPricingPlan = (item) => {
|
|
309
|
+
const normalized = normalizePlanItem(item)
|
|
310
|
+
if (!normalized) return null
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
id: normalized.id,
|
|
314
|
+
name: normalized.name,
|
|
315
|
+
desc: normalized.description,
|
|
316
|
+
unit: normalized.duration,
|
|
317
|
+
priceLabel: normalized.priceLabel,
|
|
318
|
+
price: normalized.price,
|
|
319
|
+
prices: normalized.prices,
|
|
320
|
+
priceNote: normalized.priceNote,
|
|
321
|
+
tags: normalized.features,
|
|
322
|
+
features: normalized.features,
|
|
323
|
+
highlight: normalized.highlight,
|
|
324
|
+
url: normalized.url,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const hciItems = computed(() =>
|
|
329
|
+
[props.itemId01Groups01, props.itemId02Groups01, props.itemId03Groups01].map(toPricingPlan).filter(Boolean)
|
|
330
|
+
)
|
|
331
|
+
const workshopItems = computed(() =>
|
|
332
|
+
[props.itemId01Groups02, props.itemId02Groups02, props.itemId03Groups02].map(toPricingPlan).filter(Boolean)
|
|
333
|
+
)
|
|
334
|
+
const trainingItems = computed(() =>
|
|
335
|
+
[props.itemId01Groups03, props.itemId02Groups03, props.itemId03Groups03].map(toPricingPlan).filter(Boolean)
|
|
336
|
+
)
|
|
337
|
+
|
|
273
338
|
const activePlans = computed(() => {
|
|
274
|
-
if (activeTab.value === 'workshop')
|
|
275
|
-
|
|
339
|
+
if (activeTab.value === 'workshop') {
|
|
340
|
+
return workshopItems.value.length > 0 ? workshopItems.value : workshopPlans
|
|
341
|
+
}
|
|
342
|
+
if (activeTab.value === 'training') {
|
|
343
|
+
return trainingItems.value.length > 0 ? trainingItems.value : trainingPlans
|
|
344
|
+
}
|
|
276
345
|
|
|
277
|
-
|
|
346
|
+
const source = hciItems.value.length > 0 ? hciItems.value : hciPlans
|
|
347
|
+
return source.map((plan) => ({
|
|
278
348
|
...plan,
|
|
279
349
|
price: plan.prices?.[activePeriod.value],
|
|
280
350
|
priceNote: `Giá thuê khi ký hợp đồng ${activePeriod.value} tháng`,
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
const props = defineProps<{
|
|
3
|
-
plans
|
|
2
|
+
const props = withDefaults(defineProps<{
|
|
3
|
+
plans?: Array<{
|
|
4
4
|
id: string
|
|
5
5
|
name: string
|
|
6
6
|
level: string
|
|
7
7
|
duration: string
|
|
8
8
|
icon: string
|
|
9
|
+
price?: string
|
|
10
|
+
description?: string
|
|
9
11
|
}>
|
|
10
12
|
selectedPlanId?: string
|
|
11
|
-
}>()
|
|
13
|
+
}>(), {
|
|
14
|
+
plans: () => [],
|
|
15
|
+
})
|
|
12
16
|
|
|
13
17
|
const { addOpportunity } = useCrmApi()
|
|
14
18
|
|
|
@@ -72,6 +76,11 @@ function formatLevel(level?: string) {
|
|
|
72
76
|
return labels[level || ''] || level || ''
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
// Ghép level + duration, bỏ qua phần nào rỗng thay vì để dính dấu "·" trơ
|
|
80
|
+
function planMetaLine(plan: { level?: string; duration?: string }) {
|
|
81
|
+
return [formatLevel(plan.level), plan.duration].filter(Boolean).join(' · ')
|
|
82
|
+
}
|
|
83
|
+
|
|
75
84
|
function validateAll() {
|
|
76
85
|
; (['name', 'phone', 'email', 'course'] as const).forEach((f) => {
|
|
77
86
|
touched[f] = true
|
|
@@ -152,8 +161,26 @@ async function handleSubmit() {
|
|
|
152
161
|
<form @submit.prevent="handleSubmit">
|
|
153
162
|
<!-- Course selection -->
|
|
154
163
|
<div class="mb-5">
|
|
155
|
-
<label class="mb-[10px] block text-[13px] font-semibold text-gray-700">
|
|
156
|
-
|
|
164
|
+
<label class="mb-[10px] block text-[13px] font-semibold text-gray-700">
|
|
165
|
+
{{ plans.length === 1 ? 'Gói bạn đăng ký tư vấn' : 'Chọn khóa học' }}
|
|
166
|
+
</label>
|
|
167
|
+
|
|
168
|
+
<!-- Đã chọn sẵn 1 gói từ trang trước -> chỉ hiển thị thông tin, không cần chọn lại -->
|
|
169
|
+
<div v-if="plans.length === 1" class="flex flex-col gap-2 rounded-[10px] border border-[#2f5fe0] bg-[#eef3ff] px-4 py-[14px]">
|
|
170
|
+
<div class="flex items-center gap-3">
|
|
171
|
+
<span class="relative h-[18px] w-[18px] flex-none rounded-[10px] border-2 border-[#2f5fe0]">
|
|
172
|
+
<span class="absolute inset-[3px] rounded-[10px] bg-[#2f5fe0]" />
|
|
173
|
+
</span>
|
|
174
|
+
<span class="flex flex-col gap-[2px]">
|
|
175
|
+
<span class="text-sm font-semibold text-slate-900">{{ plans[0].name }}</span>
|
|
176
|
+
<span v-if="planMetaLine(plans[0])" class="text-[12.5px] text-gray-500">{{ planMetaLine(plans[0]) }}</span>
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
<p v-if="plans[0].price" class="pl-[30px] text-[12.5px] font-semibold text-[#2f5fe0]">{{ plans[0].price }}</p>
|
|
180
|
+
<p v-if="plans[0].description" class="pl-[30px] text-[12.5px] text-gray-500">{{ plans[0].description }}</p>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div v-else class="flex flex-col gap-[10px]">
|
|
157
184
|
<label v-for="course in plans" :key="course.id"
|
|
158
185
|
class="relative flex cursor-pointer items-center gap-3 rounded-[10px] border px-4 py-[14px] transition-colors"
|
|
159
186
|
:class="selectedCourseId === course.id ? 'border-[#2f5fe0] bg-[#eef3ff]' : 'border-[#e2e5ee] bg-white'">
|
|
@@ -165,7 +192,7 @@ async function handleSubmit() {
|
|
|
165
192
|
</span>
|
|
166
193
|
<span class="flex flex-col gap-[2px]">
|
|
167
194
|
<span class="text-sm font-semibold text-slate-900">{{ course.name }}</span>
|
|
168
|
-
<span class="text-[12.5px] text-gray-500">{{
|
|
195
|
+
<span v-if="planMetaLine(course)" class="text-[12.5px] text-gray-500">{{ planMetaLine(course) }}</span>
|
|
169
196
|
</span>
|
|
170
197
|
</label>
|
|
171
198
|
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
plans: Array<{
|
|
4
|
+
id: string
|
|
5
|
+
name: string
|
|
6
|
+
level: string
|
|
7
|
+
duration: string
|
|
8
|
+
icon: string
|
|
9
|
+
}>
|
|
10
|
+
selectedPlanId?: string
|
|
11
|
+
}>()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="min-h-screen bg-[#f4f5f9] px-6 py-[7rem] font-sans text-slate-900">
|
|
16
|
+
<div class="mx-auto grid max-w-[1000px] grid-cols-1 items-start gap-6 md:grid-cols-[340px_1fr]">
|
|
17
|
+
<!-- Left column -->
|
|
18
|
+
<RegistrationSidebar />
|
|
19
|
+
|
|
20
|
+
<!-- Right column: form -->
|
|
21
|
+
<RegistrationForm :plans="plans" :selected-plan-id="selectedPlanId" />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<aside class="flex flex-col gap-4">
|
|
3
|
+
<div class="rounded-2xl border border-[#e7e9f0] bg-white p-7">
|
|
4
|
+
<h2 class="mb-5 text-[17px] font-bold text-slate-900">Vì sao chọn chúng tôi</h2>
|
|
5
|
+
<ul class="flex flex-col gap-[22px]">
|
|
6
|
+
<li class="flex items-start gap-3">
|
|
7
|
+
<span
|
|
8
|
+
class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
|
|
9
|
+
<svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
10
|
+
stroke-linecap="round" stroke-linejoin="round">
|
|
11
|
+
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
12
|
+
<circle cx="9" cy="7" r="4" />
|
|
13
|
+
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
14
|
+
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
15
|
+
</svg>
|
|
16
|
+
</span>
|
|
17
|
+
<span class="flex flex-col gap-[3px]">
|
|
18
|
+
<span class="text-sm font-semibold text-slate-900">Lớp nhỏ, kèm sát</span>
|
|
19
|
+
<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
|
|
20
|
+
từng học viên.</span>
|
|
21
|
+
</span>
|
|
22
|
+
</li>
|
|
23
|
+
<li class="flex items-start gap-3">
|
|
24
|
+
<span
|
|
25
|
+
class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
|
|
26
|
+
<svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
27
|
+
stroke-linecap="round" stroke-linejoin="round">
|
|
28
|
+
<path d="M22 10 12 5 2 10l10 5 10-5Z" />
|
|
29
|
+
<path d="M6 12v5c0 1.66 2.69 3 6 3s6-1.34 6-3v-5" />
|
|
30
|
+
</svg>
|
|
31
|
+
</span>
|
|
32
|
+
<span class="flex flex-col gap-[3px]">
|
|
33
|
+
<span class="text-sm font-semibold text-slate-900">Thực hành hệ thống thật</span>
|
|
34
|
+
<span class="text-[13px] leading-relaxed text-gray-500">Thao tác trên cụm Proxmox HCI thực tế, không
|
|
35
|
+
chỉ lý thuyết.</span>
|
|
36
|
+
</span>
|
|
37
|
+
</li>
|
|
38
|
+
<li class="flex items-start gap-3">
|
|
39
|
+
<span
|
|
40
|
+
class="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[10px] bg-[#e8eefc] text-[#2f5fe0]">
|
|
41
|
+
<svg class="h-[17px] w-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
42
|
+
stroke-linecap="round" stroke-linejoin="round">
|
|
43
|
+
<circle cx="12" cy="12" r="10" />
|
|
44
|
+
<path d="m9 12 2 2 4-4" />
|
|
45
|
+
</svg>
|
|
46
|
+
</span>
|
|
47
|
+
<span class="flex flex-col gap-[3px]">
|
|
48
|
+
<span class="text-sm font-semibold text-slate-900">Chứng nhận hoàn thành</span>
|
|
49
|
+
<span class="text-[13px] leading-relaxed text-gray-500">Cấp chứng nhận và hỗ trợ tư vấn nghề nghiệp
|
|
50
|
+
sau khóa học.</span>
|
|
51
|
+
</span>
|
|
52
|
+
</li>
|
|
53
|
+
</ul>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="rounded-2xl border border-[#dbe4fd] bg-[#eef2ff] px-6 py-[22px]">
|
|
57
|
+
<p class="mb-2 flex items-center gap-[9px] text-sm font-bold text-[#2f5fe0]">
|
|
58
|
+
<svg class="h-[17px] w-[17px] flex-none" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
59
|
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
60
|
+
<path d="M23 7 16 12l7 5V7Z" />
|
|
61
|
+
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
|
62
|
+
</svg>
|
|
63
|
+
Khai giảng linh hoạt
|
|
64
|
+
</p>
|
|
65
|
+
<p class="text-[13px] leading-relaxed text-gray-600">
|
|
66
|
+
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.
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
</aside>
|
|
70
|
+
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<header class="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-md border-b border-slate-200/70 shadow-sm">
|
|
3
3
|
<div class="max-w-7xl mx-auto px-6 lg:px-8 flex items-center justify-between h-16">
|
|
4
|
-
<NuxtLink :to="`/giai-phap-proxmox
|
|
4
|
+
<NuxtLink :to="`/giai-phap-proxmox/khoa-hoc-proxmox-hci`" class="flex items-center gap-2.5">
|
|
5
5
|
<img src="/images/logo-lvs-1300x330.png" alt="Long Vân" class="h-8 w-auto" />
|
|
6
6
|
</NuxtLink>
|
|
7
7
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
]">
|
|
17
17
|
{{ link.label }}
|
|
18
18
|
</NuxtLink>
|
|
19
|
-
<a v-else :href="`/giai-phap-proxmox
|
|
19
|
+
<a v-else :href="`/giai-phap-proxmox/khoa-hoc-proxmox-hci#${link.id}`" @click.prevent="handleAnchorClick(link)" :class="[
|
|
20
20
|
'pb-1 border-b-2 transition-all duration-200 cursor-pointer',
|
|
21
21
|
activeSection === link.id
|
|
22
22
|
? 'text-[#004AC6] font-bold border-[#004AC6]'
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
{{ link.label }}
|
|
26
26
|
</a>
|
|
27
27
|
</template>
|
|
28
|
-
<a v-else :href="`/giai-phap-proxmox
|
|
28
|
+
<a v-else :href="`/giai-phap-proxmox/khoa-hoc-proxmox-hci#${link.id}`" @click.prevent="handleAnchorClick(link)" :class="[
|
|
29
29
|
'pb-1 border-b-2 transition-all duration-200 cursor-pointer',
|
|
30
30
|
activeSection === link.id
|
|
31
31
|
? 'text-[#004AC6] font-bold border-[#004AC6]'
|
|
@@ -65,16 +65,16 @@
|
|
|
65
65
|
|
|
66
66
|
<!-- Not logged in -->
|
|
67
67
|
<div v-else class="flex items-center gap-3">
|
|
68
|
-
<a v-if="isConsultant" :href="`/giai-phap-proxmox
|
|
68
|
+
<a v-if="isConsultant" :href="`/giai-phap-proxmox/khoa-hoc-proxmox-hci#lien-he`" @click.prevent="scrollTo('lien-he')"
|
|
69
69
|
class="inline-flex items-center gap-1.5 text-sm font-bold px-5 py-2.5 rounded-[10px] border border-[#004AC6] text-[#004AC6] hover:bg-blue-50 transition-all active:scale-95 cursor-pointer">
|
|
70
70
|
Liên hệ tư vấn
|
|
71
71
|
<Icon name="lucide:headset" class="text-[16px]" />
|
|
72
72
|
</a>
|
|
73
|
-
<
|
|
73
|
+
<a :href="loginHref"
|
|
74
74
|
class="inline-flex items-center gap-1.5 text-sm font-bold px-5 py-2.5 rounded-[10px] bg-[#004AC6] text-white hover:bg-[#1D4ED8] transition-all active:scale-95 shadow-sm shadow-blue-600/20">
|
|
75
75
|
Đăng nhập
|
|
76
76
|
<Icon name="lucide:log-in" class="text-[16px]" />
|
|
77
|
-
</
|
|
77
|
+
</a>
|
|
78
78
|
</div>
|
|
79
79
|
</div>
|
|
80
80
|
</header>
|
|
@@ -98,7 +98,7 @@ const route = useRoute()
|
|
|
98
98
|
const tokenKey = config.public.ACCESSTOKEN_NAME as string
|
|
99
99
|
|
|
100
100
|
const navLinks = computed(() => [
|
|
101
|
-
{ id: 'san-pham', label: 'Sản phẩm', path: `/
|
|
101
|
+
{ id: 'san-pham', label: 'Sản phẩm', path: `/gioi-thieu-san-pham/proxmox-hci` },
|
|
102
102
|
{ id: 'giai-phap', label: 'Vì sao chọn chúng tôi' },
|
|
103
103
|
{ id: 'chi-phi', label: 'Học phí' },
|
|
104
104
|
{ id: 'khach-hang', label: 'Học viên nói gì' },
|
|
@@ -106,6 +106,10 @@ const navLinks = computed(() => [
|
|
|
106
106
|
|
|
107
107
|
const { scrollTo } = useScrollToSection()
|
|
108
108
|
|
|
109
|
+
// route.fullPath gồm cả query hiện tại (vd ?plan=...&name=...) để sau khi đăng nhập quay lại đúng chỗ
|
|
110
|
+
// Trang đăng nhập nằm ở domain LONGVAN_WEB (vd https://dev.longvan.net), không phải app này
|
|
111
|
+
const loginHref = computed(() => `${config.public.LONGVAN_WEB}/dang-nhap?redirectUrl=${encodeURIComponent(route.fullPath)}`)
|
|
112
|
+
|
|
109
113
|
const activeSection = useActiveSection(navLinks.value.map(l => l.id))
|
|
110
114
|
const user = ref<{ fullName?: string; username?: string } | null>(null)
|
|
111
115
|
const showMenu = ref(false)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Chuẩn hoá 1 item CMS (đến từ item_id_0M_groups_0N của CoursesSection hoặc PricingPlansSection)
|
|
2
|
+
// về cùng 1 shape, để mọi nơi tiêu thụ (CtaSection, PricingPlansSection...) không phải đoán field theo từng nguồn.
|
|
3
|
+
export interface NormalizedPlanItem {
|
|
4
|
+
id: string
|
|
5
|
+
name: string
|
|
6
|
+
level: string
|
|
7
|
+
duration: string
|
|
8
|
+
description: string
|
|
9
|
+
features: string[]
|
|
10
|
+
price?: number
|
|
11
|
+
prices?: Record<string, number>
|
|
12
|
+
priceLabel: string
|
|
13
|
+
priceNote: string
|
|
14
|
+
highlight: boolean
|
|
15
|
+
url?: string
|
|
16
|
+
icon: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const normalizePlanItem = (item: any): NormalizedPlanItem | null => {
|
|
20
|
+
if (!item || typeof item !== 'object') return null
|
|
21
|
+
|
|
22
|
+
const features = Array.isArray(item.config_product)
|
|
23
|
+
? item.config_product
|
|
24
|
+
: typeof item.config_product === 'string' && item.config_product
|
|
25
|
+
? [item.config_product]
|
|
26
|
+
: Array.isArray(item.features)
|
|
27
|
+
? item.features
|
|
28
|
+
: []
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
id: String(item.productId ?? item.id ?? item.title ?? item.name ?? ''),
|
|
32
|
+
name: item.title || item.name || '',
|
|
33
|
+
level: item.level || '',
|
|
34
|
+
duration: item.time || item.unit || '',
|
|
35
|
+
description: item.short_description || item.desc || '',
|
|
36
|
+
features,
|
|
37
|
+
price: item.price,
|
|
38
|
+
prices: item.prices,
|
|
39
|
+
priceLabel: item.priceLabel || '',
|
|
40
|
+
priceNote: item.priceNote || '',
|
|
41
|
+
highlight: !!item.highlight,
|
|
42
|
+
url: item.url,
|
|
43
|
+
icon: item.icon || 'lucide:server',
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -28,6 +28,23 @@ export const useSolutionApi = () => {
|
|
|
28
28
|
return data
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Tạm thời: dò slug path theo cấp bất kỳ (slug/slug2/slug3), lấy object đầu tiên rồi gọi resume theo profileId
|
|
32
|
+
const getSlugData = async (slug: string) => {
|
|
33
|
+
const list = await handleGetSlugPathView(slug, 'giai-phap')
|
|
34
|
+
console.log("🚀 ~ getSlugData ~ list:", list)
|
|
35
|
+
|
|
36
|
+
const first = list?.[0]
|
|
37
|
+
console.log("🚀 ~ getSlugData ~ first:", first)
|
|
38
|
+
|
|
39
|
+
const profileId = first?.profileId
|
|
40
|
+
if (!profileId) return { productData: first, resumeData: null }
|
|
41
|
+
|
|
42
|
+
const resumeData = await getResumeByIdData(profileId)
|
|
43
|
+
console.log("🚀 ~ getSlugData ~ resumeData:", resumeData)
|
|
44
|
+
|
|
45
|
+
return { productData: first, resumeData }
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
const getSolutionDetail = async (slug: string, type: string) => {
|
|
32
49
|
const productData = await handleGetSlugPathView(slug, type)
|
|
33
50
|
|
|
@@ -51,5 +68,6 @@ export const useSolutionApi = () => {
|
|
|
51
68
|
getPublicProductDynamic,
|
|
52
69
|
getResumeByIdData,
|
|
53
70
|
getSolutionDetail,
|
|
71
|
+
getSlugData,
|
|
54
72
|
}
|
|
55
73
|
}
|
|
@@ -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,39 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({ layout: 'default' })
|
|
3
|
+
|
|
4
|
+
const route = useRoute()
|
|
5
|
+
|
|
6
|
+
// Gói cần tư vấn được mang theo qua query từ CoursesSection (không fetch lại API)
|
|
7
|
+
const selectedPlan = computed(() => {
|
|
8
|
+
const id = route.query.plan
|
|
9
|
+
if (!id) return null
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
id: String(id),
|
|
13
|
+
name: String(route.query.name || ''),
|
|
14
|
+
level: String(route.query.level || ''),
|
|
15
|
+
duration: String(route.query.duration || ''),
|
|
16
|
+
price: String(route.query.price || ''),
|
|
17
|
+
description: String(route.query.description || ''),
|
|
18
|
+
icon: 'lucide:server',
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const plans = computed(() => (selectedPlan.value ? [selectedPlan.value] : []))
|
|
23
|
+
|
|
24
|
+
useHead({
|
|
25
|
+
title: computed(() =>
|
|
26
|
+
selectedPlan.value?.name ? `Đăng ký tư vấn - ${selectedPlan.value.name}` : 'Đăng ký tư vấn khóa học Proxmox'
|
|
27
|
+
),
|
|
28
|
+
})
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div class="bg-ink text-cloud font-body antialiased">
|
|
33
|
+
<TheHeader />
|
|
34
|
+
|
|
35
|
+
<RegistrationSection :plans="plans" :selected-plan-id="selectedPlan?.id" />
|
|
36
|
+
|
|
37
|
+
<TheFooter />
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
<RegistrationSection :plans="plans" :selected-plan-id="selectedPlanId" />
|
|
53
|
+
|
|
54
|
+
<TheFooter />
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
package/app/pages/index.vue
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
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'
|
|
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'
|
|
4
5
|
</script>
|
|
5
6
|
|
|
6
7
|
<template>
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,131 +0,0 @@
|
|
|
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>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/{index.vue → index.vue.disabled}
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|