nuxt-layer-core 1.1.3 → 1.1.4
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 +24 -8
- package/app/layers/solution/app/components/CtaSection.vue +0 -3
- package/app/layers/solution/app/components/LinuxVersionsSection.vue +23 -12
- package/app/layers/solution/app/components/PricingPlansSection.vue +25 -52
- package/app/layers/solution/app/components/RegistrationForm.vue +13 -21
- package/app/layers/solution/app/components/RegistrationSection.vue +2 -1
- package/app/layers/solution/app/composables/usePlanNormalizer.ts +49 -45
- package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/index.vue +62 -2
- package/app/layers/solution/app/pages/gioi-thieu-san-pham/[slug]/index.vue +108 -2
- package/app/layers/solution/app/pages/template/[slug]/index.vue +136 -22
- package/app/layers/solution/app/pages/tu-van/[slug]/index.vue +57 -2
- package/package.json +1 -1
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
</p>
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
<div class="grid
|
|
18
|
+
<div class="grid gap-6 lg:gap-8"
|
|
19
|
+
:class="formattedPlans.length === 1 ? 'grid-cols-1 justify-items-center' : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'">
|
|
19
20
|
<div v-for="(plan, i) in formattedPlans" :key="plan.id" :data-delay="i * 150" class="course-card group relative"
|
|
20
|
-
:class="plan.highlight ? 'lg:-mt-6' : 'lg:mt-0'">
|
|
21
|
+
:class="[plan.highlight ? 'lg:-mt-6' : 'lg:mt-0', formattedPlans.length === 1 ? 'w-full max-w-sm' : '']">
|
|
21
22
|
<!-- Glow nền cho card nổi bật -->
|
|
22
23
|
<div v-if="plan.highlight"
|
|
23
24
|
class="absolute -inset-3 bg-gradient-to-br from-[#004AC6]/25 via-blue-400/15 to-transparent rounded-[28px] blur-2xl opacity-70 -z-10">
|
|
@@ -66,8 +67,8 @@
|
|
|
66
67
|
<h4 class="font-bold text-xl text-slate-900 mb-2">
|
|
67
68
|
{{ plan.name }}
|
|
68
69
|
</h4>
|
|
69
|
-
<p class="text-slate-500 text-sm leading-relaxed">
|
|
70
|
-
{{ plan.
|
|
70
|
+
<p v-if="plan.tagline" class="text-slate-500 text-sm leading-relaxed">
|
|
71
|
+
{{ plan.tagline }}
|
|
71
72
|
</p>
|
|
72
73
|
</div>
|
|
73
74
|
|
|
@@ -81,6 +82,8 @@
|
|
|
81
82
|
</span>
|
|
82
83
|
</div>
|
|
83
84
|
|
|
85
|
+
<p class="text-slate-500 text-sm leading-relaxed" v-html="plan.description"></p>
|
|
86
|
+
|
|
84
87
|
<span class="text-[13px] text-slate-600 leading-loose [&_svg]:inline [&_svg]:text-[#004AC6]"
|
|
85
88
|
v-html="plan.included"></span>
|
|
86
89
|
|
|
@@ -100,13 +103,22 @@
|
|
|
100
103
|
</div>
|
|
101
104
|
</div>
|
|
102
105
|
|
|
103
|
-
<div v-if="isConsultant" class="p-7 pt-2 mt-auto">
|
|
104
|
-
<
|
|
106
|
+
<div v-if="plan.url || isConsultant" class="p-7 pt-2 mt-auto">
|
|
107
|
+
<a v-if="plan.url" :href="plan.url" target="_blank" rel="noopener noreferrer"
|
|
108
|
+
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"
|
|
109
|
+
:class="plan.highlight
|
|
110
|
+
? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 hover:bg-[#1D4ED8] hover:shadow-blue-500/40'
|
|
111
|
+
: 'border border-slate-200 text-slate-600 hover:border-[#004AC6] hover:text-[#004AC6] hover:bg-blue-50/50'
|
|
112
|
+
">
|
|
113
|
+
{{ plan.titleButton || 'Đăng ký ngay' }}
|
|
114
|
+
<Icon name="lucide:arrow-up-right" class="w-4 h-4" />
|
|
115
|
+
</a>
|
|
116
|
+
<button v-else @click="handleSelectPlan(plan)"
|
|
105
117
|
class="w-full py-3.5 rounded-xl font-bold text-sm transition-all duration-300 cursor-pointer" :class="plan.highlight
|
|
106
118
|
? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 hover:bg-[#1D4ED8] hover:shadow-blue-500/40'
|
|
107
119
|
: 'border border-slate-200 text-slate-600 hover:border-[#004AC6] hover:text-[#004AC6] hover:bg-blue-50/50'
|
|
108
120
|
">
|
|
109
|
-
Liên hệ tư vấn
|
|
121
|
+
{{ plan.titleButton || 'Liên hệ tư vấn' }}
|
|
110
122
|
</button>
|
|
111
123
|
</div>
|
|
112
124
|
</div>
|
|
@@ -233,12 +245,16 @@ const formattedPlans = computed(() => {
|
|
|
233
245
|
return items.map((item, index) => ({
|
|
234
246
|
id: item.id || `plan-${index}`,
|
|
235
247
|
name: item.name,
|
|
248
|
+
tagline: item.tagline,
|
|
236
249
|
level: item.level,
|
|
237
250
|
duration: item.duration,
|
|
238
251
|
description: item.description,
|
|
239
252
|
included: item.features,
|
|
240
253
|
icon: item.icon,
|
|
241
|
-
|
|
254
|
+
url: item.url,
|
|
255
|
+
titleButton: item.titleButton,
|
|
256
|
+
// Chỉ 1 khóa -> tự động highlight (không có khóa khác để so sánh nên luôn là khóa nổi bật)
|
|
257
|
+
highlight: items.length === 1 ? true : index === 1,
|
|
242
258
|
outcome: '',
|
|
243
259
|
}))
|
|
244
260
|
})
|
|
@@ -172,7 +172,6 @@ function formatLevel(level) {
|
|
|
172
172
|
return labels[level || ''] || level || ''
|
|
173
173
|
}
|
|
174
174
|
|
|
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
175
|
const pricingCategoryMeta = {
|
|
177
176
|
hci: { level: 'Gói thuê hạ tầng', icon: 'lucide:server-cog' },
|
|
178
177
|
workshop: { level: 'Workshop', icon: 'lucide:zap' },
|
|
@@ -183,7 +182,6 @@ const pricingCategoryMeta = {
|
|
|
183
182
|
// props.courses / props.pricingPlans[category] đã được DynamicPageSections chuẩn hoá chung shape (normalizePlanItem)
|
|
184
183
|
const formattedPlans = computed(() => {
|
|
185
184
|
const courseOptions = props.courses
|
|
186
|
-
.filter((item) => !item.url)
|
|
187
185
|
.map((item) => ({
|
|
188
186
|
id: item.id,
|
|
189
187
|
name: item.name,
|
|
@@ -195,7 +193,6 @@ const formattedPlans = computed(() => {
|
|
|
195
193
|
const pricingOptions = props.pricingPlans
|
|
196
194
|
? Object.entries(pricingCategoryMeta).flatMap(([category, meta]) =>
|
|
197
195
|
(props.pricingPlans[category] || [])
|
|
198
|
-
.filter((item) => !item.url)
|
|
199
196
|
.map((item) => ({
|
|
200
197
|
id: item.id,
|
|
201
198
|
name: item.name,
|
|
@@ -79,6 +79,13 @@
|
|
|
79
79
|
|
|
80
80
|
<Transition name="fade-swap" mode="out-in">
|
|
81
81
|
<div :key="activeVersionId" class="flex flex-col gap-14">
|
|
82
|
+
<div
|
|
83
|
+
v-if="activeVersion.groups.length === 0"
|
|
84
|
+
class="text-center text-slate-400 text-sm py-14 border border-dashed border-slate-200 rounded-2xl"
|
|
85
|
+
>
|
|
86
|
+
Chưa có gói dịch vụ nào được cấu hình cho phiên bản này.
|
|
87
|
+
</div>
|
|
88
|
+
|
|
82
89
|
<div
|
|
83
90
|
v-for="(group, gi) in activeVersion.groups"
|
|
84
91
|
:key="group.id"
|
|
@@ -95,15 +102,18 @@
|
|
|
95
102
|
</div>
|
|
96
103
|
</div>
|
|
97
104
|
|
|
98
|
-
<div class="grid
|
|
105
|
+
<div class="grid gap-6"
|
|
106
|
+
:class="group.packages.length === 1 ? 'grid-cols-1 justify-items-center' : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'">
|
|
99
107
|
<div
|
|
100
108
|
v-for="pkg in group.packages"
|
|
101
109
|
:key="pkg.id"
|
|
102
110
|
class="relative bg-white rounded-2xl flex flex-col overflow-hidden transition-all duration-300 ease-out"
|
|
103
|
-
:class="
|
|
104
|
-
? '
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
:class="[
|
|
112
|
+
group.packages.length === 1 ? 'w-full max-w-sm' : '',
|
|
113
|
+
pkg.highlight
|
|
114
|
+
? 'ring-1 ring-[#004AC6]/15 shadow-[0_8px_30px_-6px_rgba(0,74,198,0.25)] hover:shadow-[0_20px_45px_-10px_rgba(0,74,198,0.35)] hover:-translate-y-1'
|
|
115
|
+
: 'ring-1 ring-slate-200 shadow-[0_2px_10px_-2px_rgba(15,23,42,0.06)] hover:ring-blue-200 hover:shadow-[0_12px_30px_-8px_rgba(15,23,42,0.12)] hover:-translate-y-1'
|
|
116
|
+
]"
|
|
107
117
|
>
|
|
108
118
|
<div
|
|
109
119
|
v-if="pkg.highlight"
|
|
@@ -121,7 +131,7 @@
|
|
|
121
131
|
|
|
122
132
|
<div class="pb-1">
|
|
123
133
|
<span class="font-['Montserrat'] font-extrabold text-xl text-slate-900">
|
|
124
|
-
{{
|
|
134
|
+
{{ pkg.price }}
|
|
125
135
|
</span>
|
|
126
136
|
<span class="text-slate-400 text-sm">{{ pkg.unit }}</span>
|
|
127
137
|
</div>
|
|
@@ -158,7 +168,7 @@
|
|
|
158
168
|
: 'border border-slate-200 text-slate-600 hover:border-[#004AC6] hover:text-[#004AC6] hover:bg-blue-50/50'
|
|
159
169
|
"
|
|
160
170
|
>
|
|
161
|
-
Đăng ký ngay
|
|
171
|
+
{{ pkg.titleButton || 'Đăng ký ngay' }}
|
|
162
172
|
</button>
|
|
163
173
|
</div>
|
|
164
174
|
</div>
|
|
@@ -207,7 +217,12 @@ const normalizeGroup = (raw) => {
|
|
|
207
217
|
name: raw.name,
|
|
208
218
|
icon: raw.icon || 'lucide:server-cog',
|
|
209
219
|
desc: raw.desc || '',
|
|
210
|
-
|
|
220
|
+
// Chỉ 1 gói -> tự động highlight (không có gói khác để so sánh nên luôn là gói nổi bật)
|
|
221
|
+
packages: Array.isArray(raw.packages)
|
|
222
|
+
? raw.packages.length === 1
|
|
223
|
+
? [{ ...raw.packages[0], highlight: true }]
|
|
224
|
+
: raw.packages
|
|
225
|
+
: [],
|
|
211
226
|
}
|
|
212
227
|
}
|
|
213
228
|
|
|
@@ -283,10 +298,6 @@ function handleSelectPackage(pkg, group) {
|
|
|
283
298
|
})
|
|
284
299
|
}
|
|
285
300
|
|
|
286
|
-
function formatPrice(value) {
|
|
287
|
-
if (!value && value !== 0) return ''
|
|
288
|
-
return new Intl.NumberFormat('vi-VN').format(value)
|
|
289
|
-
}
|
|
290
301
|
|
|
291
302
|
const sectionRoot = ref(null)
|
|
292
303
|
const revealed = ref(false)
|
|
@@ -76,13 +76,14 @@
|
|
|
76
76
|
|
|
77
77
|
<!-- Pricing cards -->
|
|
78
78
|
<Transition name="fade-swap" mode="out-in">
|
|
79
|
-
<div :key="activeTab + '-' + activePeriod" class="grid
|
|
79
|
+
<div :key="activeTab + '-' + activePeriod" class="grid gap-6 lg:gap-8"
|
|
80
|
+
:class="activePlans.length === 1 ? 'grid-cols-1 justify-items-center' : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'">
|
|
80
81
|
<div
|
|
81
82
|
v-for="(plan, i) in activePlans"
|
|
82
83
|
:key="plan.name"
|
|
83
84
|
class="pricing-reveal group relative"
|
|
84
85
|
:style="{ '--i': i }"
|
|
85
|
-
:class="plan.highlight ? 'lg:-mt-6' : 'lg:mt-0'"
|
|
86
|
+
:class="[plan.highlight ? 'lg:-mt-6' : 'lg:mt-0', activePlans.length === 1 ? 'w-full max-w-sm' : '']"
|
|
86
87
|
>
|
|
87
88
|
<div
|
|
88
89
|
v-if="plan.highlight"
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
<div class="p-7 flex flex-col gap-4">
|
|
108
109
|
<div>
|
|
109
110
|
<h4 class="font-bold text-xl text-slate-900 mb-2">{{ plan.name }}</h4>
|
|
110
|
-
<p class="text-slate-500 text-sm leading-relaxed">{{ plan.
|
|
111
|
+
<p v-if="plan.tagline" class="text-slate-500 text-sm leading-relaxed">{{ plan.tagline }}</p>
|
|
111
112
|
</div>
|
|
112
113
|
|
|
113
114
|
<div class="pb-1">
|
|
@@ -116,13 +117,15 @@
|
|
|
116
117
|
</span>
|
|
117
118
|
<template v-else>
|
|
118
119
|
<span class="font-['Montserrat'] font-extrabold text-2xl sm:text-[28px] text-slate-900">
|
|
119
|
-
{{ plan.
|
|
120
|
+
{{ plan.price }}
|
|
120
121
|
</span>
|
|
121
122
|
<span class="text-slate-400 text-sm">{{ plan.unit }}</span>
|
|
122
123
|
</template>
|
|
123
124
|
<p class="text-slate-400 text-xs mt-1.5">{{ plan.priceNote }}</p>
|
|
124
125
|
</div>
|
|
125
126
|
|
|
127
|
+
<p class="text-slate-500 text-sm leading-relaxed" v-html="plan.desc"></p>
|
|
128
|
+
|
|
126
129
|
<div class="flex flex-wrap gap-1.5">
|
|
127
130
|
<span
|
|
128
131
|
v-for="tag in plan.tags"
|
|
@@ -157,20 +160,16 @@
|
|
|
157
160
|
: 'border border-slate-200 text-slate-600 hover:border-[#004AC6] hover:text-[#004AC6] hover:bg-blue-50/50'
|
|
158
161
|
"
|
|
159
162
|
>
|
|
160
|
-
Đăng ký ngay
|
|
163
|
+
{{ plan.titleButton || 'Đăng ký ngay' }}
|
|
161
164
|
<Icon name="lucide:arrow-up-right" class="w-4 h-4" />
|
|
162
165
|
</a>
|
|
163
166
|
<button
|
|
164
167
|
v-else
|
|
165
168
|
type="button"
|
|
166
|
-
|
|
167
|
-
class="w-full py-3.5 rounded-xl font-bold text-sm
|
|
168
|
-
:class="plan.highlight
|
|
169
|
-
? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 hover:bg-[#1D4ED8] hover:shadow-blue-500/40'
|
|
170
|
-
: 'border border-slate-200 text-slate-600 hover:border-[#004AC6] hover:text-[#004AC6] hover:bg-blue-50/50'
|
|
171
|
-
"
|
|
169
|
+
disabled
|
|
170
|
+
class="w-full py-3.5 rounded-xl font-bold text-sm cursor-not-allowed opacity-50 border border-slate-200 text-slate-600"
|
|
172
171
|
>
|
|
173
|
-
{{ activeTabData.cta }}
|
|
172
|
+
{{ plan.titleButton || activeTabData.cta }}
|
|
174
173
|
</button>
|
|
175
174
|
</div>
|
|
176
175
|
</div>
|
|
@@ -203,34 +202,6 @@ const props = defineProps({
|
|
|
203
202
|
|
|
204
203
|
useScrollAnimate('.pricing-header', 'fade-up')
|
|
205
204
|
|
|
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: `/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
|
-
|
|
234
205
|
const sectionRoot = ref(null)
|
|
235
206
|
const revealed = ref(false)
|
|
236
207
|
|
|
@@ -312,9 +283,9 @@ const toPricingPlan = (item) => {
|
|
|
312
283
|
return {
|
|
313
284
|
id: normalized.id,
|
|
314
285
|
name: normalized.name,
|
|
286
|
+
tagline: normalized.tagline,
|
|
315
287
|
desc: normalized.description,
|
|
316
288
|
unit: normalized.duration,
|
|
317
|
-
priceLabel: normalized.priceLabel,
|
|
318
289
|
price: normalized.price,
|
|
319
290
|
prices: normalized.prices,
|
|
320
291
|
priceNote: normalized.priceNote,
|
|
@@ -322,6 +293,7 @@ const toPricingPlan = (item) => {
|
|
|
322
293
|
features: normalized.features,
|
|
323
294
|
highlight: normalized.highlight,
|
|
324
295
|
url: normalized.url,
|
|
296
|
+
titleButton: normalized.titleButton,
|
|
325
297
|
}
|
|
326
298
|
}
|
|
327
299
|
|
|
@@ -335,26 +307,27 @@ const trainingItems = computed(() =>
|
|
|
335
307
|
[props.itemId01Groups03, props.itemId02Groups03, props.itemId03Groups03].map(toPricingPlan).filter(Boolean)
|
|
336
308
|
)
|
|
337
309
|
|
|
310
|
+
// Chỉ 1 gói -> tự động highlight (không có gói khác để so sánh nên luôn là gói nổi bật)
|
|
311
|
+
const withSingleHighlight = (plans) => (plans.length === 1 ? [{ ...plans[0], highlight: true }] : plans)
|
|
312
|
+
|
|
338
313
|
const activePlans = computed(() => {
|
|
339
314
|
if (activeTab.value === 'workshop') {
|
|
340
|
-
return workshopItems.value.length > 0 ? workshopItems.value : workshopPlans
|
|
315
|
+
return withSingleHighlight(workshopItems.value.length > 0 ? workshopItems.value : workshopPlans)
|
|
341
316
|
}
|
|
342
317
|
if (activeTab.value === 'training') {
|
|
343
|
-
return trainingItems.value.length > 0 ? trainingItems.value : trainingPlans
|
|
318
|
+
return withSingleHighlight(trainingItems.value.length > 0 ? trainingItems.value : trainingPlans)
|
|
344
319
|
}
|
|
345
320
|
|
|
346
321
|
const source = hciItems.value.length > 0 ? hciItems.value : hciPlans
|
|
347
|
-
return
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
322
|
+
return withSingleHighlight(
|
|
323
|
+
source.map((plan) => ({
|
|
324
|
+
...plan,
|
|
325
|
+
price: plan.prices?.[activePeriod.value],
|
|
326
|
+
priceNote: `Giá thuê khi ký hợp đồng ${activePeriod.value} tháng`,
|
|
327
|
+
}))
|
|
328
|
+
)
|
|
352
329
|
})
|
|
353
330
|
|
|
354
|
-
function formatPrice(value) {
|
|
355
|
-
if (!value && value !== 0) return ''
|
|
356
|
-
return new Intl.NumberFormat('vi-VN').format(value)
|
|
357
|
-
}
|
|
358
331
|
</script>
|
|
359
332
|
|
|
360
333
|
<style scoped>
|
|
@@ -10,8 +10,14 @@ const props = withDefaults(defineProps<{
|
|
|
10
10
|
description?: string
|
|
11
11
|
}>
|
|
12
12
|
selectedPlanId?: string
|
|
13
|
+
studyModes?: Array<{ name: string; type: string }>
|
|
13
14
|
}>(), {
|
|
14
15
|
plans: () => [],
|
|
16
|
+
studyModes: () => [
|
|
17
|
+
{ name: 'Học trực tuyến (Online)', type: 'online' },
|
|
18
|
+
{ name: 'Học tại lớp (Offline)', type: 'offline' },
|
|
19
|
+
{ name: 'Đào tạo doanh nghiệp', type: 'enterprise' },
|
|
20
|
+
],
|
|
15
21
|
})
|
|
16
22
|
|
|
17
23
|
const { addOpportunity } = useCrmApi()
|
|
@@ -19,7 +25,7 @@ const { addOpportunity } = useCrmApi()
|
|
|
19
25
|
// Form state
|
|
20
26
|
const selectedCourseId = ref(props.selectedPlanId || '')
|
|
21
27
|
const form = reactive({ name: '', phone: '', email: '', note: '' })
|
|
22
|
-
const studyMode = ref('online')
|
|
28
|
+
const studyMode = ref(props.studyModes[0]?.type || 'online')
|
|
23
29
|
const errors = reactive({ name: '', phone: '', email: '', course: '' })
|
|
24
30
|
const touched = reactive({ name: false, phone: false, email: false, course: false })
|
|
25
31
|
const submitting = ref(false)
|
|
@@ -100,12 +106,7 @@ async function handleSubmit() {
|
|
|
100
106
|
|
|
101
107
|
try {
|
|
102
108
|
const hostname = window.location.origin
|
|
103
|
-
const studyModeLabel =
|
|
104
|
-
studyMode.value === 'online'
|
|
105
|
-
? 'Học trực tuyến (Online)'
|
|
106
|
-
: studyMode.value === 'offline'
|
|
107
|
-
? 'Học tại lớp (Offline)'
|
|
108
|
-
: 'Đào tạo doanh nghiệp'
|
|
109
|
+
const studyModeLabel = props.studyModes.find((mode) => mode.type === studyMode.value)?.name || studyMode.value
|
|
109
110
|
|
|
110
111
|
const levelLabel =
|
|
111
112
|
selectedPlan.value?.level === 'basic'
|
|
@@ -238,20 +239,11 @@ async function handleSubmit() {
|
|
|
238
239
|
<div class="mb-5">
|
|
239
240
|
<label class="mb-[10px] block text-[13px] font-semibold text-gray-700">Hình thức học</label>
|
|
240
241
|
<div class="flex flex-wrap justify-center gap-[10px] sm:justify-start">
|
|
241
|
-
<button
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<button type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
|
|
247
|
-
:class="studyMode === 'offline' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
|
|
248
|
-
@click="studyMode = 'offline'">
|
|
249
|
-
Học tại lớp (Offline)
|
|
250
|
-
</button>
|
|
251
|
-
<button type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
|
|
252
|
-
:class="studyMode === 'enterprise' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
|
|
253
|
-
@click="studyMode = 'enterprise'">
|
|
254
|
-
Đào tạo doanh nghiệp
|
|
242
|
+
<button v-for="mode in studyModes" :key="mode.type" type="button"
|
|
243
|
+
class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
|
|
244
|
+
:class="studyMode === mode.type ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
|
|
245
|
+
@click="studyMode = mode.type">
|
|
246
|
+
{{ mode.name }}
|
|
255
247
|
</button>
|
|
256
248
|
</div>
|
|
257
249
|
</div>
|
|
@@ -8,6 +8,7 @@ defineProps<{
|
|
|
8
8
|
icon: string
|
|
9
9
|
}>
|
|
10
10
|
selectedPlanId?: string
|
|
11
|
+
studyModes?: Array<{ name: string; type: string }>
|
|
11
12
|
}>()
|
|
12
13
|
</script>
|
|
13
14
|
|
|
@@ -18,7 +19,7 @@ defineProps<{
|
|
|
18
19
|
<RegistrationSidebar />
|
|
19
20
|
|
|
20
21
|
<!-- Right column: form -->
|
|
21
|
-
<RegistrationForm :plans="plans" :selected-plan-id="selectedPlanId" />
|
|
22
|
+
<RegistrationForm :plans="plans" :selected-plan-id="selectedPlanId" :study-modes="studyModes" />
|
|
22
23
|
</div>
|
|
23
24
|
</div>
|
|
24
25
|
</template>
|
|
@@ -1,45 +1,49 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
tagline: string
|
|
7
|
+
level: string
|
|
8
|
+
duration: string
|
|
9
|
+
description: string
|
|
10
|
+
features: string[]
|
|
11
|
+
price?: number
|
|
12
|
+
prices?: Record<string, number>
|
|
13
|
+
priceLabel: string
|
|
14
|
+
priceNote: string
|
|
15
|
+
highlight: boolean
|
|
16
|
+
url?: string
|
|
17
|
+
titleButton: string
|
|
18
|
+
icon: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const normalizePlanItem = (item: any): NormalizedPlanItem | null => {
|
|
22
|
+
if (!item || typeof item !== 'object') return null
|
|
23
|
+
|
|
24
|
+
const features = Array.isArray(item.config_product)
|
|
25
|
+
? item.config_product
|
|
26
|
+
: typeof item.config_product === 'string' && item.config_product
|
|
27
|
+
? [item.config_product]
|
|
28
|
+
: Array.isArray(item.features)
|
|
29
|
+
? item.features
|
|
30
|
+
: []
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
id: String(item.productId ?? item.id ?? item.title ?? item.name ?? ''),
|
|
34
|
+
name: item.title || item.name || '',
|
|
35
|
+
tagline: item.tagline || '',
|
|
36
|
+
level: item.level || '',
|
|
37
|
+
duration: item.time || item.unit || '',
|
|
38
|
+
description: item.short_description || item.desc || '',
|
|
39
|
+
features,
|
|
40
|
+
price: item.price,
|
|
41
|
+
prices: item.prices,
|
|
42
|
+
priceLabel: item.priceLabel || '',
|
|
43
|
+
priceNote: item.priceNote || '',
|
|
44
|
+
highlight: !!item.highlight,
|
|
45
|
+
url: item.url,
|
|
46
|
+
titleButton: item.title_button || item.titleButton || '',
|
|
47
|
+
icon: item.icon || 'lucide:server',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -11,12 +11,72 @@ const { data } = await useAsyncData(
|
|
|
11
11
|
() => getSlugData(slug.value, 'giai-phap'),
|
|
12
12
|
{ server: true }
|
|
13
13
|
)
|
|
14
|
+
|
|
15
|
+
const targetCTA = computed(() => data.value?.productData?.targetCTA)
|
|
16
|
+
const pageSlug = computed(() => data.value?.productData?.slug || slug.value)
|
|
17
|
+
|
|
18
|
+
// Lấy 1 field group (vd khoa_dao_tao_co_ban) từ fieldResumes, gom children về object phẳng { productid, name, ... }
|
|
19
|
+
const findFieldGroup = (fieldName: string) => {
|
|
20
|
+
const group = data.value?.resumeData?.fieldResumes?.find(
|
|
21
|
+
(field: any) => field?.fieldName === fieldName && field?.type === 'FIELD_GROUP'
|
|
22
|
+
)
|
|
23
|
+
if (!group?.children) return null
|
|
24
|
+
|
|
25
|
+
const result: Record<string, any> = {}
|
|
26
|
+
for (const field of group.children) {
|
|
27
|
+
const key = field?.fieldName?.split('@')?.pop()
|
|
28
|
+
if (key) result[key] = field?.value
|
|
29
|
+
}
|
|
30
|
+
return result
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const toCourseItem = (fieldName: string, level: string) => {
|
|
34
|
+
const group = findFieldGroup(fieldName)
|
|
35
|
+
if (!group) return undefined
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
productId: group.productid,
|
|
39
|
+
name: group.name,
|
|
40
|
+
tagline: group.tagline,
|
|
41
|
+
level,
|
|
42
|
+
time: group.thoi_gian_hoc,
|
|
43
|
+
// mo_ta_ngan là rich text từ CMS, CoursesSection render bằng v-html
|
|
44
|
+
short_description: group.mo_ta_ngan,
|
|
45
|
+
url: group.url,
|
|
46
|
+
title_button: group.title_button,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const courseBasic = computed(() => toCourseItem('khoa_dao_tao_co_ban', 'basic'))
|
|
51
|
+
const courseAdvanced = computed(() => toCourseItem('khoa_dao_tao_nang_cao', 'advanced'))
|
|
52
|
+
const courseExpert = computed(() => toCourseItem('khoa_dao_tao_chuyen_gia', 'expert'))
|
|
53
|
+
|
|
54
|
+
// Đồng bộ dropdown "Khóa học quan tâm" của CtaSection với đúng data đang hiện ở CoursesSection
|
|
55
|
+
const courses = computed(() =>
|
|
56
|
+
[courseBasic.value, courseAdvanced.value, courseExpert.value].map(normalizePlanItem).filter(Boolean)
|
|
57
|
+
)
|
|
14
58
|
</script>
|
|
15
59
|
|
|
16
60
|
<template>
|
|
17
|
-
<DynamicPageSections
|
|
61
|
+
<!-- <DynamicPageSections
|
|
18
62
|
:field-resumes="data?.resumeData?.fieldResumes"
|
|
19
63
|
:target-c-t-a="data?.productData?.targetCTA"
|
|
20
64
|
:slug="data?.productData?.slug || slug"
|
|
21
|
-
/>
|
|
65
|
+
/> -->
|
|
66
|
+
|
|
67
|
+
<div class="bg-ink text-cloud font-body antialiased">
|
|
68
|
+
<TheHeader :target-c-t-a="targetCTA" :slug="pageSlug" />
|
|
69
|
+
<HeroSection />
|
|
70
|
+
<FeaturesSection />
|
|
71
|
+
<CoursesSection
|
|
72
|
+
:target-c-t-a="targetCTA"
|
|
73
|
+
:slug="pageSlug"
|
|
74
|
+
:item-id01-groups01="courseBasic"
|
|
75
|
+
:item-id02-groups01="courseAdvanced"
|
|
76
|
+
:item-id03-groups01="courseExpert"
|
|
77
|
+
/>
|
|
78
|
+
<TestimonialsSection />
|
|
79
|
+
<CtaSection :courses="courses" />
|
|
80
|
+
<TheFooter />
|
|
81
|
+
</div>
|
|
22
82
|
</template>
|
|
@@ -11,12 +11,118 @@ const { data } = await useAsyncData(
|
|
|
11
11
|
() => getSlugData(slug.value, 'giai-phap'),
|
|
12
12
|
{ server: true }
|
|
13
13
|
)
|
|
14
|
+
|
|
15
|
+
const targetCTA = computed(() => data.value?.productData?.targetCTA)
|
|
16
|
+
const pageSlug = computed(() => data.value?.productData?.slug || slug.value)
|
|
17
|
+
|
|
18
|
+
// Lấy 1 field group (vd goi_san_pham_co_ban) từ fieldResumes, gom children về object phẳng { productid, name, ... }
|
|
19
|
+
const findFieldGroup = (fieldName: string) => {
|
|
20
|
+
const group = data.value?.resumeData?.fieldResumes?.find(
|
|
21
|
+
(field: any) => field?.fieldName === fieldName && field?.type === 'FIELD_GROUP'
|
|
22
|
+
)
|
|
23
|
+
if (!group?.children) return null
|
|
24
|
+
|
|
25
|
+
const result: Record<string, any> = {}
|
|
26
|
+
for (const field of group.children) {
|
|
27
|
+
const key = field?.fieldName?.split('@')?.pop()
|
|
28
|
+
if (key) result[key] = field?.value
|
|
29
|
+
}
|
|
30
|
+
return result
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Gói thuê hạ tầng (goi_san_pham_*): giá theo 3 kỳ hạn 12/24/36 tháng, dùng thẳng text CMS trả về
|
|
34
|
+
const toRentalPlanItem = (fieldName: string, highlight = false) => {
|
|
35
|
+
const group = findFieldGroup(fieldName)
|
|
36
|
+
if (!group) return undefined
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
productId: group.productid,
|
|
40
|
+
name: group.name,
|
|
41
|
+
tagline: group.tagline,
|
|
42
|
+
unit: group.don_vi ? `/${group.don_vi}` : '',
|
|
43
|
+
// short_description là rich text từ CMS, PricingPlansSection render bằng v-html
|
|
44
|
+
short_description: group.short_description,
|
|
45
|
+
prices: {
|
|
46
|
+
12: group.gia_thue_12_thang,
|
|
47
|
+
24: group.gia_thue_24_thang,
|
|
48
|
+
36: group.gia_thue_36_thang,
|
|
49
|
+
},
|
|
50
|
+
url: group.url,
|
|
51
|
+
title_button: group.title_button,
|
|
52
|
+
highlight,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Workshop / khóa đào tạo (goi_workshop_*, goi_dao_tao_proxmox_*): 1 mức giá duy nhất
|
|
57
|
+
const toFlatPriceItem = (fieldName: string, highlight = false) => {
|
|
58
|
+
const group = findFieldGroup(fieldName)
|
|
59
|
+
if (!group) return undefined
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
productId: group.productid,
|
|
63
|
+
name: group.name,
|
|
64
|
+
tagline: group.tagline,
|
|
65
|
+
unit: group.don_vi ? `/${group.don_vi}` : '',
|
|
66
|
+
short_description: group.short_description,
|
|
67
|
+
price: group.hoc_phi,
|
|
68
|
+
url: group.url,
|
|
69
|
+
title_button: group.title_button,
|
|
70
|
+
highlight,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pricingHci01 = computed(() => toRentalPlanItem('goi_san_pham_co_ban'))
|
|
75
|
+
const pricingHci02 = computed(() => toRentalPlanItem('goi_san_pham_nang_cao', true))
|
|
76
|
+
const pricingHci03 = computed(() => toRentalPlanItem('goi_san_pham_cao_cap'))
|
|
77
|
+
|
|
78
|
+
const pricingWorkshop01 = computed(() => toFlatPriceItem('goi_workshop_co_ban'))
|
|
79
|
+
const pricingWorkshop02 = computed(() => toFlatPriceItem('goi_workshop_nang_cao', true))
|
|
80
|
+
const pricingWorkshop03 = computed(() => toFlatPriceItem('goi_workshop_cao_cap'))
|
|
81
|
+
|
|
82
|
+
const pricingTraining01 = computed(() => toFlatPriceItem('goi_dao_tao_proxmox_co_ban'))
|
|
83
|
+
const pricingTraining02 = computed(() => toFlatPriceItem('goi_dao_tao_proxmox_nang_cao', true))
|
|
84
|
+
const pricingTraining03 = computed(() => toFlatPriceItem('goi_dao_tao_proxmox_cao_cap'))
|
|
85
|
+
|
|
86
|
+
// Đồng bộ dropdown "Khóa học quan tâm" của CtaSection với đúng data đang hiện ở PricingPlansSection
|
|
87
|
+
const pricingPlans = computed(() => ({
|
|
88
|
+
hci: [pricingHci01.value, pricingHci02.value, pricingHci03.value].map(normalizePlanItem).filter(Boolean),
|
|
89
|
+
workshop: [pricingWorkshop01.value, pricingWorkshop02.value, pricingWorkshop03.value].map(normalizePlanItem).filter(Boolean),
|
|
90
|
+
training: [pricingTraining01.value, pricingTraining02.value, pricingTraining03.value].map(normalizePlanItem).filter(Boolean),
|
|
91
|
+
}))
|
|
14
92
|
</script>
|
|
15
93
|
|
|
16
94
|
<template>
|
|
17
|
-
<DynamicPageSections
|
|
95
|
+
<!-- <DynamicPageSections
|
|
18
96
|
:field-resumes="data?.resumeData?.fieldResumes"
|
|
19
97
|
:target-c-t-a="data?.productData?.targetCTA"
|
|
20
98
|
:slug="data?.productData?.slug || slug"
|
|
21
|
-
/>
|
|
99
|
+
/> -->
|
|
100
|
+
|
|
101
|
+
<div>
|
|
102
|
+
<main class="min-h-screen bg-white text-slate-900">
|
|
103
|
+
<TheHeader :target-c-t-a="targetCTA" :slug="pageSlug" />
|
|
104
|
+
<ProxmoxHeroSection />
|
|
105
|
+
<ProxmoxPainPointsSection />
|
|
106
|
+
<ProxmoxVeFeaturesSection />
|
|
107
|
+
<ProxmoxHciOverviewSection />
|
|
108
|
+
<PricingPlansSection
|
|
109
|
+
:item-id01-groups01="pricingHci01"
|
|
110
|
+
:item-id02-groups01="pricingHci02"
|
|
111
|
+
:item-id03-groups01="pricingHci03"
|
|
112
|
+
:item-id01-groups02="pricingWorkshop01"
|
|
113
|
+
:item-id02-groups02="pricingWorkshop02"
|
|
114
|
+
:item-id03-groups02="pricingWorkshop03"
|
|
115
|
+
:item-id01-groups03="pricingTraining01"
|
|
116
|
+
:item-id02-groups03="pricingTraining02"
|
|
117
|
+
:item-id03-groups03="pricingTraining03"
|
|
118
|
+
/>
|
|
119
|
+
<ProxmoxTrainingBenefitsSection />
|
|
120
|
+
<ProxmoxComparisonSection />
|
|
121
|
+
<!-- <CoursesSection :target-c-t-a="targetCTA" :slug="pageSlug" /> -->
|
|
122
|
+
<!-- <ProxmoxTestimonialsSection />
|
|
123
|
+
<ProxmoxFaqSection /> -->
|
|
124
|
+
<CtaSection :pricing-plans="pricingPlans" />
|
|
125
|
+
<TheFooter />
|
|
126
|
+
</main>
|
|
127
|
+
</div>
|
|
22
128
|
</template>
|
|
@@ -1,22 +1,136 @@
|
|
|
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, 'gioi-thieu-template'),
|
|
12
|
-
{ server: true }
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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, 'gioi-thieu-template'),
|
|
12
|
+
{ server: true }
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const targetCTA = computed(() => data.value?.productData?.targetCTA)
|
|
16
|
+
const pageSlug = computed(() => data.value?.productData?.slug || slug.value)
|
|
17
|
+
|
|
18
|
+
// Lấy 1 field group (vd version-khuyen-nghi) từ fieldResumes, gom children về object phẳng { name, type, ... }
|
|
19
|
+
const findFieldGroup = (fieldName: string) => {
|
|
20
|
+
const group = data.value?.resumeData?.fieldResumes?.find(
|
|
21
|
+
(field: any) => field?.fieldName === fieldName && field?.type === 'FIELD_GROUP'
|
|
22
|
+
)
|
|
23
|
+
if (!group?.children) return null
|
|
24
|
+
|
|
25
|
+
const result: Record<string, any> = {}
|
|
26
|
+
for (const field of group.children) {
|
|
27
|
+
const key = field?.fieldName?.split('@')?.pop()
|
|
28
|
+
if (key) result[key] = field?.value
|
|
29
|
+
}
|
|
30
|
+
return result
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 1 phiên bản Ubuntu (version-khuyen-nghi, version_on_dinh, version_lts)
|
|
34
|
+
const toVersionMeta = (fieldName: string, badgeTone: string, recommended = false) => {
|
|
35
|
+
const group = findFieldGroup(fieldName)
|
|
36
|
+
if (!group) return undefined
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: group.productid,
|
|
40
|
+
version: String(group.name || '').replace(/^Ubuntu\s+/i, ''),
|
|
41
|
+
codename: '',
|
|
42
|
+
badge: group.type,
|
|
43
|
+
badgeTone,
|
|
44
|
+
recommended,
|
|
45
|
+
releaseDate: group.createdate,
|
|
46
|
+
supportUntil: group.expired ? `Hỗ trợ đến ${group.expired}` : '',
|
|
47
|
+
desc: group.description,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const versionRecommended = computed(() => toVersionMeta('version-khuyen-nghi', 'primary', true))
|
|
52
|
+
const versionStable = computed(() => toVersionMeta('version_on_dinh', 'amber'))
|
|
53
|
+
const versionLts = computed(() => toVersionMeta('version_lts', 'slate'))
|
|
54
|
+
|
|
55
|
+
// short_description là rich text <ul><li>✓ ...</li></ul> -> tách thành mảng text thuần
|
|
56
|
+
// (template tự thêm icon check riêng nên phải bỏ ký tự ✓ có sẵn trong CMS để khỏi lặp)
|
|
57
|
+
const parseFeatureList = (html?: string | null) => {
|
|
58
|
+
if (!html) return []
|
|
59
|
+
|
|
60
|
+
return [...html.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)]
|
|
61
|
+
.map((match) =>
|
|
62
|
+
(match[1] || '')
|
|
63
|
+
.replace(/<[^>]+>/g, '')
|
|
64
|
+
.replace(/&/g, '&')
|
|
65
|
+
.replace(/^[✓\s]+/, '')
|
|
66
|
+
.trim()
|
|
67
|
+
)
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 1 gói dịch vụ (san_pham_01/02/03), gắn với 1 phiên bản Ubuntu qua relate_groups <-> productid của version
|
|
72
|
+
const toPackage = (fieldName: string) => {
|
|
73
|
+
const group = findFieldGroup(fieldName)
|
|
74
|
+
if (!group?.name) return null
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
id: group.productid || group.product_slug || group.name,
|
|
78
|
+
relateGroups: group.relate_groups,
|
|
79
|
+
name: group.name,
|
|
80
|
+
desc: group.tagline,
|
|
81
|
+
price: group.gia_tien,
|
|
82
|
+
unit: group.don_vi ? `/${group.don_vi}` : '',
|
|
83
|
+
features: parseFeatureList(group.short_description),
|
|
84
|
+
url: group.url,
|
|
85
|
+
titleButton: group.title_button,
|
|
86
|
+
highlight: false,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const packages = computed(() =>
|
|
91
|
+
[toPackage('san_pham_01'), toPackage('san_pham_02'), toPackage('san_pham_03')].filter(
|
|
92
|
+
(pkg): pkg is NonNullable<typeof pkg> => !!pkg
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
// Gom các gói thuộc về đúng phiên bản Ubuntu thành 1 nhóm sản phẩm cho phiên bản đó
|
|
97
|
+
const toProductGroup = (versionId?: string) => {
|
|
98
|
+
const items = packages.value.filter((pkg) => pkg.relateGroups === versionId)
|
|
99
|
+
if (!items.length) return undefined
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
id: 'goi-dich-vu',
|
|
103
|
+
name: 'Gói dịch vụ đề xuất',
|
|
104
|
+
icon: 'lucide:server-cog',
|
|
105
|
+
desc: '',
|
|
106
|
+
packages: items,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const groupRecommended = computed(() => toProductGroup(versionRecommended.value?.id))
|
|
111
|
+
const groupStable = computed(() => toProductGroup(versionStable.value?.id))
|
|
112
|
+
const groupLts = computed(() => toProductGroup(versionLts.value?.id))
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<template>
|
|
116
|
+
<!-- <DynamicPageSections
|
|
117
|
+
:field-resumes="data?.resumeData?.fieldResumes"
|
|
118
|
+
:target-c-t-a="data?.productData?.targetCTA"
|
|
119
|
+
:slug="data?.productData?.slug || slug"
|
|
120
|
+
/> -->
|
|
121
|
+
|
|
122
|
+
<div class="bg-ink text-cloud font-body antialiased">
|
|
123
|
+
<TheHeader :target-c-t-a="targetCTA" :slug="pageSlug" />
|
|
124
|
+
<LinuxIntroSection />
|
|
125
|
+
<LinuxVersionsSection
|
|
126
|
+
:groups-item01="versionRecommended"
|
|
127
|
+
:groups-item02="versionStable"
|
|
128
|
+
:groups-item03="versionLts"
|
|
129
|
+
:item-id01-groups01="groupRecommended"
|
|
130
|
+
:item-id01-groups02="groupStable"
|
|
131
|
+
:item-id01-groups03="groupLts"
|
|
132
|
+
/>
|
|
133
|
+
<WhyChooseUbuntuSection />
|
|
134
|
+
<TheFooter />
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
@@ -11,12 +11,67 @@ const { data } = await useAsyncData(
|
|
|
11
11
|
() => getSlugData(slug.value, 'giai-phap'),
|
|
12
12
|
{ server: true }
|
|
13
13
|
)
|
|
14
|
+
|
|
15
|
+
const targetCTA = computed(() => data.value?.productData?.targetCTA)
|
|
16
|
+
const pageSlug = computed(() => data.value?.productData?.slug || slug.value)
|
|
17
|
+
|
|
18
|
+
// Lấy giá trị 1 field top-level (vd name, level) từ fieldResumes
|
|
19
|
+
const findFieldValue = (fieldName: string) => {
|
|
20
|
+
const field = data.value?.resumeData?.fieldResumes?.find(
|
|
21
|
+
(item: any) => item?.fieldName === fieldName && item?.type === 'FIELD'
|
|
22
|
+
)
|
|
23
|
+
return field?.value ?? ''
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Lấy 1 field group (vd hinh_thuc_hoc_online) từ fieldResumes, gom children về object phẳng { name, type }
|
|
27
|
+
const findFieldGroup = (fieldName: string) => {
|
|
28
|
+
const group = data.value?.resumeData?.fieldResumes?.find(
|
|
29
|
+
(field: any) => field?.fieldName === fieldName && field?.type === 'FIELD_GROUP'
|
|
30
|
+
)
|
|
31
|
+
if (!group?.children) return null
|
|
32
|
+
|
|
33
|
+
const result: Record<string, any> = {}
|
|
34
|
+
for (const field of group.children) {
|
|
35
|
+
const key = field?.fieldName?.split('@')?.pop()
|
|
36
|
+
if (key) result[key] = field?.value
|
|
37
|
+
}
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const plan = computed(() => ({
|
|
42
|
+
id: findFieldValue('productid') || pageSlug.value,
|
|
43
|
+
name: findFieldValue('name'),
|
|
44
|
+
level: findFieldValue('level'),
|
|
45
|
+
duration: findFieldValue('thoi-gian'),
|
|
46
|
+
description: findFieldValue('short_description'),
|
|
47
|
+
icon: 'lucide:server',
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
const toStudyMode = (fieldName: string) => {
|
|
51
|
+
const group = findFieldGroup(fieldName)
|
|
52
|
+
if (!group) return null
|
|
53
|
+
return { name: group.name, type: String(group.type || '').trim() }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const studyModes = computed(() =>
|
|
57
|
+
[
|
|
58
|
+
toStudyMode('hinh_thuc_hoc_online'),
|
|
59
|
+
toStudyMode('hinh_thuc_hoc_tai_lop'),
|
|
60
|
+
toStudyMode('dao_tao_doanh_nghiep'),
|
|
61
|
+
].filter((mode): mode is { name: string; type: string } => !!mode)
|
|
62
|
+
)
|
|
14
63
|
</script>
|
|
15
64
|
|
|
16
65
|
<template>
|
|
17
|
-
<DynamicPageSections
|
|
66
|
+
<!-- <DynamicPageSections
|
|
18
67
|
:field-resumes="data?.resumeData?.fieldResumes"
|
|
19
68
|
:target-c-t-a="data?.productData?.targetCTA"
|
|
20
69
|
:slug="data?.productData?.slug || slug"
|
|
21
|
-
/>
|
|
70
|
+
/> -->
|
|
71
|
+
|
|
72
|
+
<div class="bg-ink text-cloud font-body antialiased">
|
|
73
|
+
<TheHeader :target-c-t-a="targetCTA" :slug="pageSlug" />
|
|
74
|
+
<RegistrationSection :plans="[plan]" :study-modes="studyModes" />
|
|
75
|
+
<TheFooter />
|
|
76
|
+
</div>
|
|
22
77
|
</template>
|