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