nuxt-layer-core 1.0.1

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 (85) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/app/app.config.ts +5 -0
  3. package/app/app.vue +11 -0
  4. package/app/assets/css/main.css +113 -0
  5. package/app/components/.gitkeep +0 -0
  6. package/app/components/ui/sonner/Sonner.vue +62 -0
  7. package/app/components/ui/sonner/index.ts +1 -0
  8. package/app/composables/.gitkeep +0 -0
  9. package/app/layers/solution/app/assets/proxmox-css/main.css +89 -0
  10. package/app/layers/solution/app/assets/proxmox-css/proxmox-utils.css +68 -0
  11. package/app/layers/solution/app/components/ClusterDiagram.vue +72 -0
  12. package/app/layers/solution/app/components/CoursesSection.vue +245 -0
  13. package/app/layers/solution/app/components/CtaSection.vue +347 -0
  14. package/app/layers/solution/app/components/DynamicPageSections.vue +103 -0
  15. package/app/layers/solution/app/components/FeaturesSection.vue +67 -0
  16. package/app/layers/solution/app/components/HeroSection.vue +144 -0
  17. package/app/layers/solution/app/components/PricingPlansSection.vue +396 -0
  18. package/app/layers/solution/app/components/RegistrationForm.vue +283 -0
  19. package/app/layers/solution/app/components/RegistrationSection.vue +24 -0
  20. package/app/layers/solution/app/components/RegistrationSidebar.vue +70 -0
  21. package/app/layers/solution/app/components/TestimonialsSection.vue +58 -0
  22. package/app/layers/solution/app/components/TheFooter.vue +61 -0
  23. package/app/layers/solution/app/components/TheHeader.vue +193 -0
  24. package/app/layers/solution/app/components/proxmox/AppIcon.vue +45 -0
  25. package/app/layers/solution/app/components/proxmox/ComparisonSection.vue +54 -0
  26. package/app/layers/solution/app/components/proxmox/FaqSection.vue +58 -0
  27. package/app/layers/solution/app/components/proxmox/HciOverviewSection.vue +37 -0
  28. package/app/layers/solution/app/components/proxmox/HeroSection.vue +74 -0
  29. package/app/layers/solution/app/components/proxmox/TestimonialsSection.vue +44 -0
  30. package/app/layers/solution/app/components/proxmox/TrainingBenefitsSection.vue +37 -0
  31. package/app/layers/solution/app/components/proxmox/VeFeaturesSection.vue +55 -0
  32. package/app/layers/solution/app/composables/useActiveSection.ts +47 -0
  33. package/app/layers/solution/app/composables/useAuthApi.ts +244 -0
  34. package/app/layers/solution/app/composables/useAuthState.ts +16 -0
  35. package/app/layers/solution/app/composables/useConsultationRequest.ts +8 -0
  36. package/app/layers/solution/app/composables/useCookie.ts +79 -0
  37. package/app/layers/solution/app/composables/useCookieHelper.ts +81 -0
  38. package/app/layers/solution/app/composables/useCrmApi.ts +32 -0
  39. package/app/layers/solution/app/composables/useDynamicCollection.ts +23 -0
  40. package/app/layers/solution/app/composables/usePlanNormalizer.ts +45 -0
  41. package/app/layers/solution/app/composables/useScrollAnimate.ts +40 -0
  42. package/app/layers/solution/app/composables/useScrollToSection.ts +13 -0
  43. package/app/layers/solution/app/composables/useSolutionApi.ts +73 -0
  44. package/app/layers/solution/app/composables/useToast.ts +14 -0
  45. package/app/layers/solution/app/data/pricingPlans.ts +150 -0
  46. package/app/layers/solution/app/data/proxmox-content.ts +158 -0
  47. package/app/layers/solution/app/images/Proxmox HCI.png +0 -0
  48. package/app/layers/solution/app/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
  49. package/app/layers/solution/app/layouts/default.vue +5 -0
  50. package/app/layers/solution/app/nuxt.config.ts +15 -0
  51. package/app/layers/solution/app/pages/[slug]/[slug2]/[slug3]/index.vue +22 -0
  52. package/app/layers/solution/app/pages/[slug]/[slug2]/index.vue +23 -0
  53. package/app/layers/solution/app/pages/[slug]/index.vue +22 -0
  54. package/app/layers/solution/app/pages/cap-nhat-thong-tin/index.vue.disabled +302 -0
  55. package/app/layers/solution/app/pages/dang-ky/index.vue.disabled +627 -0
  56. package/app/layers/solution/app/pages/dang-ky-tu-van/khoa-hoc-proxmox.vue.disabled +39 -0
  57. package/app/layers/solution/app/pages/dang-nhap/index.vue.disabled +208 -0
  58. package/app/layers/solution/app/pages/doi-mat-khau/index.vue.disabled +237 -0
  59. package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/index.vue.disabled +37 -0
  60. package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue.disabled +45 -0
  61. package/app/layers/solution/app/pages/giai-phap-proxmox/dang-ky-khoa-hoc/[slug].vue.disabled +56 -0
  62. package/app/layers/solution/app/pages/quen-mat-khau/index.vue.disabled +136 -0
  63. package/app/layers/solution/app/pages/xac-thuc/index.vue.disabled +320 -0
  64. package/app/layers/solution/app/plugins/reveal.ts +56 -0
  65. package/app/layers/solution/app/public/images/image_from_https_hercules_cdn.com_file_6dztkvkfnip0ud23tzzvu57c.png +0 -0
  66. package/app/layers/solution/app/public/images/logo-lvs-1300x330.png +0 -0
  67. package/app/layouts/auth.vue +71 -0
  68. package/app/lib/utils.ts +7 -0
  69. package/app/middleware/.gitkeep +0 -0
  70. package/app/modules/layer-route-priority.ts +60 -0
  71. package/app/pages/index.vue +10 -0
  72. package/app/pages/san-pham-proxmox.vue +8 -0
  73. package/app/plugins/.gitkeep +0 -0
  74. package/app/plugins/sdk.ts +29 -0
  75. package/app/stores/.gitkeep +0 -0
  76. package/app/types/sdk.d.ts +15 -0
  77. package/components.json +24 -0
  78. package/nuxt.config.ts +105 -0
  79. package/package.json +52 -0
  80. package/public/.gitkeep +0 -0
  81. package/server/api/.gitkeep +0 -0
  82. package/server/utils/.gitkeep +0 -0
  83. package/tailwind.config.js +96 -0
  84. package/tsconfig.base.json +18 -0
  85. package/tsconfig.json +18 -0
