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.
@@ -15,9 +15,10 @@
15
15
  </p>
16
16
  </div>
17
17
 
18
- <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8">
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.description }}
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
- <button @click="handleSelectPlan(plan)"
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
- highlight: index === 1,
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 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
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="pkg.highlight
104
- ? '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'
105
- : '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'
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
- {{ formatPrice(pkg.price) }}đ
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
- packages: Array.isArray(raw.packages) ? raw.packages : [],
220
+ // Chỉ 1 gói -> tự động highlight (không 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 grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8">
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.desc }}</p>
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.priceLabel }}{{ formatPrice(plan.price) }}đ
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
- @click="handleContactClick(plan)"
167
- class="w-full py-3.5 rounded-xl font-bold text-sm transition-all duration-300 cursor-pointer"
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 source.map((plan) => ({
348
- ...plan,
349
- price: plan.prices?.[activePeriod.value],
350
- priceNote: `Giá thuê khi ký hợp đồng ${activePeriod.value} tháng`,
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 type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
242
- :class="studyMode === 'online' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
243
- @click="studyMode = 'online'">
244
- Học trực tuyến (Online)
245
- </button>
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
- 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
- }
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
- </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>
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(/&amp;/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>
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "nuxt-layer-core",
3
3
  "type": "module",
4
4
  "private": false,
5
- "version": "1.1.3",
5
+ "version": "1.1.4",
6
6
  "scripts": {
7
7
  "build": "env-cmd -f .env nuxt build",
8
8
  "dev": "env-cmd -f .env nuxt dev"