PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / trunk
Booking for Appointments and Events Calendar – Amelia vtrunk
2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / v3 / src / views / _components / address-input / AmAddressInput.vue
ameliabooking / v3 / src / views / _components / address-input Last commit date
AmAddressInput.vue 5 days ago
AmAddressInput.vue
507 lines
1 <template>
2 <!-- Address Field -->
3 <div v-if="googleMapsLoaded()" ref="wrapperElement" class="am-input-wrapper" :style="cssVars">
4 <div class="el-input am-input am-input--default">
5 <div
6 v-click-outside="handleClickOutside"
7 class="el-input__wrapper"
8 :class="{ 'is-focus': isFocus }"
9 @click="() => (isFocus = true)"
10 >
11 <input
12 :id="`amelia-address-autocomplete-${props.id}`"
13 ref="inputElement"
14 v-model="inputValue"
15 type="text"
16 class="el-input__inner"
17 :placeholder="props.placeholder"
18 :aria-label="props.ariaLabel"
19 @input="handleInputChange"
20 @focus="handleFocus"
21 @keydown="handleKeydown"
22 />
23 </div>
24 </div>
25 </div>
26 <AmInput v-else v-model="model" :placeholder="props.placeholder" />
27 <!-- /Address Field -->
28
29 <!-- Autocomplete dropdown (Teleported to body) -->
30 <Teleport to="body">
31 <div
32 v-if="showDropdown && predictions.length > 0"
33 class="am-address-dropdown"
34 :style="[cssVars, dropdownStyle]"
35 >
36 <div
37 v-for="(prediction, index) in predictions"
38 :key="prediction.place_id"
39 class="am-address-dropdown-item"
40 :class="{ 'is-active': index === selectedIndex }"
41 @mousedown.prevent="selectPrediction(prediction)"
42 @mouseenter="selectedIndex = index"
43 >
44 <div class="am-address-main">{{ prediction.main_text }}</div>
45 <div v-if="prediction.secondary_text" class="am-address-secondary">
46 {{ prediction.secondary_text }}
47 </div>
48 </div>
49 </div>
50 </Teleport>
51 </template>
52
53 <script setup>
54 // * Vue imports
55 import { computed, ref, toRefs, onMounted, onUnmounted, inject, watch } from 'vue'
56
57 // * Element Plus
58 import { ClickOutside as vClickOutside } from 'element-plus'
59
60 // * Components
61 import AmInput from '../input/AmInput.vue'
62
63 // * Composables
64 import { useColorTransparency } from '../../../assets/js/common/colorManipulation'
65
66 // * Import from Vuex
67 import { useStore } from 'vuex'
68
69 // * Store
70 let store = useStore()
71
72 // * Component Props
73 const props = defineProps({
74 modelValue: {
75 type: [String, Array, Object, Number],
76 default: '',
77 required: true,
78 },
79 id: {
80 type: [String, Number],
81 required: true,
82 },
83 placeholder: {
84 type: String,
85 default: '',
86 },
87 ariaLabel: {
88 type: String,
89 default: 'address input',
90 },
91 })
92
93 // * Define Emits
94 const emits = defineEmits(['update:modelValue', 'address-selected'])
95
96 // * Component Refs
97 let inputElement = ref(null)
98 let wrapperElement = ref(null)
99
100 // * Component model
101 let { modelValue } = toRefs(props)
102 let model = computed({
103 get: () => modelValue.value,
104 set: (val) => {
105 emits('update:modelValue', val)
106 },
107 })
108
109 // * Local state
110 let isFocus = ref(false)
111 let inputValue = ref(modelValue.value || '')
112 let showDropdown = ref(false)
113 let selectedIndex = ref(-1)
114 let dropdownStyle = ref({})
115
116 // * Google Places API state
117 let predictions = ref([])
118 let sessionToken = null
119 let debounceTimer = null
120 let hasPermanentError = ref(false)
121 let originalConsoleError = null
122 let errorSuppressionActive = false
123
124 const DEBOUNCE_MS = 300
125
126 /**
127 * Suppress Google Maps API console errors globally
128 */
129 function suppressGoogleMapsErrors() {
130 if (errorSuppressionActive) return
131
132 originalConsoleError = console.error
133 errorSuppressionActive = true
134 }
135
136 function googleMapsLoaded() {
137 return window.google && window.google.maps?.places && store.state.settings.general.gMapApiKey
138 }
139
140 /**
141 * Initialize session token for new Places API
142 */
143 function initializeSessionToken() {
144 if (window.google?.maps?.places?.AutocompleteSessionToken) {
145 const { AutocompleteSessionToken } = window.google.maps.places
146 sessionToken = new AutocompleteSessionToken()
147 }
148 }
149
150 /**
151 * Fetch predictions using the new Place Autocomplete Data API only
152 */
153 async function fetchPredictions(input) {
154 if (!input.trim()) {
155 predictions.value = []
156 showDropdown.value = false
157 return
158 }
159
160 // Don't make API calls if we already know there's an error
161 if (hasPermanentError.value) {
162 return
163 }
164
165 // Only use new API, no fallback
166 if (!window.google?.maps?.places?.AutocompleteSuggestion) {
167 hasPermanentError.value = true
168 return
169 }
170
171 try {
172 const { AutocompleteSuggestion } = window.google.maps.places
173
174 const { suggestions } = await AutocompleteSuggestion.fetchAutocompleteSuggestions({
175 input,
176 sessionToken,
177 })
178
179 if (suggestions && suggestions.length > 0) {
180 predictions.value = suggestions
181 .filter((s) => s.placePrediction)
182 .map((s) => {
183 const p = s.placePrediction
184 const fullText = p.text?.toString() || ''
185 let mainText = fullText
186 let secondaryText = ''
187
188 if (p.structuredFormat) {
189 mainText = p.structuredFormat.mainText?.toString() || mainText
190 secondaryText = p.structuredFormat.secondaryText?.toString() || ''
191 } else {
192 const commaIndex = fullText.indexOf(', ')
193 if (commaIndex > -1) {
194 mainText = fullText.substring(0, commaIndex)
195 secondaryText = fullText.substring(commaIndex + 2)
196 }
197 }
198
199 return {
200 place_id: p.placeId,
201 main_text: mainText,
202 secondary_text: secondaryText,
203 _prediction: p,
204 }
205 })
206
207 showDropdown.value = true
208 selectedIndex.value = -1
209 updateDropdownPosition()
210 } else {
211 predictions.value = []
212 showDropdown.value = false
213 }
214 } catch (err) {
215 const errorMessage = err?.message || err?.toString() || ''
216 // Mark as permanent error for 403 or API errors and suppress future errors
217 if (
218 errorMessage.includes('AutocompleteSuggestion') ||
219 errorMessage.includes('NOT_FOUND') ||
220 errorMessage.includes('Places API') ||
221 errorMessage.includes('Forbidden') ||
222 errorMessage.includes('403') ||
223 err?.code === 'NOT_FOUND' ||
224 err?.status === 403
225 ) {
226 hasPermanentError.value = true
227 suppressGoogleMapsErrors()
228 }
229 predictions.value = []
230 showDropdown.value = false
231 }
232 }
233
234 /**
235 * Handle input changes with debouncing
236 */
237 function handleInputChange() {
238 if (debounceTimer) {
239 clearTimeout(debounceTimer)
240 }
241
242 debounceTimer = setTimeout(() => {
243 fetchPredictions(inputValue.value)
244 }, DEBOUNCE_MS)
245 }
246
247 /**
248 * Get place details when a prediction is selected (new API only)
249 */
250 async function selectPrediction(prediction) {
251 try {
252 if (!prediction._prediction) {
253 return
254 }
255
256 const place = prediction._prediction.toPlace()
257
258 // Fetch place fields (this concludes the session)
259 await place.fetchFields({
260 fields: ['formattedAddress', 'addressComponents'],
261 })
262
263 if (place.formattedAddress) {
264 inputValue.value = place.formattedAddress
265 emits('update:modelValue', place.formattedAddress)
266 }
267
268 // Emit address components if available
269 if (place.addressComponents) {
270 const addressComponents = place.addressComponents.map((component) => ({
271 long_name: component.longText,
272 short_name: component.shortText,
273 types: component.types,
274 }))
275 emits('address-selected', addressComponents)
276 }
277
278 // Create new session token for next autocomplete session
279 initializeSessionToken()
280
281 // Hide dropdown and reset
282 showDropdown.value = false
283 predictions.value = []
284 selectedIndex.value = -1
285 } catch (err) {
286 console.error('Error fetching place details:', err)
287
288 const fallbackAddress = prediction.secondary_text
289 ? `${prediction.main_text}, ${prediction.secondary_text}`
290 : prediction.main_text
291
292 inputValue.value = fallbackAddress
293 emits('update:modelValue', fallbackAddress)
294
295 showDropdown.value = false
296 predictions.value = []
297 selectedIndex.value = -1
298 }
299 }
300
301 /**
302 * Handle keyboard navigation
303 */
304 function handleKeydown(event) {
305 if (!showDropdown.value || predictions.value.length === 0) return
306
307 switch (event.key) {
308 case 'ArrowDown':
309 event.preventDefault()
310 selectedIndex.value = Math.min(selectedIndex.value + 1, predictions.value.length - 1)
311 break
312 case 'ArrowUp':
313 event.preventDefault()
314 selectedIndex.value = Math.max(selectedIndex.value - 1, -1)
315 break
316 case 'Enter':
317 event.preventDefault()
318 if (selectedIndex.value >= 0 && selectedIndex.value < predictions.value.length) {
319 selectPrediction(predictions.value[selectedIndex.value])
320 }
321 break
322 case 'Escape':
323 event.preventDefault()
324 showDropdown.value = false
325 selectedIndex.value = -1
326 break
327 }
328 }
329
330 /**
331 * Update dropdown position
332 */
333 function updateDropdownPosition() {
334 if (!wrapperElement.value) return
335
336 const rect = wrapperElement.value.getBoundingClientRect()
337 dropdownStyle.value = {
338 position: 'fixed',
339 top: `${rect.bottom + 4}px`,
340 left: `${rect.left}px`,
341 width: `${rect.width}px`,
342 }
343 }
344
345 /**
346 * Handle focus
347 */
348 function handleFocus() {
349 isFocus.value = true
350 if (showDropdown.value && predictions.value.length > 0) {
351 updateDropdownPosition()
352 }
353 }
354
355 /**
356 * Handle click outside
357 */
358 function handleClickOutside() {
359 isFocus.value = false
360 showDropdown.value = false
361 selectedIndex.value = -1
362 }
363
364 // Watch for external changes to modelValue
365 watch(modelValue, (newValue) => {
366 if (newValue !== inputValue.value) {
367 inputValue.value = newValue || ''
368 }
369 })
370
371 onMounted(() => {
372 // Listen for scroll/resize to update dropdown position
373 window.addEventListener('scroll', updateDropdownPosition, true)
374 window.addEventListener('resize', updateDropdownPosition)
375
376 const initializePlaces = async () => {
377 if (!window.google?.maps?.places || !store.state.settings.general.gMapApiKey) {
378 hasPermanentError.value = true
379 return
380 }
381
382 try {
383 await window.google.maps.importLibrary('places')
384
385 if (window.google?.maps?.places?.AutocompleteSuggestion) {
386 initializeSessionToken()
387 } else {
388 hasPermanentError.value = true
389 console.warn('New Places API not available. Autocomplete disabled.')
390 }
391 } catch (err) {
392 console.error('Failed to initialize Google Places:', err)
393 hasPermanentError.value = true
394 }
395 }
396
397 initializePlaces()
398 })
399
400 onUnmounted(() => {
401 if (debounceTimer) {
402 clearTimeout(debounceTimer)
403 }
404 window.removeEventListener('scroll', updateDropdownPosition, true)
405 window.removeEventListener('resize', updateDropdownPosition)
406
407 if (errorSuppressionActive && originalConsoleError) {
408 console.error = originalConsoleError
409 }
410 })
411
412 // * Color Vars
413 let amColors = inject(
414 'amColors',
415 ref({
416 colorPrimary: '#1246D6',
417 colorSuccess: '#019719',
418 colorError: '#B4190F',
419 colorWarning: '#CCA20C',
420 colorMainBgr: '#FFFFFF',
421 colorMainHeadingText: '#33434C',
422 colorMainText: '#1A2C37',
423 colorSbBgr: '#17295A',
424 colorSbText: '#FFFFFF',
425 colorInpBgr: '#FFFFFF',
426 colorInpBorder: '#D1D5D7',
427 colorInpText: '#1A2C37',
428 colorInpPlaceHolder: '#808A90',
429 colorDropBgr: '#FFFFFF',
430 colorDropBorder: '#D1D5D7',
431 colorDropText: '#0E1920',
432 colorBtnPrim: '#265CF2',
433 colorBtnPrimText: '#FFFFFF',
434 colorBtnSec: '#1A2C37',
435 colorBtnSecText: '#FFFFFF',
436 }),
437 )
438
439 // * Css Variables
440 let cssVars = computed(() => {
441 return {
442 '--am-c-inp-bgr': amColors.value.colorInpBgr,
443 '--am-c-inp-border': amColors.value.colorInpBorder,
444 '--am-c-inp-text': amColors.value.colorInpText,
445 '--am-c-inp-text-op03': useColorTransparency(amColors.value.colorInpText, 0.03),
446 '--am-c-inp-text-op05': useColorTransparency(amColors.value.colorInpText, 0.05),
447 '--am-c-inp-text-op40': useColorTransparency(amColors.value.colorInpText, 0.4),
448 '--am-c-inp-text-op60': useColorTransparency(amColors.value.colorInpText, 0.6),
449 '--am-c-inp-placeholder': amColors.value.colorInpPlaceHolder,
450 '--am-c-drop-bgr': amColors.value.colorDropBgr,
451 '--am-c-drop-border': amColors.value.colorDropBorder,
452 '--am-c-drop-text': amColors.value.colorDropText,
453 }
454 })
455 </script>
456
457 <style scoped>
458 .am-address-dropdown {
459 background: var(--am-c-drop-bgr);
460 border: 1px solid var(--am-c-drop-border);
461 border-radius: 4px;
462 max-height: 300px;
463 overflow-y: auto;
464 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
465 z-index: 9999999999 !important;
466 font-family: 'Amelia Roboto', sans-serif;
467 }
468
469 .am-address-dropdown-item {
470 padding: 0 12px;
471 min-height: 40px;
472 display: flex;
473 flex-direction: column;
474 justify-content: center;
475 cursor: pointer;
476 border-bottom: 1px solid var(--am-c-inp-text-op05);
477 transition: background-color 0.2s;
478 }
479
480 .am-address-dropdown-item:last-child {
481 border-bottom: none;
482 }
483
484 .am-address-dropdown-item:hover,
485 .am-address-dropdown-item.is-active {
486 background: var(--am-c-inp-text-op05);
487 }
488
489 .am-address-main {
490 color: var(--am-c-drop-text);
491 font-weight: 500;
492 font-size: 15px;
493 line-height: 1.4;
494 margin-bottom: 2px;
495 }
496
497 .am-address-secondary {
498 color: var(--am-c-inp-text-op60);
499 font-size: 13px;
500 line-height: 1.3;
501 }
502
503 .am-input-wrapper {
504 position: relative;
505 }
506 </style>
507