nuxt-layer-core 1.1.2 → 1.1.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.
@@ -1,396 +1,396 @@
1
- <template>
2
- <section id="goi-san-pham" ref="sectionRoot" class="py-24 bg-white overflow-hidden" :class="{ 'pricing-revealed': revealed }">
3
- <div class="max-w-6xl mx-auto px-6 lg:px-8">
4
- <div class="text-center mb-12 pricing-header">
5
- <h2 class="text-[#004AC6] font-bold uppercase tracking-widest text-sm mb-2">Gói sản phẩm</h2>
6
- <h3 class="font-['Montserrat'] font-extrabold text-2xl sm:text-4xl text-slate-900 mb-4">
7
- Chọn gói phù hợp<br class="hidden sm:block" />
8
- với nhu cầu của bạn
9
- </h3>
10
- <p class="text-slate-500 max-w-2xl mx-auto leading-relaxed">
11
- Thuê hạ tầng Proxmox HCI theo tháng, tham gia workshop 1 ngày hoặc chọn khóa đào tạo chuyên sâu. Chi phí
12
- minh bạch, điều chỉnh theo yêu cầu riêng.
13
- </p>
14
-
15
- <div class="flex flex-wrap items-center justify-center gap-3 mt-8">
16
- <button
17
- v-for="tab in tabs"
18
- :key="tab.id"
19
- type="button"
20
- @click="activeTab = tab.id"
21
- class="inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-bold transition-all duration-300 cursor-pointer"
22
- :class="activeTab === tab.id
23
- ? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 scale-[1.03]'
24
- : 'bg-white text-slate-500 border border-slate-200 hover:border-blue-200 hover:text-[#004AC6]'
25
- "
26
- >
27
- <Icon :name="tab.icon" class="w-4 h-4" />
28
- {{ tab.label }}
29
- </button>
30
- </div>
31
-
32
- <Transition name="fade-swap" mode="out-in">
33
- <p :key="activeTab" class="text-slate-400 text-sm max-w-xl mx-auto mt-5">
34
- {{ activeTabData.intro }}
35
- </p>
36
- </Transition>
37
- </div>
38
-
39
- <Transition name="fade-swap" mode="out-in">
40
- <div :key="activeTab">
41
- <!-- Info cards: only for Proxmox HCI -->
42
- <div v-if="activeTab === 'hci'" class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
43
- <div
44
- v-for="(info, i) in hciInfoCards"
45
- :key="info.title"
46
- class="pricing-reveal bg-white rounded-2xl border border-slate-200 p-6 hover:border-blue-200 hover:shadow-lg transition-all duration-300"
47
- :style="{ '--i': i }"
48
- >
49
- <div class="w-11 h-11 rounded-[10px] bg-blue-50 flex items-center justify-center mb-5 text-[#004AC6]">
50
- <Icon :name="info.icon" class="w-5 h-5" />
51
- </div>
52
- <h4 class="font-bold text-slate-900 mb-2">{{ info.title }}</h4>
53
- <p class="text-sm text-slate-500 leading-relaxed">{{ info.desc }}</p>
54
- </div>
55
- </div>
56
-
57
- <!-- Billing period switch: only for Proxmox HCI -->
58
- <div v-if="activeTab === 'hci'" class="flex flex-col items-center gap-4 mb-10">
59
- <p class="text-slate-500 text-sm">Chọn kỳ hạn thuê, càng dài chi phí hàng tháng càng thấp</p>
60
- <div class="inline-flex items-center gap-1 bg-white border border-slate-200 rounded-full p-1">
61
- <button
62
- v-for="period in billingPeriods"
63
- :key="period.value"
64
- type="button"
65
- @click="activePeriod = period.value"
66
- class="px-4 py-2 rounded-full text-sm font-semibold transition-all duration-300 cursor-pointer"
67
- :class="activePeriod === period.value
68
- ? 'bg-[#004AC6] text-white shadow-md shadow-blue-500/25'
69
- : 'text-slate-500 hover:text-[#004AC6]'
70
- "
71
- >
72
- {{ period.label }}
73
- </button>
74
- </div>
75
- </div>
76
-
77
- <!-- Pricing cards -->
78
- <Transition name="fade-swap" mode="out-in">
79
- <div :key="activeTab + '-' + activePeriod" class="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
80
- <div
81
- v-for="(plan, i) in activePlans"
82
- :key="plan.name"
83
- class="pricing-reveal group relative"
84
- :style="{ '--i': i }"
85
- :class="plan.highlight ? 'md:-mt-6' : 'md:mt-0'"
86
- >
87
- <div
88
- v-if="plan.highlight"
89
- 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"
90
- ></div>
91
-
92
- <div
93
- class="relative bg-white rounded-2xl flex flex-col overflow-hidden h-full transition-all duration-300 ease-out"
94
- :class="plan.highlight
95
- ? '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'
96
- : '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'
97
- "
98
- >
99
- <div
100
- v-if="plan.highlight"
101
- class="bg-gradient-to-r from-[#004AC6] to-[#1D63E0] text-white text-[11px] font-bold py-2.5 text-center uppercase tracking-widest flex items-center justify-center gap-1.5"
102
- >
103
- <Icon name="lucide:star" class="w-3.5 h-3.5" />
104
- Được chọn nhiều nhất
105
- </div>
106
-
107
- <div class="p-7 flex flex-col gap-4">
108
- <div>
109
- <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
- </div>
112
-
113
- <div class="pb-1">
114
- <span v-if="plan.contact" class="font-['Montserrat'] font-extrabold text-2xl text-[#004AC6]">
115
- Liên hệ
116
- </span>
117
- <template v-else>
118
- <span class="font-['Montserrat'] font-extrabold text-2xl sm:text-[28px] text-slate-900">
119
- {{ plan.priceLabel }}{{ formatPrice(plan.price) }}đ
120
- </span>
121
- <span class="text-slate-400 text-sm">{{ plan.unit }}</span>
122
- </template>
123
- <p class="text-slate-400 text-xs mt-1.5">{{ plan.priceNote }}</p>
124
- </div>
125
-
126
- <div class="flex flex-wrap gap-1.5">
127
- <span
128
- v-for="tag in plan.tags"
129
- :key="tag"
130
- class="text-[11px] font-medium text-slate-500 bg-slate-50 border border-slate-100 rounded-md px-2 py-1"
131
- >
132
- {{ tag }}
133
- </span>
134
- </div>
135
-
136
- <ul class="flex flex-col gap-2.5 pt-4 border-t border-slate-100">
137
- <li
138
- v-for="feature in plan.features"
139
- :key="feature"
140
- class="flex items-start gap-2 text-sm text-slate-600"
141
- >
142
- <Icon name="lucide:check" class="w-4 h-4 text-[#004AC6] mt-0.5 shrink-0" />
143
- {{ feature }}
144
- </li>
145
- </ul>
146
- </div>
147
-
148
- <div class="p-7 pt-2 mt-auto">
149
- <a
150
- v-if="plan.url"
151
- :href="plan.url"
152
- target="_blank"
153
- rel="noopener noreferrer"
154
- class="w-full py-3.5 rounded-xl font-bold text-sm transition-all duration-300 cursor-pointer inline-flex items-center justify-center gap-2"
155
- :class="plan.highlight
156
- ? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 hover:bg-[#1D4ED8] hover:shadow-blue-500/40'
157
- : 'border border-slate-200 text-slate-600 hover:border-[#004AC6] hover:text-[#004AC6] hover:bg-blue-50/50'
158
- "
159
- >
160
- Đăng ký ngay
161
- <Icon name="lucide:arrow-up-right" class="w-4 h-4" />
162
- </a>
163
- <button
164
- v-else
165
- 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
- "
172
- >
173
- {{ activeTabData.cta }}
174
- </button>
175
- </div>
176
- </div>
177
- </div>
178
- </div>
179
- </Transition>
180
- </div>
181
- </Transition>
182
- </div>
183
- </section>
184
- </template>
185
-
186
- <script setup>
187
- import { hciPlans, workshopPlans, trainingPlans } from '../data/pricingPlans'
188
-
189
- const props = defineProps({
190
- groupsItem01: { type: String, default: '' },
191
- groupsItem02: { type: String, default: '' },
192
- groupsItem03: { type: String, default: '' },
193
- itemId01Groups01: { type: Object, default: null },
194
- itemId02Groups01: { type: Object, default: null },
195
- itemId03Groups01: { type: Object, default: null },
196
- itemId01Groups02: { type: Object, default: null },
197
- itemId02Groups02: { type: Object, default: null },
198
- itemId03Groups02: { type: Object, default: null },
199
- itemId01Groups03: { type: Object, default: null },
200
- itemId02Groups03: { type: Object, default: null },
201
- itemId03Groups03: { type: Object, default: null },
202
- })
203
-
204
- useScrollAnimate('.pricing-header', 'fade-up')
205
-
206
- const router = useRouter()
207
-
208
- // Không có url riêng -> mở trang đăng ký tư vấn, mang theo thông tin gói qua query (giống CoursesSection.handleSelectPlan)
209
- const categoryLevelLabels = {
210
- hci: 'Gói thuê hạ tầng',
211
- workshop: 'Workshop',
212
- training: 'Khóa đào tạo',
213
- }
214
-
215
- function handleContactClick(plan) {
216
- const priceText = plan.priceLabel || typeof plan.price === 'number'
217
- ? `${plan.priceLabel || ''}${formatPrice(plan.price)}đ${plan.unit ? ' ' + plan.unit : ''}`
218
- : ''
219
-
220
- router.push({
221
- path: `/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
- const sectionRoot = ref(null)
235
- const revealed = ref(false)
236
-
237
- onMounted(() => {
238
- const observer = new IntersectionObserver(
239
- (entries) => {
240
- for (const entry of entries) {
241
- if (entry.isIntersecting) {
242
- revealed.value = true
243
- observer.disconnect()
244
- }
245
- }
246
- },
247
- { threshold: 0.15 }
248
- )
249
-
250
- if (sectionRoot.value) observer.observe(sectionRoot.value)
251
- onUnmounted(() => observer.disconnect())
252
- })
253
-
254
- const activeTab = ref('hci')
255
- const activePeriod = ref(36)
256
-
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
- ])
264
-
265
- const billingPeriods = [
266
- { value: 12, label: 'Thuê 12 tháng' },
267
- { value: 24, label: 'Thuê 24 tháng' },
268
- { value: 36, label: 'Thuê 36 tháng' },
269
- ]
270
-
271
- const hciInfoCards = [
272
- {
273
- icon: 'lucide:server',
274
- title: 'Thuê trọn gói tại DC của chúng tôi',
275
- desc: 'Cụm đặt tại trung tâm dữ liệu Tier III, gồm phần cứng, điện, mạng và vận hành. Bạn chỉ dùng và trả phí hàng tháng.',
276
- },
277
- {
278
- icon: 'lucide:building-2',
279
- title: 'Thuê phần cứng đặt tại doanh nghiệp',
280
- desc: 'Chúng tôi mang thiết bị tới đặt tại phòng máy của bạn, triển khai và bảo hành. Dữ liệu nằm trong nội bộ, không cần đầu tư ban đầu.',
281
- },
282
- {
283
- icon: 'lucide:refresh-cw',
284
- title: 'Thuê linh hoạt theo node',
285
- desc: 'Thêm hoặc bớt node theo từng tháng khi nhu cầu thay đổi. Có tùy chọn chuyển quyền sở hữu thiết bị khi hết hạn thuê.',
286
- },
287
- ]
288
-
289
- const tabContent = {
290
- hci: {
291
- intro: 'Thuê cụm siêu hội tụ theo tháng: không đầu tư ban đầu, đã bao gồm phần cứng, triển khai Ceph, HA và đội ngũ vận hành.',
292
- cta: 'Nhận báo giá',
293
- },
294
- workshop: {
295
- intro: 'Workshop cài đặt & triển khai Proxmox HCI trong 1 ngày, thực hành 100% trên lab thật.',
296
- cta: 'Đăng ký workshop',
297
- },
298
- training: {
299
- intro: 'Khóa học do giảng viên được Proxmox chứng nhận đứng lớp, kèm chứng nhận hoàn thành.',
300
- cta: 'Đăng ký khóa học',
301
- },
302
- }
303
-
304
- const activeTabData = computed(() => tabContent[activeTab.value])
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
-
338
- const activePlans = computed(() => {
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
- }
345
-
346
- 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
- }))
352
- })
353
-
354
- function formatPrice(value) {
355
- if (!value && value !== 0) return ''
356
- return new Intl.NumberFormat('vi-VN').format(value)
357
- }
358
- </script>
359
-
360
- <style scoped>
361
- .pricing-reveal {
362
- animation: pricing-card-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
363
- animation-delay: calc(var(--i, 0) * 110ms);
364
- animation-play-state: paused;
365
- }
366
-
367
- .pricing-revealed .pricing-reveal {
368
- animation-play-state: running;
369
- }
370
-
371
- @keyframes pricing-card-in {
372
- from {
373
- opacity: 0;
374
- transform: translateY(24px);
375
- }
376
- to {
377
- opacity: 1;
378
- transform: translateY(0);
379
- }
380
- }
381
-
382
- .fade-swap-enter-active,
383
- .fade-swap-leave-active {
384
- transition: opacity 0.3s ease, transform 0.3s ease;
385
- }
386
-
387
- .fade-swap-enter-from {
388
- opacity: 0;
389
- transform: translateY(10px);
390
- }
391
-
392
- .fade-swap-leave-to {
393
- opacity: 0;
394
- transform: translateY(-6px);
395
- }
396
- </style>
1
+ <template>
2
+ <section id="goi-san-pham" ref="sectionRoot" class="py-24 bg-white overflow-hidden" :class="{ 'pricing-revealed': revealed }">
3
+ <div class="max-w-6xl mx-auto px-6 lg:px-8">
4
+ <div class="text-center mb-12 pricing-header">
5
+ <h2 class="text-[#004AC6] font-bold uppercase tracking-widest text-sm mb-2">Gói sản phẩm</h2>
6
+ <h3 class="font-['Montserrat'] font-extrabold text-2xl sm:text-4xl text-slate-900 mb-4">
7
+ Chọn gói phù hợp<br class="hidden sm:block" />
8
+ với nhu cầu của bạn
9
+ </h3>
10
+ <p class="text-slate-500 max-w-2xl mx-auto leading-relaxed">
11
+ Thuê hạ tầng Proxmox HCI theo tháng, tham gia workshop 1 ngày hoặc chọn khóa đào tạo chuyên sâu. Chi phí
12
+ minh bạch, điều chỉnh theo yêu cầu riêng.
13
+ </p>
14
+
15
+ <div class="flex flex-wrap items-center justify-center gap-3 mt-8">
16
+ <button
17
+ v-for="tab in tabs"
18
+ :key="tab.id"
19
+ type="button"
20
+ @click="activeTab = tab.id"
21
+ class="inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-bold transition-all duration-300 cursor-pointer"
22
+ :class="activeTab === tab.id
23
+ ? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 scale-[1.03]'
24
+ : 'bg-white text-slate-500 border border-slate-200 hover:border-blue-200 hover:text-[#004AC6]'
25
+ "
26
+ >
27
+ <Icon :name="tab.icon" class="w-4 h-4" />
28
+ {{ tab.label }}
29
+ </button>
30
+ </div>
31
+
32
+ <Transition name="fade-swap" mode="out-in">
33
+ <p :key="activeTab" class="text-slate-400 text-sm max-w-xl mx-auto mt-5">
34
+ {{ activeTabData.intro }}
35
+ </p>
36
+ </Transition>
37
+ </div>
38
+
39
+ <Transition name="fade-swap" mode="out-in">
40
+ <div :key="activeTab">
41
+ <!-- Info cards: only for Proxmox HCI -->
42
+ <div v-if="activeTab === 'hci'" class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 mb-16">
43
+ <div
44
+ v-for="(info, i) in hciInfoCards"
45
+ :key="info.title"
46
+ class="pricing-reveal bg-white rounded-2xl border border-slate-200 p-6 hover:border-blue-200 hover:shadow-lg transition-all duration-300"
47
+ :style="{ '--i': i }"
48
+ >
49
+ <div class="w-11 h-11 rounded-[10px] bg-blue-50 flex items-center justify-center mb-5 text-[#004AC6]">
50
+ <Icon :name="info.icon" class="w-5 h-5" />
51
+ </div>
52
+ <h4 class="font-bold text-slate-900 mb-2">{{ info.title }}</h4>
53
+ <p class="text-sm text-slate-500 leading-relaxed">{{ info.desc }}</p>
54
+ </div>
55
+ </div>
56
+
57
+ <!-- Billing period switch: only for Proxmox HCI -->
58
+ <div v-if="activeTab === 'hci'" class="flex flex-col items-center gap-4 mb-10">
59
+ <p class="text-slate-500 text-sm">Chọn kỳ hạn thuê, càng dài chi phí hàng tháng càng thấp</p>
60
+ <div class="inline-flex items-center gap-1 bg-white border border-slate-200 rounded-full p-1">
61
+ <button
62
+ v-for="period in billingPeriods"
63
+ :key="period.value"
64
+ type="button"
65
+ @click="activePeriod = period.value"
66
+ class="px-4 py-2 rounded-full text-sm font-semibold transition-all duration-300 cursor-pointer"
67
+ :class="activePeriod === period.value
68
+ ? 'bg-[#004AC6] text-white shadow-md shadow-blue-500/25'
69
+ : 'text-slate-500 hover:text-[#004AC6]'
70
+ "
71
+ >
72
+ {{ period.label }}
73
+ </button>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- Pricing cards -->
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">
80
+ <div
81
+ v-for="(plan, i) in activePlans"
82
+ :key="plan.name"
83
+ class="pricing-reveal group relative"
84
+ :style="{ '--i': i }"
85
+ :class="plan.highlight ? 'lg:-mt-6' : 'lg:mt-0'"
86
+ >
87
+ <div
88
+ v-if="plan.highlight"
89
+ 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"
90
+ ></div>
91
+
92
+ <div
93
+ class="relative bg-white rounded-2xl flex flex-col overflow-hidden h-full transition-all duration-300 ease-out"
94
+ :class="plan.highlight
95
+ ? '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'
96
+ : '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'
97
+ "
98
+ >
99
+ <div
100
+ v-if="plan.highlight"
101
+ class="bg-gradient-to-r from-[#004AC6] to-[#1D63E0] text-white text-[11px] font-bold py-2.5 text-center uppercase tracking-widest flex items-center justify-center gap-1.5"
102
+ >
103
+ <Icon name="lucide:star" class="w-3.5 h-3.5" />
104
+ Được chọn nhiều nhất
105
+ </div>
106
+
107
+ <div class="p-7 flex flex-col gap-4">
108
+ <div>
109
+ <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
+ </div>
112
+
113
+ <div class="pb-1">
114
+ <span v-if="plan.contact" class="font-['Montserrat'] font-extrabold text-2xl text-[#004AC6]">
115
+ Liên hệ
116
+ </span>
117
+ <template v-else>
118
+ <span class="font-['Montserrat'] font-extrabold text-2xl sm:text-[28px] text-slate-900">
119
+ {{ plan.priceLabel }}{{ formatPrice(plan.price) }}đ
120
+ </span>
121
+ <span class="text-slate-400 text-sm">{{ plan.unit }}</span>
122
+ </template>
123
+ <p class="text-slate-400 text-xs mt-1.5">{{ plan.priceNote }}</p>
124
+ </div>
125
+
126
+ <div class="flex flex-wrap gap-1.5">
127
+ <span
128
+ v-for="tag in plan.tags"
129
+ :key="tag"
130
+ class="text-[11px] font-medium text-slate-500 bg-slate-50 border border-slate-100 rounded-md px-2 py-1"
131
+ >
132
+ {{ tag }}
133
+ </span>
134
+ </div>
135
+
136
+ <ul class="flex flex-col gap-2.5 pt-4 border-t border-slate-100">
137
+ <li
138
+ v-for="feature in plan.features"
139
+ :key="feature"
140
+ class="flex items-start gap-2 text-sm text-slate-600"
141
+ >
142
+ <Icon name="lucide:check" class="w-4 h-4 text-[#004AC6] mt-0.5 shrink-0" />
143
+ {{ feature }}
144
+ </li>
145
+ </ul>
146
+ </div>
147
+
148
+ <div class="p-7 pt-2 mt-auto">
149
+ <a
150
+ v-if="plan.url"
151
+ :href="plan.url"
152
+ target="_blank"
153
+ rel="noopener noreferrer"
154
+ class="w-full py-3.5 rounded-xl font-bold text-sm transition-all duration-300 cursor-pointer inline-flex items-center justify-center gap-2"
155
+ :class="plan.highlight
156
+ ? 'bg-[#004AC6] text-white shadow-lg shadow-blue-500/25 hover:bg-[#1D4ED8] hover:shadow-blue-500/40'
157
+ : 'border border-slate-200 text-slate-600 hover:border-[#004AC6] hover:text-[#004AC6] hover:bg-blue-50/50'
158
+ "
159
+ >
160
+ Đăng ký ngay
161
+ <Icon name="lucide:arrow-up-right" class="w-4 h-4" />
162
+ </a>
163
+ <button
164
+ v-else
165
+ 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
+ "
172
+ >
173
+ {{ activeTabData.cta }}
174
+ </button>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </Transition>
180
+ </div>
181
+ </Transition>
182
+ </div>
183
+ </section>
184
+ </template>
185
+
186
+ <script setup>
187
+ import { hciPlans, workshopPlans, trainingPlans } from '../data/pricingPlans'
188
+
189
+ const props = defineProps({
190
+ groupsItem01: { type: String, default: '' },
191
+ groupsItem02: { type: String, default: '' },
192
+ groupsItem03: { type: String, default: '' },
193
+ itemId01Groups01: { type: Object, default: null },
194
+ itemId02Groups01: { type: Object, default: null },
195
+ itemId03Groups01: { type: Object, default: null },
196
+ itemId01Groups02: { type: Object, default: null },
197
+ itemId02Groups02: { type: Object, default: null },
198
+ itemId03Groups02: { type: Object, default: null },
199
+ itemId01Groups03: { type: Object, default: null },
200
+ itemId02Groups03: { type: Object, default: null },
201
+ itemId03Groups03: { type: Object, default: null },
202
+ })
203
+
204
+ useScrollAnimate('.pricing-header', 'fade-up')
205
+
206
+ const router = useRouter()
207
+
208
+ // Không có url riêng -> mở trang đăng ký tư vấn, mang theo thông tin gói qua query (giống CoursesSection.handleSelectPlan)
209
+ const categoryLevelLabels = {
210
+ hci: 'Gói thuê hạ tầng',
211
+ workshop: 'Workshop',
212
+ training: 'Khóa đào tạo',
213
+ }
214
+
215
+ function handleContactClick(plan) {
216
+ const priceText = plan.priceLabel || typeof plan.price === 'number'
217
+ ? `${plan.priceLabel || ''}${formatPrice(plan.price)}đ${plan.unit ? ' ' + plan.unit : ''}`
218
+ : ''
219
+
220
+ router.push({
221
+ path: `/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
+ const sectionRoot = ref(null)
235
+ const revealed = ref(false)
236
+
237
+ onMounted(() => {
238
+ const observer = new IntersectionObserver(
239
+ (entries) => {
240
+ for (const entry of entries) {
241
+ if (entry.isIntersecting) {
242
+ revealed.value = true
243
+ observer.disconnect()
244
+ }
245
+ }
246
+ },
247
+ { threshold: 0.15 }
248
+ )
249
+
250
+ if (sectionRoot.value) observer.observe(sectionRoot.value)
251
+ onUnmounted(() => observer.disconnect())
252
+ })
253
+
254
+ const activeTab = ref('hci')
255
+ const activePeriod = ref(36)
256
+
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
+ ])
264
+
265
+ const billingPeriods = [
266
+ { value: 12, label: 'Thuê 12 tháng' },
267
+ { value: 24, label: 'Thuê 24 tháng' },
268
+ { value: 36, label: 'Thuê 36 tháng' },
269
+ ]
270
+
271
+ const hciInfoCards = [
272
+ {
273
+ icon: 'lucide:server',
274
+ title: 'Thuê trọn gói tại DC của chúng tôi',
275
+ desc: 'Cụm đặt tại trung tâm dữ liệu Tier III, gồm phần cứng, điện, mạng và vận hành. Bạn chỉ dùng và trả phí hàng tháng.',
276
+ },
277
+ {
278
+ icon: 'lucide:building-2',
279
+ title: 'Thuê phần cứng đặt tại doanh nghiệp',
280
+ desc: 'Chúng tôi mang thiết bị tới đặt tại phòng máy của bạn, triển khai và bảo hành. Dữ liệu nằm trong nội bộ, không cần đầu tư ban đầu.',
281
+ },
282
+ {
283
+ icon: 'lucide:refresh-cw',
284
+ title: 'Thuê linh hoạt theo node',
285
+ desc: 'Thêm hoặc bớt node theo từng tháng khi nhu cầu thay đổi. Có tùy chọn chuyển quyền sở hữu thiết bị khi hết hạn thuê.',
286
+ },
287
+ ]
288
+
289
+ const tabContent = {
290
+ hci: {
291
+ intro: 'Thuê cụm siêu hội tụ theo tháng: không đầu tư ban đầu, đã bao gồm phần cứng, triển khai Ceph, HA và đội ngũ vận hành.',
292
+ cta: 'Nhận báo giá',
293
+ },
294
+ workshop: {
295
+ intro: 'Workshop cài đặt & triển khai Proxmox HCI trong 1 ngày, thực hành 100% trên lab thật.',
296
+ cta: 'Đăng ký workshop',
297
+ },
298
+ training: {
299
+ intro: 'Khóa học do giảng viên được Proxmox chứng nhận đứng lớp, kèm chứng nhận hoàn thành.',
300
+ cta: 'Đăng ký khóa học',
301
+ },
302
+ }
303
+
304
+ const activeTabData = computed(() => tabContent[activeTab.value])
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
+
338
+ const activePlans = computed(() => {
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
+ }
345
+
346
+ 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
+ }))
352
+ })
353
+
354
+ function formatPrice(value) {
355
+ if (!value && value !== 0) return ''
356
+ return new Intl.NumberFormat('vi-VN').format(value)
357
+ }
358
+ </script>
359
+
360
+ <style scoped>
361
+ .pricing-reveal {
362
+ animation: pricing-card-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
363
+ animation-delay: calc(var(--i, 0) * 110ms);
364
+ animation-play-state: paused;
365
+ }
366
+
367
+ .pricing-revealed .pricing-reveal {
368
+ animation-play-state: running;
369
+ }
370
+
371
+ @keyframes pricing-card-in {
372
+ from {
373
+ opacity: 0;
374
+ transform: translateY(24px);
375
+ }
376
+ to {
377
+ opacity: 1;
378
+ transform: translateY(0);
379
+ }
380
+ }
381
+
382
+ .fade-swap-enter-active,
383
+ .fade-swap-leave-active {
384
+ transition: opacity 0.3s ease, transform 0.3s ease;
385
+ }
386
+
387
+ .fade-swap-enter-from {
388
+ opacity: 0;
389
+ transform: translateY(10px);
390
+ }
391
+
392
+ .fade-swap-leave-to {
393
+ opacity: 0;
394
+ transform: translateY(-6px);
395
+ }
396
+ </style>