nuxt-layer-core 2.0.2 → 2.0.3

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