nuxt-layer-core 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/layers/solution/app/components/CtaSection.vue +347 -347
- package/app/layers/solution/app/components/HeroSection.vue +143 -143
- package/app/layers/solution/app/components/RegistrationForm.vue +9 -6
- package/app/layers/solution/app/components/RegistrationSection.vue +24 -24
- package/app/layers/solution/app/components/RegistrationSidebar.vue +70 -70
- package/app/layers/solution/app/components/TheHeader.vue +255 -192
- package/app/layers/solution/app/components/proxmox/ComparisonSection.vue +9 -9
- package/app/layers/solution/app/components/proxmox/HciOverviewSection.vue +1 -1
- package/app/layers/solution/app/components/proxmox/PainPointsSection.vue +43 -0
- package/app/layers/solution/app/components/proxmox/TrainingBenefitsSection.vue +1 -1
- package/app/layers/solution/app/data/proxmox-content.ts +196 -158
- package/app/layers/solution/app/pages/giai-phap-proxmox/[slug]/proxmox-hci/index.vue.disabled +1 -0
- package/package.json +52 -52
|
@@ -1,347 +1,347 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<section id="lien-he" class="py-24 bg-white border-t border-slate-200">
|
|
3
|
-
<div class="max-w-7xl mx-auto px-6 lg:px-8">
|
|
4
|
-
<div class="cta-card bg-[#F7F9FB] rounded-3xl flex flex-col md:flex-row shadow-2xl">
|
|
5
|
-
<div
|
|
6
|
-
class="md:w-5/12 bg-gradient-to-br from-[#004AC6] to-[#1D4ED8] p-
|
|
7
|
-
<p class="text-xs font-bold uppercase tracking-widest text-blue-100">Bắt đầu</p>
|
|
8
|
-
<h3 class="font-['Montserrat'] font-extrabold text-2xl sm:text-3xl leading-tight">
|
|
9
|
-
Đặt lịch tư vấn khóa học miễn phí, 30 phút
|
|
10
|
-
</h3>
|
|
11
|
-
<p class="text-blue-100/90 leading-relaxed">
|
|
12
|
-
Gửi thông tin hạ tầng hiện tại và mục tiêu học tập, đội ngũ kỹ thuật của Long Vân
|
|
13
|
-
sẽ phản hồi và đề xuất khóa học phù hợp trong vòng 24 giờ.
|
|
14
|
-
</p>
|
|
15
|
-
<div class="flex items-center gap-4">
|
|
16
|
-
<div class="w-10 h-10 rounded-[10px] bg-white/10 flex items-center justify-center">
|
|
17
|
-
<Icon name="lucide:mail" class="text-[18px]" />
|
|
18
|
-
</div>
|
|
19
|
-
<span class="text-sm">tuvan@longvan.net</span>
|
|
20
|
-
</div>
|
|
21
|
-
<div class="flex items-center gap-4">
|
|
22
|
-
<div class="w-10 h-10 rounded-[10px] bg-white/10 flex items-center justify-center">
|
|
23
|
-
<Icon name="lucide:phone" class="text-[18px]" />
|
|
24
|
-
</div>
|
|
25
|
-
<span class="text-sm">1800 6070</span>
|
|
26
|
-
</div>
|
|
27
|
-
</div>
|
|
28
|
-
|
|
29
|
-
<div class="md:w-7/12 p-
|
|
30
|
-
<form class="space-y-5" @submit.prevent="handleSubmit" novalidate>
|
|
31
|
-
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
|
32
|
-
<div class="space-y-1.5">
|
|
33
|
-
<label for="cta-name" class="text-xs font-bold uppercase text-slate-500">
|
|
34
|
-
Họ và tên <span class="text-red-500">*</span>
|
|
35
|
-
</label>
|
|
36
|
-
<input id="cta-name" v-model.trim="form.name" type="text" @blur="touch('name')"
|
|
37
|
-
class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
|
|
38
|
-
:class="errors.name && touched.name ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
|
|
39
|
-
placeholder="Nguyễn Văn A" />
|
|
40
|
-
<p v-if="errors.name && touched.name" class="text-red-500 text-xs mt-1">{{ errors.name }}</p>
|
|
41
|
-
</div>
|
|
42
|
-
<div class="space-y-1.5">
|
|
43
|
-
<label for="cta-phone" class="text-xs font-bold uppercase text-slate-500">
|
|
44
|
-
Số điện thoại <span class="text-red-500">*</span>
|
|
45
|
-
</label>
|
|
46
|
-
<input id="cta-phone" v-model.trim="form.phone" type="tel" @blur="touch('phone')"
|
|
47
|
-
class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
|
|
48
|
-
:class="errors.phone && touched.phone ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
|
|
49
|
-
placeholder="0912 345 678" />
|
|
50
|
-
<p v-if="errors.phone && touched.phone" class="text-red-500 text-xs mt-1">{{ errors.phone }}</p>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
53
|
-
<div class="space-y-1.5">
|
|
54
|
-
<label for="cta-email" class="text-xs font-bold uppercase text-slate-500">Email</label>
|
|
55
|
-
<input id="cta-email" v-model.trim="form.email" type="email" @blur="touch('email')"
|
|
56
|
-
class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
|
|
57
|
-
:class="errors.email && touched.email ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
|
|
58
|
-
placeholder="ban@congty.com" />
|
|
59
|
-
<p v-if="errors.email && touched.email" class="text-red-500 text-xs mt-1">{{ errors.email }}</p>
|
|
60
|
-
</div>
|
|
61
|
-
|
|
62
|
-
<!-- Searchable dropdown -->
|
|
63
|
-
<div class="space-y-1.5" ref="dropdownRef">
|
|
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>
|
|
67
|
-
<div class="relative">
|
|
68
|
-
<div @click="toggleDropdown"
|
|
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"
|
|
70
|
-
:class="[
|
|
71
|
-
dropdownOpen ? 'border-[#004AC6] ring-2 ring-blue-200' : 'border-slate-300',
|
|
72
|
-
errors.course && touched.course ? 'border-red-400' : ''
|
|
73
|
-
]">
|
|
74
|
-
<span :class="selectedCourse ? 'text-slate-900' : 'text-slate-400'">
|
|
75
|
-
{{ selectedCourse ? selectedCourse.name : 'Chọn khóa học...' }}
|
|
76
|
-
</span>
|
|
77
|
-
<Icon name="lucide:chevron-down" class="text-[16px] text-slate-400 transition-transform"
|
|
78
|
-
:class="dropdownOpen ? 'rotate-180' : ''" />
|
|
79
|
-
</div>
|
|
80
|
-
|
|
81
|
-
<Transition enter-active-class="transition ease-out duration-150"
|
|
82
|
-
enter-from-class="opacity-0 -translate-y-1" enter-to-class="opacity-100 translate-y-0"
|
|
83
|
-
leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100 translate-y-0"
|
|
84
|
-
leave-to-class="opacity-0 -translate-y-1">
|
|
85
|
-
<div v-if="dropdownOpen"
|
|
86
|
-
class="absolute z-50 mt-1 w-full bg-white border border-slate-200 rounded-[10px] shadow-lg overflow-hidden">
|
|
87
|
-
<div class="p-2 border-b border-slate-100">
|
|
88
|
-
<div class="relative">
|
|
89
|
-
<Icon name="lucide:search"
|
|
90
|
-
class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-[14px]" />
|
|
91
|
-
<input ref="searchInput" v-model="searchQuery" type="text" placeholder="Tìm khóa học..."
|
|
92
|
-
class="w-full pl-9 pr-3 py-2 text-sm border border-slate-200 rounded-md outline-none focus:border-[#004AC6] focus:ring-1 focus:ring-blue-200"
|
|
93
|
-
@click.stop />
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
<div class="max-h-56 overflow-y-auto">
|
|
97
|
-
<div v-for="plan in filteredCourses" :key="plan.id" @click="selectCourse(plan)"
|
|
98
|
-
class="px-4 py-3 text-sm cursor-pointer flex items-center gap-3 transition-colors"
|
|
99
|
-
:class="selectedCourse?.id === plan.id ? 'bg-blue-50 text-[#004AC6]' : 'hover:bg-slate-50 text-slate-700'">
|
|
100
|
-
<Icon :name="plan.icon" class="text-[16px] shrink-0"
|
|
101
|
-
:class="selectedCourse?.id === plan.id ? 'text-[#004AC6]' : 'text-slate-400'" />
|
|
102
|
-
<div class="flex-1 min-w-0">
|
|
103
|
-
<p class="font-medium truncate">{{ plan.name }}</p>
|
|
104
|
-
<p class="text-xs text-slate-400 truncate">{{ formatLevel(plan.level) }} · {{ plan.duration }}
|
|
105
|
-
</p>
|
|
106
|
-
</div>
|
|
107
|
-
<Icon v-if="selectedCourse?.id === plan.id" name="lucide:check"
|
|
108
|
-
class="text-[16px] text-[#004AC6] shrink-0" />
|
|
109
|
-
</div>
|
|
110
|
-
<div v-if="filteredCourses.length === 0" class="px-4 py-6 text-center text-sm text-slate-400">
|
|
111
|
-
Không tìm thấy khóa học
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
</Transition>
|
|
116
|
-
</div>
|
|
117
|
-
<p v-if="errors.course && touched.course" class="text-red-500 text-xs mt-1">{{ errors.course }}</p>
|
|
118
|
-
</div>
|
|
119
|
-
|
|
120
|
-
<button type="submit" :disabled="submitting"
|
|
121
|
-
class="w-full bg-[#004AC6] text-white py-3.5 rounded-[10px] font-bold flex items-center justify-center gap-2 hover:bg-[#1D4ED8] transition-all shadow-lg active:scale-95 disabled:opacity-60 disabled:cursor-not-allowed">
|
|
122
|
-
<template v-if="submitting">
|
|
123
|
-
<span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-[10px] animate-spin"></span>
|
|
124
|
-
Đang gửi...
|
|
125
|
-
</template>
|
|
126
|
-
<template v-else>
|
|
127
|
-
Gửi yêu cầu tư vấn
|
|
128
|
-
<Icon name="lucide:send" class="text-[18px]" />
|
|
129
|
-
</template>
|
|
130
|
-
</button>
|
|
131
|
-
|
|
132
|
-
<Transition enter-active-class="transition ease-out duration-300" enter-from-class="opacity-0 translate-y-2"
|
|
133
|
-
enter-to-class="opacity-100 translate-y-0">
|
|
134
|
-
<div v-if="submitSuccess"
|
|
135
|
-
class="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-[10px] text-green-700 text-sm">
|
|
136
|
-
<Icon name="lucide:check-circle" class="text-[18px] text-green-500 shrink-0" />
|
|
137
|
-
Gửi yêu cầu tư vấn thành công! Chúng tôi sẽ liên hệ bạn trong 24 giờ.
|
|
138
|
-
</div>
|
|
139
|
-
</Transition>
|
|
140
|
-
</form>
|
|
141
|
-
</div>
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
</section>
|
|
145
|
-
</template>
|
|
146
|
-
|
|
147
|
-
<script setup>
|
|
148
|
-
useScrollAnimate('.cta-card', 'scale-up')
|
|
149
|
-
|
|
150
|
-
const props = defineProps({
|
|
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 },
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
const { addOpportunity } = useCrmApi()
|
|
158
|
-
|
|
159
|
-
const samplePlans = [
|
|
160
|
-
{ id: 'sample-1', name: 'Deployment & Management', icon: 'lucide:graduation-cap', level: 'Nền tảng', duration: '14 giờ' },
|
|
161
|
-
{ id: 'sample-2', name: 'Clustering & Shared Storage', icon: 'lucide:network', level: 'Chuyên sâu', duration: '14 giờ' },
|
|
162
|
-
{ id: 'sample-3', name: 'Proxmox VE Bundle', icon: 'lucide:package', level: 'Trọn gói', duration: '28 giờ' },
|
|
163
|
-
]
|
|
164
|
-
|
|
165
|
-
function formatLevel(level) {
|
|
166
|
-
const labels = {
|
|
167
|
-
basic: 'Cơ bản',
|
|
168
|
-
advanced: 'Nâng cao',
|
|
169
|
-
expert: 'Chuyên gia',
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return labels[level || ''] || level || ''
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// CtaSection chỉ để tự gửi tư vấn -> chỉ show option KHÔNG có url riêng (option có url đã có nút "Đăng ký ngay" đi thẳng)
|
|
176
|
-
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' },
|
|
180
|
-
}
|
|
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)
|
|
184
|
-
const formattedPlans = computed(() => {
|
|
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
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
const dropdownPlans = computed(() => formattedPlans.value)
|
|
214
|
-
|
|
215
|
-
// Form state
|
|
216
|
-
const form = reactive({ name: '', phone: '', email: '' })
|
|
217
|
-
const errors = reactive({ name: '', phone: '', email: '', course: '' })
|
|
218
|
-
const touched = reactive({ name: false, phone: false, email: false, course: false })
|
|
219
|
-
const submitting = ref(false)
|
|
220
|
-
const submitSuccess = ref(false)
|
|
221
|
-
|
|
222
|
-
// Dropdown state
|
|
223
|
-
const dropdownOpen = ref(false)
|
|
224
|
-
const searchQuery = ref('')
|
|
225
|
-
const selectedCourse = ref(null)
|
|
226
|
-
const dropdownRef = ref(null)
|
|
227
|
-
const searchInput = ref(null)
|
|
228
|
-
|
|
229
|
-
// Auto-select when selectedPlanId changes
|
|
230
|
-
watch(() => props.selectedPlanId, (id) => {
|
|
231
|
-
if (!id) return
|
|
232
|
-
const found = formattedPlans.value.find(p => p.id === id)
|
|
233
|
-
if (found) {
|
|
234
|
-
selectedCourse.value = found
|
|
235
|
-
touched.course = true
|
|
236
|
-
errors.course = ''
|
|
237
|
-
}
|
|
238
|
-
}, { immediate: true })
|
|
239
|
-
|
|
240
|
-
// Search filter
|
|
241
|
-
const filteredCourses = computed(() => {
|
|
242
|
-
if (!searchQuery.value) return dropdownPlans.value
|
|
243
|
-
const q = searchQuery.value.toLowerCase()
|
|
244
|
-
return dropdownPlans.value.filter(p =>
|
|
245
|
-
p.name.toLowerCase().includes(q) || p.level.toLowerCase().includes(q)
|
|
246
|
-
)
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
function toggleDropdown() {
|
|
250
|
-
dropdownOpen.value = !dropdownOpen.value
|
|
251
|
-
if (dropdownOpen.value) {
|
|
252
|
-
nextTick(() => searchInput.value?.focus())
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function selectCourse(plan) {
|
|
257
|
-
selectedCourse.value = plan
|
|
258
|
-
errors.course = ''
|
|
259
|
-
dropdownOpen.value = false
|
|
260
|
-
searchQuery.value = ''
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Close dropdown on outside click
|
|
264
|
-
function onClickOutside(e) {
|
|
265
|
-
if (dropdownRef.value && !dropdownRef.value.contains(e.target)) {
|
|
266
|
-
dropdownOpen.value = false
|
|
267
|
-
searchQuery.value = ''
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
272
|
-
onUnmounted(() => document.removeEventListener('click', onClickOutside))
|
|
273
|
-
|
|
274
|
-
// Validation
|
|
275
|
-
function touch(field) {
|
|
276
|
-
touched[field] = true
|
|
277
|
-
validateField(field)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function validateField(field) {
|
|
281
|
-
if (field === 'name') {
|
|
282
|
-
if (!form.name) errors.name = 'Vui lòng nhập họ tên'
|
|
283
|
-
else if (form.name.length < 2) errors.name = 'Họ tên phải có ít nhất 2 ký tự'
|
|
284
|
-
else errors.name = ''
|
|
285
|
-
}
|
|
286
|
-
if (field === 'phone') {
|
|
287
|
-
if (!form.phone) errors.phone = 'Vui lòng nhập số điện thoại'
|
|
288
|
-
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ệ'
|
|
289
|
-
else errors.phone = ''
|
|
290
|
-
}
|
|
291
|
-
if (field === 'email') {
|
|
292
|
-
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Email không hợp lệ'
|
|
293
|
-
else errors.email = ''
|
|
294
|
-
}
|
|
295
|
-
if (field === 'course') {
|
|
296
|
-
if (!selectedCourse.value) errors.course = 'Vui lòng chọn khóa học'
|
|
297
|
-
else errors.course = ''
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function validateAll() {
|
|
302
|
-
['name', 'phone', 'email', 'course'].forEach(f => { touched[f] = true; validateField(f) })
|
|
303
|
-
return !errors.name && !errors.phone && !errors.email && !errors.course
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Submit
|
|
307
|
-
async function handleSubmit() {
|
|
308
|
-
if (!validateAll()) return
|
|
309
|
-
submitting.value = true
|
|
310
|
-
submitSuccess.value = false
|
|
311
|
-
|
|
312
|
-
try {
|
|
313
|
-
const hostname = window.location.origin
|
|
314
|
-
|
|
315
|
-
const description = [
|
|
316
|
-
`<strong>Khóa học:</strong> ${selectedCourse.value?.name || ''}`,
|
|
317
|
-
`<strong>Trình độ:</strong> ${formatLevel(selectedCourse.value?.level)}`,
|
|
318
|
-
`<strong>Thời lượng:</strong> ${selectedCourse.value?.duration || ''}`,
|
|
319
|
-
`<strong>Họ và tên:</strong> ${form.name}`,
|
|
320
|
-
`<strong>Số điện thoại:</strong> ${form.phone}`,
|
|
321
|
-
`<strong>Email:</strong> ${form.email || 'Không cung cấp'}`,
|
|
322
|
-
].join('<br>')
|
|
323
|
-
|
|
324
|
-
await addOpportunity({
|
|
325
|
-
name: `Yêu cầu tư vấn khóa học ${selectedCourse.value.name}`,
|
|
326
|
-
description: description,
|
|
327
|
-
referName: form.name,
|
|
328
|
-
referPhone: form.phone,
|
|
329
|
-
referEmail: form.email,
|
|
330
|
-
targetUrl: `${hostname}${window.location.pathname}`,
|
|
331
|
-
extSource: 'WEBSITE',
|
|
332
|
-
})
|
|
333
|
-
submitSuccess.value = true
|
|
334
|
-
form.name = ''
|
|
335
|
-
form.phone = ''
|
|
336
|
-
form.email = ''
|
|
337
|
-
selectedCourse.value = null
|
|
338
|
-
Object.keys(touched).forEach(k => touched[k] = false)
|
|
339
|
-
} catch (err) {
|
|
340
|
-
console.error('Error submitting consultation:', err)
|
|
341
|
-
errors.name = 'Gửi yêu cầu thất bại, vui lòng thử lại.'
|
|
342
|
-
touched.name = true
|
|
343
|
-
} finally {
|
|
344
|
-
submitting.value = false
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<section id="lien-he" class="py-24 bg-white border-t border-slate-200">
|
|
3
|
+
<div class="max-w-7xl mx-auto px-6 lg:px-8">
|
|
4
|
+
<div class="cta-card bg-[#F7F9FB] rounded-3xl flex flex-col md:flex-row shadow-2xl">
|
|
5
|
+
<div
|
|
6
|
+
class="md:w-5/12 rounded-t-3xl bg-gradient-to-br from-[#004AC6] to-[#1D4ED8] p-6 text-white space-y-6 sm:p-10 sm:space-y-8 md:space-y-8 md:rounded-tr-none md:rounded-bl-3xl md:p-12">
|
|
7
|
+
<p class="text-xs font-bold uppercase tracking-widest text-blue-100">Bắt đầu</p>
|
|
8
|
+
<h3 class="font-['Montserrat'] font-extrabold text-2xl sm:text-3xl leading-tight">
|
|
9
|
+
Đặt lịch tư vấn khóa học miễn phí, 30 phút
|
|
10
|
+
</h3>
|
|
11
|
+
<p class="text-blue-100/90 leading-relaxed">
|
|
12
|
+
Gửi thông tin hạ tầng hiện tại và mục tiêu học tập, đội ngũ kỹ thuật của Long Vân
|
|
13
|
+
sẽ phản hồi và đề xuất khóa học phù hợp trong vòng 24 giờ.
|
|
14
|
+
</p>
|
|
15
|
+
<div class="flex items-center gap-4">
|
|
16
|
+
<div class="w-10 h-10 rounded-[10px] bg-white/10 flex items-center justify-center">
|
|
17
|
+
<Icon name="lucide:mail" class="text-[18px]" />
|
|
18
|
+
</div>
|
|
19
|
+
<span class="text-sm">tuvan@longvan.net</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="flex items-center gap-4">
|
|
22
|
+
<div class="w-10 h-10 rounded-[10px] bg-white/10 flex items-center justify-center">
|
|
23
|
+
<Icon name="lucide:phone" class="text-[18px]" />
|
|
24
|
+
</div>
|
|
25
|
+
<span class="text-sm">1800 6070</span>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="md:w-7/12 rounded-b-3xl bg-white p-6 sm:p-10 md:rounded-bl-none md:rounded-tr-3xl md:p-12">
|
|
30
|
+
<form class="space-y-5" @submit.prevent="handleSubmit" novalidate>
|
|
31
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
|
32
|
+
<div class="space-y-1.5">
|
|
33
|
+
<label for="cta-name" class="text-xs font-bold uppercase text-slate-500">
|
|
34
|
+
Họ và tên <span class="text-red-500">*</span>
|
|
35
|
+
</label>
|
|
36
|
+
<input id="cta-name" v-model.trim="form.name" type="text" @blur="touch('name')"
|
|
37
|
+
class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
|
|
38
|
+
:class="errors.name && touched.name ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
|
|
39
|
+
placeholder="Nguyễn Văn A" />
|
|
40
|
+
<p v-if="errors.name && touched.name" class="text-red-500 text-xs mt-1">{{ errors.name }}</p>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="space-y-1.5">
|
|
43
|
+
<label for="cta-phone" class="text-xs font-bold uppercase text-slate-500">
|
|
44
|
+
Số điện thoại <span class="text-red-500">*</span>
|
|
45
|
+
</label>
|
|
46
|
+
<input id="cta-phone" v-model.trim="form.phone" type="tel" @blur="touch('phone')"
|
|
47
|
+
class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
|
|
48
|
+
:class="errors.phone && touched.phone ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
|
|
49
|
+
placeholder="0912 345 678" />
|
|
50
|
+
<p v-if="errors.phone && touched.phone" class="text-red-500 text-xs mt-1">{{ errors.phone }}</p>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="space-y-1.5">
|
|
54
|
+
<label for="cta-email" class="text-xs font-bold uppercase text-slate-500">Email</label>
|
|
55
|
+
<input id="cta-email" v-model.trim="form.email" type="email" @blur="touch('email')"
|
|
56
|
+
class="w-full px-4 py-3 border rounded-[10px] outline-none text-sm text-slate-900 transition-all"
|
|
57
|
+
:class="errors.email && touched.email ? 'border-red-400 focus:ring-2 focus:ring-red-200 focus:border-red-400' : 'border-slate-300 focus:ring-2 focus:ring-blue-200 focus:border-[#004AC6]'"
|
|
58
|
+
placeholder="ban@congty.com" />
|
|
59
|
+
<p v-if="errors.email && touched.email" class="text-red-500 text-xs mt-1">{{ errors.email }}</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Searchable dropdown -->
|
|
63
|
+
<div class="space-y-1.5" ref="dropdownRef">
|
|
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>
|
|
67
|
+
<div class="relative">
|
|
68
|
+
<div @click="toggleDropdown"
|
|
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"
|
|
70
|
+
:class="[
|
|
71
|
+
dropdownOpen ? 'border-[#004AC6] ring-2 ring-blue-200' : 'border-slate-300',
|
|
72
|
+
errors.course && touched.course ? 'border-red-400' : ''
|
|
73
|
+
]">
|
|
74
|
+
<span :class="selectedCourse ? 'text-slate-900' : 'text-slate-400'">
|
|
75
|
+
{{ selectedCourse ? selectedCourse.name : 'Chọn khóa học...' }}
|
|
76
|
+
</span>
|
|
77
|
+
<Icon name="lucide:chevron-down" class="text-[16px] text-slate-400 transition-transform"
|
|
78
|
+
:class="dropdownOpen ? 'rotate-180' : ''" />
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<Transition enter-active-class="transition ease-out duration-150"
|
|
82
|
+
enter-from-class="opacity-0 -translate-y-1" enter-to-class="opacity-100 translate-y-0"
|
|
83
|
+
leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100 translate-y-0"
|
|
84
|
+
leave-to-class="opacity-0 -translate-y-1">
|
|
85
|
+
<div v-if="dropdownOpen"
|
|
86
|
+
class="absolute z-50 mt-1 w-full bg-white border border-slate-200 rounded-[10px] shadow-lg overflow-hidden">
|
|
87
|
+
<div class="p-2 border-b border-slate-100">
|
|
88
|
+
<div class="relative">
|
|
89
|
+
<Icon name="lucide:search"
|
|
90
|
+
class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-[14px]" />
|
|
91
|
+
<input ref="searchInput" v-model="searchQuery" type="text" placeholder="Tìm khóa học..."
|
|
92
|
+
class="w-full pl-9 pr-3 py-2 text-sm border border-slate-200 rounded-md outline-none focus:border-[#004AC6] focus:ring-1 focus:ring-blue-200"
|
|
93
|
+
@click.stop />
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="max-h-56 overflow-y-auto">
|
|
97
|
+
<div v-for="plan in filteredCourses" :key="plan.id" @click="selectCourse(plan)"
|
|
98
|
+
class="px-4 py-3 text-sm cursor-pointer flex items-center gap-3 transition-colors"
|
|
99
|
+
:class="selectedCourse?.id === plan.id ? 'bg-blue-50 text-[#004AC6]' : 'hover:bg-slate-50 text-slate-700'">
|
|
100
|
+
<Icon :name="plan.icon" class="text-[16px] shrink-0"
|
|
101
|
+
:class="selectedCourse?.id === plan.id ? 'text-[#004AC6]' : 'text-slate-400'" />
|
|
102
|
+
<div class="flex-1 min-w-0">
|
|
103
|
+
<p class="font-medium truncate">{{ plan.name }}</p>
|
|
104
|
+
<p class="text-xs text-slate-400 truncate">{{ formatLevel(plan.level) }} · {{ plan.duration }}
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
<Icon v-if="selectedCourse?.id === plan.id" name="lucide:check"
|
|
108
|
+
class="text-[16px] text-[#004AC6] shrink-0" />
|
|
109
|
+
</div>
|
|
110
|
+
<div v-if="filteredCourses.length === 0" class="px-4 py-6 text-center text-sm text-slate-400">
|
|
111
|
+
Không tìm thấy khóa học
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</Transition>
|
|
116
|
+
</div>
|
|
117
|
+
<p v-if="errors.course && touched.course" class="text-red-500 text-xs mt-1">{{ errors.course }}</p>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<button type="submit" :disabled="submitting"
|
|
121
|
+
class="w-full bg-[#004AC6] text-white py-3.5 rounded-[10px] font-bold flex items-center justify-center gap-2 hover:bg-[#1D4ED8] transition-all shadow-lg active:scale-95 disabled:opacity-60 disabled:cursor-not-allowed">
|
|
122
|
+
<template v-if="submitting">
|
|
123
|
+
<span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-[10px] animate-spin"></span>
|
|
124
|
+
Đang gửi...
|
|
125
|
+
</template>
|
|
126
|
+
<template v-else>
|
|
127
|
+
Gửi yêu cầu tư vấn
|
|
128
|
+
<Icon name="lucide:send" class="text-[18px]" />
|
|
129
|
+
</template>
|
|
130
|
+
</button>
|
|
131
|
+
|
|
132
|
+
<Transition enter-active-class="transition ease-out duration-300" enter-from-class="opacity-0 translate-y-2"
|
|
133
|
+
enter-to-class="opacity-100 translate-y-0">
|
|
134
|
+
<div v-if="submitSuccess"
|
|
135
|
+
class="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-[10px] text-green-700 text-sm">
|
|
136
|
+
<Icon name="lucide:check-circle" class="text-[18px] text-green-500 shrink-0" />
|
|
137
|
+
Gửi yêu cầu tư vấn thành công! Chúng tôi sẽ liên hệ bạn trong 24 giờ.
|
|
138
|
+
</div>
|
|
139
|
+
</Transition>
|
|
140
|
+
</form>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</section>
|
|
145
|
+
</template>
|
|
146
|
+
|
|
147
|
+
<script setup>
|
|
148
|
+
useScrollAnimate('.cta-card', 'scale-up')
|
|
149
|
+
|
|
150
|
+
const props = defineProps({
|
|
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 },
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const { addOpportunity } = useCrmApi()
|
|
158
|
+
|
|
159
|
+
const samplePlans = [
|
|
160
|
+
{ id: 'sample-1', name: 'Deployment & Management', icon: 'lucide:graduation-cap', level: 'Nền tảng', duration: '14 giờ' },
|
|
161
|
+
{ id: 'sample-2', name: 'Clustering & Shared Storage', icon: 'lucide:network', level: 'Chuyên sâu', duration: '14 giờ' },
|
|
162
|
+
{ id: 'sample-3', name: 'Proxmox VE Bundle', icon: 'lucide:package', level: 'Trọn gói', duration: '28 giờ' },
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
function formatLevel(level) {
|
|
166
|
+
const labels = {
|
|
167
|
+
basic: 'Cơ bản',
|
|
168
|
+
advanced: 'Nâng cao',
|
|
169
|
+
expert: 'Chuyên gia',
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return labels[level || ''] || level || ''
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// CtaSection chỉ để tự gửi tư vấn -> chỉ show option KHÔNG có url riêng (option có url đã có nút "Đăng ký ngay" đi thẳng)
|
|
176
|
+
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' },
|
|
180
|
+
}
|
|
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)
|
|
184
|
+
const formattedPlans = computed(() => {
|
|
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
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const dropdownPlans = computed(() => formattedPlans.value)
|
|
214
|
+
|
|
215
|
+
// Form state
|
|
216
|
+
const form = reactive({ name: '', phone: '', email: '' })
|
|
217
|
+
const errors = reactive({ name: '', phone: '', email: '', course: '' })
|
|
218
|
+
const touched = reactive({ name: false, phone: false, email: false, course: false })
|
|
219
|
+
const submitting = ref(false)
|
|
220
|
+
const submitSuccess = ref(false)
|
|
221
|
+
|
|
222
|
+
// Dropdown state
|
|
223
|
+
const dropdownOpen = ref(false)
|
|
224
|
+
const searchQuery = ref('')
|
|
225
|
+
const selectedCourse = ref(null)
|
|
226
|
+
const dropdownRef = ref(null)
|
|
227
|
+
const searchInput = ref(null)
|
|
228
|
+
|
|
229
|
+
// Auto-select when selectedPlanId changes
|
|
230
|
+
watch(() => props.selectedPlanId, (id) => {
|
|
231
|
+
if (!id) return
|
|
232
|
+
const found = formattedPlans.value.find(p => p.id === id)
|
|
233
|
+
if (found) {
|
|
234
|
+
selectedCourse.value = found
|
|
235
|
+
touched.course = true
|
|
236
|
+
errors.course = ''
|
|
237
|
+
}
|
|
238
|
+
}, { immediate: true })
|
|
239
|
+
|
|
240
|
+
// Search filter
|
|
241
|
+
const filteredCourses = computed(() => {
|
|
242
|
+
if (!searchQuery.value) return dropdownPlans.value
|
|
243
|
+
const q = searchQuery.value.toLowerCase()
|
|
244
|
+
return dropdownPlans.value.filter(p =>
|
|
245
|
+
p.name.toLowerCase().includes(q) || p.level.toLowerCase().includes(q)
|
|
246
|
+
)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
function toggleDropdown() {
|
|
250
|
+
dropdownOpen.value = !dropdownOpen.value
|
|
251
|
+
if (dropdownOpen.value) {
|
|
252
|
+
nextTick(() => searchInput.value?.focus())
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function selectCourse(plan) {
|
|
257
|
+
selectedCourse.value = plan
|
|
258
|
+
errors.course = ''
|
|
259
|
+
dropdownOpen.value = false
|
|
260
|
+
searchQuery.value = ''
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Close dropdown on outside click
|
|
264
|
+
function onClickOutside(e) {
|
|
265
|
+
if (dropdownRef.value && !dropdownRef.value.contains(e.target)) {
|
|
266
|
+
dropdownOpen.value = false
|
|
267
|
+
searchQuery.value = ''
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
272
|
+
onUnmounted(() => document.removeEventListener('click', onClickOutside))
|
|
273
|
+
|
|
274
|
+
// Validation
|
|
275
|
+
function touch(field) {
|
|
276
|
+
touched[field] = true
|
|
277
|
+
validateField(field)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function validateField(field) {
|
|
281
|
+
if (field === 'name') {
|
|
282
|
+
if (!form.name) errors.name = 'Vui lòng nhập họ tên'
|
|
283
|
+
else if (form.name.length < 2) errors.name = 'Họ tên phải có ít nhất 2 ký tự'
|
|
284
|
+
else errors.name = ''
|
|
285
|
+
}
|
|
286
|
+
if (field === 'phone') {
|
|
287
|
+
if (!form.phone) errors.phone = 'Vui lòng nhập số điện thoại'
|
|
288
|
+
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ệ'
|
|
289
|
+
else errors.phone = ''
|
|
290
|
+
}
|
|
291
|
+
if (field === 'email') {
|
|
292
|
+
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Email không hợp lệ'
|
|
293
|
+
else errors.email = ''
|
|
294
|
+
}
|
|
295
|
+
if (field === 'course') {
|
|
296
|
+
if (!selectedCourse.value) errors.course = 'Vui lòng chọn khóa học'
|
|
297
|
+
else errors.course = ''
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validateAll() {
|
|
302
|
+
['name', 'phone', 'email', 'course'].forEach(f => { touched[f] = true; validateField(f) })
|
|
303
|
+
return !errors.name && !errors.phone && !errors.email && !errors.course
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Submit
|
|
307
|
+
async function handleSubmit() {
|
|
308
|
+
if (!validateAll()) return
|
|
309
|
+
submitting.value = true
|
|
310
|
+
submitSuccess.value = false
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const hostname = window.location.origin
|
|
314
|
+
|
|
315
|
+
const description = [
|
|
316
|
+
`<strong>Khóa học:</strong> ${selectedCourse.value?.name || ''}`,
|
|
317
|
+
`<strong>Trình độ:</strong> ${formatLevel(selectedCourse.value?.level)}`,
|
|
318
|
+
`<strong>Thời lượng:</strong> ${selectedCourse.value?.duration || ''}`,
|
|
319
|
+
`<strong>Họ và tên:</strong> ${form.name}`,
|
|
320
|
+
`<strong>Số điện thoại:</strong> ${form.phone}`,
|
|
321
|
+
`<strong>Email:</strong> ${form.email || 'Không cung cấp'}`,
|
|
322
|
+
].join('<br>')
|
|
323
|
+
|
|
324
|
+
await addOpportunity({
|
|
325
|
+
name: `Yêu cầu tư vấn khóa học ${selectedCourse.value.name}`,
|
|
326
|
+
description: description,
|
|
327
|
+
referName: form.name,
|
|
328
|
+
referPhone: form.phone,
|
|
329
|
+
referEmail: form.email,
|
|
330
|
+
targetUrl: `${hostname}${window.location.pathname}`,
|
|
331
|
+
extSource: 'WEBSITE',
|
|
332
|
+
})
|
|
333
|
+
submitSuccess.value = true
|
|
334
|
+
form.name = ''
|
|
335
|
+
form.phone = ''
|
|
336
|
+
form.email = ''
|
|
337
|
+
selectedCourse.value = null
|
|
338
|
+
Object.keys(touched).forEach(k => touched[k] = false)
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error('Error submitting consultation:', err)
|
|
341
|
+
errors.name = 'Gửi yêu cầu thất bại, vui lòng thử lại.'
|
|
342
|
+
touched.name = true
|
|
343
|
+
} finally {
|
|
344
|
+
submitting.value = false
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
</script>
|