nuxt-layer-core 2.0.1 → 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 -22
- package/app/layers/solution/app/components/DynamicPageSections.vue +103 -0
- package/app/layers/solution/app/components/PricingPlansSection.vue +108 -146
- 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/useConsultationRequest.ts +8 -0
- 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/data/pricingPlans.ts +150 -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>
|
|
@@ -148,13 +148,14 @@
|
|
|
148
148
|
useScrollAnimate('.cta-card', 'scale-up')
|
|
149
149
|
|
|
150
150
|
const props = defineProps({
|
|
151
|
-
plans: { type: Object, default: null },
|
|
152
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 },
|
|
153
155
|
})
|
|
154
156
|
|
|
155
157
|
const { addOpportunity } = useCrmApi()
|
|
156
158
|
|
|
157
|
-
// Format plans from resumeData (same logic as CoursesSection)
|
|
158
159
|
const samplePlans = [
|
|
159
160
|
{ id: 'sample-1', name: 'Deployment & Management', icon: 'lucide:graduation-cap', level: 'Nền tảng', duration: '14 giờ' },
|
|
160
161
|
{ id: 'sample-2', name: 'Clustering & Shared Storage', icon: 'lucide:network', level: 'Chuyên sâu', duration: '14 giờ' },
|
|
@@ -171,29 +172,46 @@ function formatLevel(level) {
|
|
|
171
172
|
return labels[level || ''] || level || ''
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const fields = Object.fromEntries(
|
|
180
|
-
group.children?.map((field) => [field?.fieldName?.split('@')?.pop(), field?.value]) || []
|
|
181
|
-
)
|
|
182
|
-
return {
|
|
183
|
-
id: fields.productid || group.fieldName,
|
|
184
|
-
name: fields.title || '',
|
|
185
|
-
level: fields.level || '',
|
|
186
|
-
duration: fields.time || '',
|
|
187
|
-
icon: fields.icon || 'lucide:server',
|
|
188
|
-
}
|
|
189
|
-
}) || []
|
|
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' },
|
|
190
180
|
}
|
|
191
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)
|
|
192
184
|
const formattedPlans = computed(() => {
|
|
193
|
-
const
|
|
194
|
-
|
|
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
|
|
195
211
|
})
|
|
196
212
|
|
|
213
|
+
const dropdownPlans = computed(() => formattedPlans.value)
|
|
214
|
+
|
|
197
215
|
// Form state
|
|
198
216
|
const form = reactive({ name: '', phone: '', email: '' })
|
|
199
217
|
const errors = reactive({ name: '', phone: '', email: '', course: '' })
|
|
@@ -221,9 +239,9 @@ watch(() => props.selectedPlanId, (id) => {
|
|
|
221
239
|
|
|
222
240
|
// Search filter
|
|
223
241
|
const filteredCourses = computed(() => {
|
|
224
|
-
if (!searchQuery.value) return
|
|
242
|
+
if (!searchQuery.value) return dropdownPlans.value
|
|
225
243
|
const q = searchQuery.value.toLowerCase()
|
|
226
|
-
return
|
|
244
|
+
return dropdownPlans.value.filter(p =>
|
|
227
245
|
p.name.toLowerCase().includes(q) || p.level.toLowerCase().includes(q)
|
|
228
246
|
)
|
|
229
247
|
})
|
|
@@ -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>
|
|
@@ -146,8 +146,24 @@
|
|
|
146
146
|
</div>
|
|
147
147
|
|
|
148
148
|
<div class="p-7 pt-2 mt-auto">
|
|
149
|
+
<a
|
|
150
|
+
v-if="plan.url"
|
|
151
|
+
:href="plan.url"
|
|
152
|
+
target="_blank"
|
|
153
|
+
rel="noopener noreferrer"
|
|
154
|
+
class="w-full py-3.5 rounded-xl font-bold text-sm transition-all duration-300 cursor-pointer inline-flex items-center justify-center gap-2"
|
|
155
|
+
:class="plan.highlight
|
|
156
|
+
? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 hover:bg-[#1D4ED8] hover:shadow-blue-500/40'
|
|
157
|
+
: 'border border-slate-200 text-slate-600 hover:border-[#004AC6] hover:text-[#004AC6] hover:bg-blue-50/50'
|
|
158
|
+
"
|
|
159
|
+
>
|
|
160
|
+
Đăng ký ngay
|
|
161
|
+
<Icon name="lucide:arrow-up-right" class="w-4 h-4" />
|
|
162
|
+
</a>
|
|
149
163
|
<button
|
|
164
|
+
v-else
|
|
150
165
|
type="button"
|
|
166
|
+
@click="handleContactClick(plan)"
|
|
151
167
|
class="w-full py-3.5 rounded-xl font-bold text-sm transition-all duration-300 cursor-pointer"
|
|
152
168
|
:class="plan.highlight
|
|
153
169
|
? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 hover:bg-[#1D4ED8] hover:shadow-blue-500/40'
|
|
@@ -168,8 +184,53 @@
|
|
|
168
184
|
</template>
|
|
169
185
|
|
|
170
186
|
<script setup>
|
|
187
|
+
import { hciPlans, workshopPlans, trainingPlans } from '../data/pricingPlans'
|
|
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
|
+
|
|
171
204
|
useScrollAnimate('.pricing-header', 'fade-up')
|
|
172
205
|
|
|
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
|
+
}
|
|
214
|
+
|
|
215
|
+
function handleContactClick(plan) {
|
|
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
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
173
234
|
const sectionRoot = ref(null)
|
|
174
235
|
const revealed = ref(false)
|
|
175
236
|
|
|
@@ -193,11 +254,13 @@ onMounted(() => {
|
|
|
193
254
|
const activeTab = ref('hci')
|
|
194
255
|
const activePeriod = ref(36)
|
|
195
256
|
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
{ id: '
|
|
200
|
-
|
|
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
|
+
])
|
|
201
264
|
|
|
202
265
|
const billingPeriods = [
|
|
203
266
|
{ value: 12, label: 'Thuê 12 tháng' },
|
|
@@ -223,144 +286,6 @@ const hciInfoCards = [
|
|
|
223
286
|
},
|
|
224
287
|
]
|
|
225
288
|
|
|
226
|
-
const hciPlans = [
|
|
227
|
-
{
|
|
228
|
-
name: 'HCI Starter',
|
|
229
|
-
desc: 'Cụm 3 node cho doanh nghiệp vừa và nhỏ',
|
|
230
|
-
unit: '/tháng',
|
|
231
|
-
priceLabel: '',
|
|
232
|
-
prices: { 12: 14900000, 24: 11900000, 36: 9900000 },
|
|
233
|
-
tags: ['3 node x86', 'Ceph 3 bản sao', '10 GbE nội cụm', '~20 TB khả dụng'],
|
|
234
|
-
features: [
|
|
235
|
-
'Không chi phí đầu tư thiết bị ban đầu',
|
|
236
|
-
'Triển khai, tinh chỉnh và bật High Availability',
|
|
237
|
-
'Bảo hành, thay thế phần cứng trong suốt thời gian thuê',
|
|
238
|
-
'Đào tạo vận hành 1 buổi cho đội IT',
|
|
239
|
-
],
|
|
240
|
-
highlight: false,
|
|
241
|
-
},
|
|
242
|
-
{
|
|
243
|
-
name: 'HCI Pro',
|
|
244
|
-
desc: 'Cụm mở rộng cho hệ thống production',
|
|
245
|
-
unit: '/tháng',
|
|
246
|
-
priceLabel: '',
|
|
247
|
-
prices: { 12: 27900000, 24: 23900000, 36: 20900000 },
|
|
248
|
-
tags: ['5 node NVMe', 'Ceph RBD + CephFS', '25 GbE dự phòng', '~60 TB khả dụng'],
|
|
249
|
-
features: [
|
|
250
|
-
'Proxmox Backup Server riêng đi kèm',
|
|
251
|
-
'SDN, firewall phân tầng cho từng VM',
|
|
252
|
-
'Giám sát, cảnh báo toàn cụm 24/7',
|
|
253
|
-
'Nâng cấp cấu hình giữa kỳ không phát sinh phí ký lại',
|
|
254
|
-
],
|
|
255
|
-
highlight: true,
|
|
256
|
-
},
|
|
257
|
-
{
|
|
258
|
-
name: 'HCI Datacenter',
|
|
259
|
-
desc: 'Đa site, khôi phục thảm họa',
|
|
260
|
-
unit: '',
|
|
261
|
-
contact: true,
|
|
262
|
-
tags: ['Từ 8 node', 'Ceph đa pool', 'Mạng lõi 100 GbE', 'Dung lượng theo yêu cầu'],
|
|
263
|
-
features: [
|
|
264
|
-
'Nhân bản chéo hai trung tâm dữ liệu',
|
|
265
|
-
'Kịch bản DR và diễn tập định kỳ',
|
|
266
|
-
'Tự động hóa qua API/Terraform',
|
|
267
|
-
'Kỹ sư trực onsite theo SLA cam kết',
|
|
268
|
-
],
|
|
269
|
-
highlight: false,
|
|
270
|
-
},
|
|
271
|
-
]
|
|
272
|
-
|
|
273
|
-
const workshopPlans = [
|
|
274
|
-
{
|
|
275
|
-
name: 'Workshop Trực tuyến',
|
|
276
|
-
desc: 'Tham gia từ xa · lab riêng trên cloud',
|
|
277
|
-
unit: '/học viên',
|
|
278
|
-
priceLabel: '',
|
|
279
|
-
price: 2900000,
|
|
280
|
-
priceNote: '',
|
|
281
|
-
tags: ['1 ngày · 8 giờ', 'Trực tuyến', 'Lab cloud riêng'],
|
|
282
|
-
features: [
|
|
283
|
-
'Cài đặt Proxmox VE từ số 0',
|
|
284
|
-
'Dựng cụm HCI với Ceph, bật High Availability',
|
|
285
|
-
'Ghi hình buổi học để xem lại 30 ngày',
|
|
286
|
-
'Checklist triển khai dùng ngay',
|
|
287
|
-
],
|
|
288
|
-
highlight: false,
|
|
289
|
-
},
|
|
290
|
-
{
|
|
291
|
-
name: 'Workshop Tại lớp',
|
|
292
|
-
desc: 'Học trực tiếp cùng giảng viên',
|
|
293
|
-
unit: '/học viên',
|
|
294
|
-
priceLabel: '',
|
|
295
|
-
price: 3900000,
|
|
296
|
-
priceNote: '',
|
|
297
|
-
tags: ['1 ngày · 8 giờ', 'Tại lớp', 'Thiết bị thật tại lab'],
|
|
298
|
-
features: [
|
|
299
|
-
'Thao tác trực tiếp trên cụm nhiều node',
|
|
300
|
-
'Mô phỏng sự cố node và tự phục hồi',
|
|
301
|
-
'Hỏi đáp 1-1 với giảng viên',
|
|
302
|
-
'Suất ăn trưa và tài liệu in kèm',
|
|
303
|
-
],
|
|
304
|
-
highlight: true,
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
name: 'Workshop Nội bộ doanh nghiệp',
|
|
308
|
-
desc: 'Tổ chức riêng cho đội IT của bạn',
|
|
309
|
-
unit: '/lớp tối đa 15 người',
|
|
310
|
-
priceLabel: 'Từ ',
|
|
311
|
-
price: 39000000,
|
|
312
|
-
priceNote: '',
|
|
313
|
-
tags: ['1-2 ngày', 'Tại doanh nghiệp', 'Nội dung tùy chỉnh'],
|
|
314
|
-
features: [
|
|
315
|
-
'Nội dung thiết kế theo hạ tầng hiện có',
|
|
316
|
-
'Giảng viên và thiết bị lab tới tận nơi',
|
|
317
|
-
'Đánh giá năng lực đội IT sau workshop',
|
|
318
|
-
'Ưu đãi khi kết hợp gói thuê HCI',
|
|
319
|
-
],
|
|
320
|
-
highlight: false,
|
|
321
|
-
},
|
|
322
|
-
]
|
|
323
|
-
|
|
324
|
-
const trainingPlans = [
|
|
325
|
-
{
|
|
326
|
-
name: 'Deployment & Management',
|
|
327
|
-
desc: 'Nền tảng · máy chủ đơn',
|
|
328
|
-
unit: '/học viên',
|
|
329
|
-
priceLabel: 'Từ ',
|
|
330
|
-
price: 9900000,
|
|
331
|
-
priceNote: '',
|
|
332
|
-
tags: ['14 giờ · 2 buổi', 'Trực tuyến hoặc tại lớp', 'Lab thực hành riêng'],
|
|
333
|
-
features: ['Cài đặt, mạng, lưu trữ, VM/LXC', 'Backup, phân quyền, tường lửa', 'Chứng nhận hoàn thành Proxmox'],
|
|
334
|
-
highlight: false,
|
|
335
|
-
},
|
|
336
|
-
{
|
|
337
|
-
name: 'Clustering & Shared Storage',
|
|
338
|
-
desc: 'Chuyên sâu · cụm và Ceph',
|
|
339
|
-
unit: '/học viên',
|
|
340
|
-
priceLabel: 'Từ ',
|
|
341
|
-
price: 11900000,
|
|
342
|
-
priceNote: '',
|
|
343
|
-
tags: ['14 giờ · 2 buổi', 'Trực tuyến hoặc tại lớp', 'Lab cụm nhiều node'],
|
|
344
|
-
features: ['Cluster, pmxcfs, Corosync, SDN', 'Ceph HCI, ZFS replication, HA', 'Kịch bản sự cố thực chiến'],
|
|
345
|
-
highlight: true,
|
|
346
|
-
},
|
|
347
|
-
{
|
|
348
|
-
name: 'Proxmox VE Bundle',
|
|
349
|
-
desc: 'Trọn gói cả hai khóa · tiết kiệm hơn',
|
|
350
|
-
unit: '/học viên',
|
|
351
|
-
priceLabel: 'Từ ',
|
|
352
|
-
price: 19900000,
|
|
353
|
-
priceNote: '',
|
|
354
|
-
tags: ['28 giờ · 4-5 buổi', 'Trực tuyến hoặc tại lớp', 'Lộ trình liền mạch'],
|
|
355
|
-
features: [
|
|
356
|
-
'Gồm đủ Module 1 và Module 2',
|
|
357
|
-
'Ưu đãi khi đăng ký theo nhóm',
|
|
358
|
-
'Hỗ trợ hỏi đáp sau khóa học',
|
|
359
|
-
],
|
|
360
|
-
highlight: false,
|
|
361
|
-
},
|
|
362
|
-
]
|
|
363
|
-
|
|
364
289
|
const tabContent = {
|
|
365
290
|
hci: {
|
|
366
291
|
intro: 'Thuê cụm siêu hội tụ theo tháng: không đầu tư ban đầu, đã bao gồm phần cứng, triển khai Ceph, HA và đội ngũ vận hành.',
|
|
@@ -378,11 +303,48 @@ const tabContent = {
|
|
|
378
303
|
|
|
379
304
|
const activeTabData = computed(() => tabContent[activeTab.value])
|
|
380
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
|
+
|
|
381
338
|
const activePlans = computed(() => {
|
|
382
|
-
if (activeTab.value === 'workshop')
|
|
383
|
-
|
|
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
|
+
}
|
|
384
345
|
|
|
385
|
-
|
|
346
|
+
const source = hciItems.value.length > 0 ? hciItems.value : hciPlans
|
|
347
|
+
return source.map((plan) => ({
|
|
386
348
|
...plan,
|
|
387
349
|
price: plan.prices?.[activePeriod.value],
|
|
388
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>
|