@@ -0,0 +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: `/dang-ky-tu-van/khoa-hoc-proxmox`,
222
+ query: {
223
+ plan: plan.id || plan.name,
224
+ name: plan.name,
225
+ level: categoryLevelLabels[activeTab.value] || '',
226
+ // plan.unit là hậu tố giá ("/tháng", "/học viên"), không phải thời lượng -> ưu tiên tags[0] ("1 ngày · 8 giờ")
227
+ duration: plan.tags?.[0] || '',
228
+ price: priceText,
229
+ description: plan.desc || '',
230
+ },
231
+ })
232
+ }
233
+
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>
@@ -0,0 +1,283 @@
1
+ <script setup lang="ts">
2
+ const props = withDefaults(defineProps<{
3
+ plans?: Array<{
4
+ id: string
5
+ name: string
6
+ level: string
7
+ duration: string
8
+ icon: string
9
+ price?: string
10
+ description?: string
11
+ }>
12
+ selectedPlanId?: string
13
+ }>(), {
14
+ plans: () => [],
15
+ })
16
+
17
+ const { addOpportunity } = useCrmApi()
18
+
19
+ // Form state
20
+ const selectedCourseId = ref(props.selectedPlanId || '')
21
+ const form = reactive({ name: '', phone: '', email: '', note: '' })
22
+ const studyMode = ref('online')
23
+ const errors = reactive({ name: '', phone: '', email: '', course: '' })
24
+ const touched = reactive({ name: false, phone: false, email: false, course: false })
25
+ const submitting = ref(false)
26
+ const submitSuccess = ref(false)
27
+
28
+ // Auto-select first plan if none selected
29
+ watch(() => props.plans, (list: any) => {
30
+ if (!selectedCourseId.value && list.length > 0) {
31
+ selectedCourseId.value = list[0].id
32
+ }
33
+ }, { immediate: true })
34
+
35
+ // Sync when selectedPlanId prop changes
36
+ watch(() => props.selectedPlanId, (id) => {
37
+ if (id) selectedCourseId.value = id
38
+ })
39
+
40
+ const selectedPlan = computed(() => props.plans.find((p) => p.id === selectedCourseId.value))
41
+
42
+ // Validation
43
+ function touch(field: keyof typeof touched) {
44
+ touched[field] = true
45
+ validateField(field)
46
+ }
47
+
48
+ function validateField(field: keyof typeof touched) {
49
+ if (field === 'name') {
50
+ if (!form.name) errors.name = 'Vui lòng nhập họ tên'
51
+ else if (form.name.length < 2) errors.name = 'Họ tên phải có ít nhất 2 ký tự'
52
+ else errors.name = ''
53
+ }
54
+ if (field === 'phone') {
55
+ if (!form.phone) errors.phone = 'Vui lòng nhập số điện thoại'
56
+ else if (!/^(0|\+84)\d{9,10}$/.test(form.phone.replace(/\s/g, ''))) errors.phone = 'Số điện thoại không hợp lệ'
57
+ else errors.phone = ''
58
+ }
59
+ if (field === 'email') {
60
+ if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Email không hợp lệ'
61
+ else errors.email = ''
62
+ }
63
+ if (field === 'course') {
64
+ if (!selectedCourseId.value) errors.course = 'Vui lòng chọn khóa học'
65
+ else errors.course = ''
66
+ }
67
+ }
68
+
69
+ function formatLevel(level?: string) {
70
+ const labels: Record<string, string> = {
71
+ basic: 'Cơ bản',
72
+ advanced: 'Nâng cao',
73
+ expert: 'Chuyên gia',
74
+ }
75
+
76
+ return labels[level || ''] || level || ''
77
+ }
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
+
84
+ function validateAll() {
85
+ ; (['name', 'phone', 'email', 'course'] as const).forEach((f) => {
86
+ touched[f] = true
87
+ validateField(f)
88
+ })
89
+ return !errors.name && !errors.phone && !errors.email && !errors.course
90
+ }
91
+
92
+ // Submit
93
+ async function handleSubmit() {
94
+ if (!validateAll()) return
95
+ submitting.value = true
96
+ submitSuccess.value = false
97
+
98
+ try {
99
+ const hostname = window.location.origin
100
+ const studyModeLabel =
101
+ studyMode.value === 'online'
102
+ ? 'Học trực tuyến (Online)'
103
+ : studyMode.value === 'offline'
104
+ ? 'Học tại lớp (Offline)'
105
+ : 'Đào tạo doanh nghiệp'
106
+
107
+ const levelLabel =
108
+ selectedPlan.value?.level === 'basic'
109
+ ? 'Cơ bản'
110
+ : selectedPlan.value?.level === 'advanced'
111
+ ? 'Nâng cao'
112
+ : selectedPlan.value?.level === 'expert'
113
+ ? 'Chuyên gia'
114
+ : selectedPlan.value?.level || ''
115
+
116
+
117
+ const description = [
118
+ `<strong>Khóa học:</strong> ${selectedPlan.value?.name || ''}`,
119
+ `<strong>Trình độ:</strong> ${levelLabel}`,
120
+ `<strong>Thời lượng:</strong> ${selectedPlan.value?.duration || ''}`,
121
+ `<strong>Hình thức học:</strong> ${studyModeLabel}`,
122
+ `<strong>Họ và tên:</strong> ${form.name}`,
123
+ `<strong>Số điện thoại:</strong> ${form.phone}`,
124
+ `<strong>Email:</strong> ${form.email || 'Không cung cấp'}`,
125
+ `<strong>Ghi chú:</strong> ${form.note || 'Không có'}`,
126
+ ].join('<br>')
127
+
128
+ await addOpportunity({
129
+ name: `Yêu cầu đăng ký khóa học ${selectedPlan.value?.name}`,
130
+ description: description,
131
+ referName: form.name,
132
+ referPhone: form.phone,
133
+ referEmail: form.email,
134
+ targetUrl: `${hostname}${window.location.pathname}`,
135
+ extSource: 'WEBSITE',
136
+ })
137
+ submitSuccess.value = true
138
+
139
+ setTimeout(() => {
140
+ submitSuccess.value = false
141
+ }, 2000)
142
+ form.name = ''
143
+ form.phone = ''
144
+ form.email = ''
145
+ form.note = ''
146
+ Object.keys(touched).forEach((k) => (touched[k as keyof typeof touched] = false))
147
+ } catch (err) {
148
+ console.error('Error submitting registration:', err)
149
+ errors.name = 'Gửi đăng ký thất bại, vui lòng thử lại.'
150
+ touched.name = true
151
+ } finally {
152
+ submitting.value = false
153
+ }
154
+ }
155
+ </script>
156
+
157
+ <template>
158
+ <section class="rounded-2xl border border-[#e7e9f0] bg-white p-8">
159
+ <h2 class="mb-5 text-[17px] font-bold text-slate-900">Thông tin đăng ký</h2>
160
+
161
+ <form @submit.prevent="handleSubmit">
162
+ <!-- Course selection -->
163
+ <div class="mb-5">
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]">
184
+ <label v-for="course in plans" :key="course.id"
185
+ class="relative flex cursor-pointer items-center gap-3 rounded-[10px] border px-4 py-[14px] transition-colors"
186
+ :class="selectedCourseId === course.id ? 'border-[#2f5fe0] bg-[#eef3ff]' : 'border-[#e2e5ee] bg-white'">
187
+ <input type="radio" name="course" class="absolute h-0 w-0 opacity-0" :value="course.id"
188
+ v-model="selectedCourseId" @change="touch('course')">
189
+ <span class="relative h-[18px] w-[18px] flex-none rounded-[10px] border-2 transition-colors"
190
+ :class="selectedCourseId === course.id ? 'border-[#2f5fe0]' : 'border-[#cbd2e1]'">
191
+ <span v-if="selectedCourseId === course.id" class="absolute inset-[3px] rounded-[10px] bg-[#2f5fe0]" />
192
+ </span>
193
+ <span class="flex flex-col gap-[2px]">
194
+ <span class="text-sm font-semibold text-slate-900">{{ course.name }}</span>
195
+ <span v-if="planMetaLine(course)" class="text-[12.5px] text-gray-500">{{ planMetaLine(course) }}</span>
196
+ </span>
197
+ </label>
198
+ </div>
199
+ <p v-if="touched.course && errors.course" class="mt-1 text-[12px] text-red-500">{{ errors.course }}</p>
200
+ </div>
201
+
202
+ <!-- Name & Phone -->
203
+ <div class="mb-5 grid grid-cols-1 gap-4 sm:grid-cols-2">
204
+ <div>
205
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700" for="fullname">Họ và tên</label>
206
+ <input id="fullname" v-model="form.name" type="text"
207
+ class="w-full rounded-[10px] border px-[14px] py-[11px] text-sm text-slate-900 placeholder:text-gray-400 focus:outline-none focus:ring-[3px]"
208
+ :class="touched.name && errors.name ? 'border-red-400 focus:border-red-400 focus:ring-red-400/15' : 'border-[#e2e5ee] focus:border-[#2f5fe0] focus:ring-[#2f5fe0]/15'"
209
+ placeholder="Nguyễn Văn A" @blur="touch('name')">
210
+ <p v-if="touched.name && errors.name" class="mt-1 text-[12px] text-red-500">{{ errors.name }}</p>
211
+ </div>
212
+ <div>
213
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700" for="phone">Số điện thoại</label>
214
+ <input id="phone" v-model="form.phone" type="tel"
215
+ class="w-full rounded-[10px] border px-[14px] py-[11px] text-sm text-slate-900 placeholder:text-gray-400 focus:outline-none focus:ring-[3px]"
216
+ :class="touched.phone && errors.phone ? 'border-red-400 focus:border-red-400 focus:ring-red-400/15' : 'border-[#e2e5ee] focus:border-[#2f5fe0] focus:ring-[#2f5fe0]/15'"
217
+ placeholder="0912 345 678" @blur="touch('phone')">
218
+ <p v-if="touched.phone && errors.phone" class="mt-1 text-[12px] text-red-500">{{ errors.phone }}</p>
219
+ </div>
220
+ </div>
221
+
222
+ <!-- Email -->
223
+ <div class="mb-5">
224
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700" for="email">Email</label>
225
+ <input id="email" v-model="form.email" type="email"
226
+ class="w-full rounded-[10px] border px-[14px] py-[11px] text-sm text-slate-900 placeholder:text-gray-400 focus:outline-none focus:ring-[3px]"
227
+ :class="touched.email && errors.email ? 'border-red-400 focus:border-red-400 focus:ring-red-400/15' : 'border-[#e2e5ee] focus:border-[#2f5fe0] focus:ring-[#2f5fe0]/15'"
228
+ placeholder="example@congty.vn" @blur="touch('email')">
229
+ <p v-if="touched.email && errors.email" class="mt-1 text-[12px] text-red-500">{{ errors.email }}</p>
230
+ </div>
231
+
232
+ <!-- Study mode -->
233
+ <div class="mb-5">
234
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700">Hình thức học</label>
235
+ <div class="flex flex-wrap gap-[10px]">
236
+ <button type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
237
+ :class="studyMode === 'online' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
238
+ @click="studyMode = 'online'">
239
+ Học trực tuyến (Online)
240
+ </button>
241
+ <button type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
242
+ :class="studyMode === 'offline' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
243
+ @click="studyMode = 'offline'">
244
+ Học tại lớp (Offline)
245
+ </button>
246
+ <button type="button" class="rounded-[10px] border px-4 py-[9px] text-[13.5px] font-medium transition-colors"
247
+ :class="studyMode === 'enterprise' ? 'border-[#2f5fe0] bg-[#2f5fe0] text-white' : 'border-[#e2e5ee] bg-white text-gray-700'"
248
+ @click="studyMode = 'enterprise'">
249
+ Đào tạo doanh nghiệp
250
+ </button>
251
+ </div>
252
+ </div>
253
+
254
+ <!-- Notes -->
255
+ <div class="mb-5">
256
+ <label class="mb-[10px] block text-[13px] font-semibold text-gray-700" for="note">Ghi chú (tùy chọn)</label>
257
+ <textarea id="note" v-model="form.note" rows="3"
258
+ class="w-full min-h-[84px] resize-y rounded-[10px] border border-[#e2e5ee] bg-[#fafafb] px-[14px] py-[11px] text-sm text-slate-900 placeholder:text-gray-400 focus:border-[#2f5fe0] focus:outline-none focus:ring-[3px] focus:ring-[#2f5fe0]/15"
259
+ placeholder="Kinh nghiệm hiện tại, mục tiêu học tập..." />
260
+ </div>
261
+
262
+ <!-- Submit -->
263
+ <button type="submit" :disabled="submitting"
264
+ class="flex w-full items-center justify-center gap-2 rounded-[10px] bg-[#2f5fe0] px-5 py-[13px] text-[14.5px] font-semibold text-white transition-colors hover:bg-[#274ec2] disabled:opacity-60">
265
+ <span v-if="submitting">Đang gửi...</span>
266
+ <template v-else>
267
+ Gửi đăng ký
268
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
269
+ stroke-linecap="round" stroke-linejoin="round">
270
+ <line x1="22" y1="2" x2="11" y2="13" />
271
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
272
+ </svg>
273
+ </template>
274
+ </button>
275
+ </form>
276
+
277
+ <!-- Success message -->
278
+ <div v-if="submitSuccess"
279
+ class="mb-5 mt-5 rounded-[10px] border border-green-200 bg-green-50 p-4 text-sm text-green-700">
280
+ Đăng ký thành công! Chúng tôi sẽ liên hệ với bạn trong thời gian sớm nhất.
281
+ </div>
282
+ </section>
283
+ </template>