@controleonline/ui-default 1.0.263 → 1.0.265

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 (61) hide show
  1. package/AGENTS.md +47 -45
  2. package/package.json +20 -20
  3. package/src/react/components/errors/DefaultErrors.js +307 -325
  4. package/src/react/components/files/DefaultFile.js +46 -46
  5. package/src/react/components/filters/CompactFilterSelector.js +78 -24
  6. package/src/react/components/filters/CompactFilterSelector.styles.js +17 -5
  7. package/src/react/components/filters/DefaultColumnFilter.js +191 -57
  8. package/src/react/components/filters/DefaultExternalFilters.js +245 -182
  9. package/src/react/components/filters/DefaultSearch.js +131 -92
  10. package/src/react/components/filters/DefaultSearch.styles.js +65 -70
  11. package/src/react/components/form/DefaultForm.js +172 -151
  12. package/src/react/components/form/DefaultForm.styles.js +71 -66
  13. package/src/react/components/help/DefaultTooltip.js +87 -87
  14. package/src/react/components/help/DefaultTooltip.styles.js +61 -61
  15. package/src/react/components/inputs/DefaultDateInput.js +150 -0
  16. package/src/react/components/inputs/DefaultInput.js +162 -127
  17. package/src/react/components/inputs/DefaultInput.styles.js +6 -0
  18. package/src/react/components/inputs/DefaultSelect.js +320 -152
  19. package/src/react/components/inputs/defaultInputUtils.js +40 -19
  20. package/src/react/components/map/DefaultGoogleMap.styles.js +115 -9
  21. package/src/react/components/map/DefaultGoogleMap.web.js +560 -446
  22. package/src/react/components/map/DefaultMap.native.js +73 -71
  23. package/src/react/components/map/DefaultMap.styles.js +16 -16
  24. package/src/react/components/map/DefaultMap.web.js +68 -66
  25. package/src/react/components/map/DefaultNativeMap.native.js +307 -183
  26. package/src/react/components/map/DefaultNativeMap.shared.js +439 -340
  27. package/src/react/components/map/DefaultNativeMap.styles.js +156 -122
  28. package/src/react/components/table/DefaultColumnMenu.js +108 -0
  29. package/src/react/components/table/DefaultDebug.js +233 -0
  30. package/src/react/components/table/DefaultFiltersModal.js +142 -0
  31. package/src/react/components/table/DefaultModalButton.js +18 -0
  32. package/src/react/components/table/DefaultSearchModal.js +49 -0
  33. package/src/react/components/table/DefaultTable.js +345 -1846
  34. package/src/react/components/table/DefaultTable.styles.js +247 -136
  35. package/src/react/components/table/DefaultTable.utils.js +502 -0
  36. package/src/react/components/table/DefaultTableBody.js +26 -0
  37. package/src/react/components/table/DefaultTableCards.js +171 -0
  38. package/src/react/components/table/DefaultTableControls.js +208 -0
  39. package/src/react/components/table/DefaultTableEmptyState.js +22 -0
  40. package/src/react/components/table/DefaultTableFooter.js +172 -0
  41. package/src/react/components/table/DefaultTableInput.js +145 -0
  42. package/src/react/components/table/DefaultTableRows.js +340 -0
  43. package/src/react/components/table/DefaultTableSearch.js +100 -0
  44. package/src/react/components/table/DefaultTableToolbar.js +98 -0
  45. package/src/react/components/table/DefaultToolbarAction.js +56 -0
  46. package/src/react/components/table/useDefaultTablePagination.js +370 -0
  47. package/src/react/components/table/useDefaultTableSorting.js +126 -0
  48. package/src/react/components/table/useDefaultTableTheme.js +58 -0
  49. package/src/react/index.js +10 -10
  50. package/src/react/utils/tableVisibleColumnsPreferences.js +239 -211
  51. package/src/store/default/actions.js +51 -13
  52. package/src/store/default/getters.js +10 -8
  53. package/src/store/default/mutation_types.js +4 -2
  54. package/src/store/default/mutations.js +10 -1
  55. package/src/tests/react/components/errors/DefaultErrors.test.js +162 -162
  56. package/src/tests/react/components/files/DefaultFile.test.js +80 -80
  57. package/src/tests/react/components/form/DefaultForm.test.js +81 -0
  58. package/src/tests/react/components/inputs/DefaultSelect.test.js +185 -0
  59. package/src/tests/react/components/table/DefaultTable.test.js +1183 -0
  60. package/src/tests/react/store/defaultActions.test.js +59 -0
  61. package/src/tests/react/utils/tableVisibleColumnsPreferences.test.js +247 -168
@@ -1,199 +1,119 @@
1
- import React, {useEffect, useRef} from 'react';
2
- import {Platform, View} from 'react-native';
3
-
4
- import styles from './DefaultGoogleMap.styles';
5
- import {
6
- buildGoogleMapsNavigationUrl,
7
- buildWazeNavigationUrl,
8
- } from './DefaultMap.shared';
9
-
10
- const GOOGLE_MAPS_SCRIPT_ID = 'shop-google-maps-api-script';
11
- const GOOGLE_MAPS_CALLBACK_NAME = '__shopGoogleMapsApiReady__';
12
- const GOOGLE_MAPS_LOAD_TIMEOUT_MS = 15000;
13
- const GOOGLE_MAPS_POLL_INTERVAL_MS = 100;
14
- const ROUTE_COLOR = '#0EA5E9';
15
- const markerIconUrlValidationCache = new Map();
16
-
17
- const escapeHtml = value =>
18
- String(value || '')
19
- .replace(/&/g, '&')
20
- .replace(/</g, '&lt;')
21
- .replace(/>/g, '&gt;')
22
- .replace(/"/g, '&quot;')
23
- .replace(/'/g, '&#39;');
24
-
25
- const buildPopupLine = value => {
26
- if (!value) {
27
- return '';
28
- }
29
-
30
- return `<div class="shop-map-popup-line">${escapeHtml(value)}</div>`;
31
- };
32
-
33
- const buildPopupMeta = (label, value) => {
34
- if (!value) {
1
+ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2
+ import {Image, Platform, Text, TouchableOpacity, View} from 'react-native';
3
+
4
+ import styles from './DefaultGoogleMap.styles';
5
+ import {
6
+ buildGoogleMapsNavigationUrl,
7
+ buildWazeNavigationUrl,
8
+ } from './DefaultMap.shared';
9
+ import {resolveMapPopupTheme} from './DefaultNativeMap.shared';
10
+ import {resolveAddressDisplayParts} from '@controleonline/ui-common/src/react/utils/entityDisplay';
11
+
12
+ const GOOGLE_MAPS_SCRIPT_ID = 'shop-google-maps-api-script';
13
+ const GOOGLE_MAPS_CALLBACK_NAME = '__shopGoogleMapsApiReady__';
14
+ const GOOGLE_MAPS_LOAD_TIMEOUT_MS = 15000;
15
+ const GOOGLE_MAPS_POLL_INTERVAL_MS = 100;
16
+ const ROUTE_COLOR = '#0EA5E9';
17
+ const markerIconUrlValidationCache = new Map();
18
+
19
+ const buildPopupTravelSummary = (item, routeSummary = null) => {
20
+ const distanceLabel =
21
+ routeSummary?.distanceLabel || item?.distanceLabel || '';
22
+ const durationLabel =
23
+ routeSummary?.durationLabel ||
24
+ item?.durationLabel ||
25
+ item?.travelDurationLabel ||
26
+ '';
27
+
28
+ if (!distanceLabel && !durationLabel) {
35
29
  return '';
36
30
  }
37
31
 
38
32
  return `
39
- <div class="shop-map-popup-meta">
40
- <span class="shop-map-popup-meta-label">${escapeHtml(label)}</span>
41
- <span>${escapeHtml(value)}</span>
33
+ <div class="shop-map-popup-summary">
34
+ ${[distanceLabel, durationLabel].filter(Boolean).join(' • ')}
42
35
  </div>
43
36
  `;
44
37
  };
45
38
 
46
- const buildPopupContent = (item, position) => {
47
- const navigationPosition = position || {
48
- latitude: Number(item?.latitude),
49
- longitude: Number(item?.longitude),
50
- };
51
- const googleMapsUrl =
52
- item?.googleMapsUrl ||
53
- buildGoogleMapsNavigationUrl(navigationPosition);
54
- const wazeUrl = item?.wazeUrl || buildWazeNavigationUrl(navigationPosition);
55
-
56
- return `
57
- <div class="shop-map-popup">
58
- <div class="shop-map-popup-company">${escapeHtml(item.companyName)}</div>
59
- <div class="shop-map-popup-title">${escapeHtml(item.title)}</div>
60
- ${buildPopupLine(item.addressLine)}
61
- ${buildPopupLine(item.addressExtra)}
62
- <div class="shop-map-popup-meta-list">
63
- ${buildPopupMeta('Telefone', item.phoneLabel)}
64
- ${buildPopupMeta('Distancia', item.distanceLabel)}
65
- ${buildPopupMeta('Horario', item.openingHours)}
66
- </div>
67
- <div class="shop-map-popup-actions">
68
- <a
69
- class="shop-map-popup-action shop-map-popup-action-primary"
70
- href="${escapeHtml(googleMapsUrl)}"
71
- target="_blank"
72
- rel="noopener noreferrer"
73
- >
74
- Abrir no Maps
75
- </a>
76
- <a
77
- class="shop-map-popup-action"
78
- href="${escapeHtml(wazeUrl)}"
79
- target="_blank"
80
- rel="noopener noreferrer"
81
- >
82
- Waze
83
- </a>
84
- </div>
85
- </div>
86
- `;
87
- };
88
-
89
- const normalizeCoordinate = value => {
90
- const numeric = Number(value);
91
- return Number.isFinite(numeric) ? numeric : null;
92
- };
93
-
94
- const extractCoordinates = address => {
95
- if (!address || typeof address !== 'object') {
96
- return null;
97
- }
98
-
99
- const latitude = normalizeCoordinate(address.latitude);
100
- const longitude = normalizeCoordinate(address.longitude);
101
-
102
- if (latitude === null || longitude === null) {
103
- return null;
104
- }
105
-
106
- return {latitude, longitude};
107
- };
108
-
109
- const injectPopupStyles = () => {
110
- if (typeof document === 'undefined') {
111
- return;
112
- }
39
+ const buildPopupTravelSummaryText = (item, routeSummary = null) =>
40
+ buildPopupTravelSummary(item, routeSummary)
41
+ .replace(/<\/?div[^>]*>/g, '')
42
+ .trim();
43
+
44
+ const normalizeText = value => String(value || '').trim();
113
45
 
114
- if (document.getElementById('shop-google-map-popup-styles')) {
115
- return;
46
+ const formatPostalCode = value => {
47
+ const digits = String(value || '').replace(/\D+/g, '');
48
+
49
+ if (digits.length !== 8) {
50
+ return normalizeText(value);
116
51
  }
117
52
 
118
- const styleElement = document.createElement('style');
119
- styleElement.id = 'shop-google-map-popup-styles';
120
- styleElement.textContent = `
121
- .shop-map-popup {
122
- min-width: 220px;
123
- max-width: 280px;
124
- color: #0f172a;
125
- font-family: Arial, sans-serif;
126
- }
53
+ return `${digits.slice(0, 5)}-${digits.slice(5)}`;
54
+ };
127
55
 
128
- .shop-map-popup-company {
129
- font-size: 11px;
130
- font-weight: 700;
131
- text-transform: uppercase;
132
- letter-spacing: 0.08em;
133
- color: #0369a1;
134
- margin-bottom: 6px;
135
- }
56
+ const extractAddressExtraFallback = value => {
57
+ const parts = String(value || '')
58
+ .split('•')
59
+ .map(item => normalizeText(item))
60
+ .filter(Boolean);
136
61
 
137
- .shop-map-popup-title {
138
- font-size: 16px;
139
- font-weight: 700;
140
- margin-bottom: 8px;
141
- }
62
+ const postalCodeLine = formatPostalCode(parts.find(item => /\d{5,8}/.test(item)));
63
+ const cityStateLine = normalizeText(
64
+ parts.find(item => /\b[A-Z]{2}\b/.test(item) || item.includes('/')),
65
+ );
142
66
 
143
- .shop-map-popup-line {
144
- font-size: 13px;
145
- line-height: 1.45;
146
- color: #0f172a;
147
- margin-bottom: 4px;
148
- }
67
+ return {
68
+ cityStateLine,
69
+ postalCodeLine,
70
+ };
71
+ };
149
72
 
150
- .shop-map-popup-meta-list {
151
- display: grid;
152
- gap: 6px;
153
- margin-top: 10px;
154
- }
73
+ const extractAddressDisplayLines = item => {
74
+ const addressParts = resolveAddressDisplayParts(item);
75
+ const addressExtraFallback = extractAddressExtraFallback(item?.addressExtra);
76
+ const primaryLine = normalizeText(
77
+ addressParts.streetLine || addressParts.primary || item?.addressLine,
78
+ );
79
+ const cityStateLine = normalizeText(
80
+ addressParts.cityStateLine || addressExtraFallback.cityStateLine,
81
+ );
82
+ const postalCodeLine = formatPostalCode(
83
+ addressParts.postalCode || addressExtraFallback.postalCodeLine,
84
+ );
155
85
 
156
- .shop-map-popup-meta {
157
- display: flex;
158
- justify-content: space-between;
159
- gap: 10px;
160
- font-size: 12px;
161
- color: #334155;
162
- }
86
+ return {
87
+ primaryLine,
88
+ cityStateLine,
89
+ postalCodeLine,
90
+ };
91
+ };
163
92
 
164
- .shop-map-popup-meta-label {
165
- color: #64748b;
166
- }
93
+ const normalizeCoordinate = value => {
94
+ const numeric = Number(value);
95
+ return Number.isFinite(numeric) ? numeric : null;
96
+ };
167
97
 
168
- .shop-map-popup-actions {
169
- display: flex;
170
- gap: 8px;
171
- margin-top: 14px;
172
- }
98
+ const extractCoordinates = address => {
99
+ if (!address || typeof address !== 'object') {
100
+ return null;
101
+ }
173
102
 
174
- .shop-map-popup-action {
175
- flex: 1;
176
- border-radius: 999px;
177
- border: 1px solid #cbd5e1;
178
- padding: 10px 12px;
179
- text-align: center;
180
- text-decoration: none;
181
- color: #0f172a;
182
- font-size: 12px;
183
- font-weight: 700;
184
- background: #ffffff;
185
- }
103
+ const latitude = normalizeCoordinate(address.latitude);
104
+ const longitude = normalizeCoordinate(address.longitude);
186
105
 
187
- .shop-map-popup-action-primary {
188
- border-color: transparent;
189
- background: #0ea5e9;
190
- color: #ffffff;
191
- }
192
- `;
193
- document.head.appendChild(styleElement);
106
+ if (latitude === null || longitude === null) {
107
+ return null;
108
+ }
109
+
110
+ return {latitude, longitude};
194
111
  };
195
112
 
196
- const loadGoogleMapsApi = apiKey => {
113
+ const buildPopupTitle = item =>
114
+ item?.unitAlias || item?.alias || item?.companyName || item?.title || 'Unidade';
115
+
116
+ const loadGoogleMapsApi = apiKey => {
197
117
  if (Platform.OS !== 'web' || typeof window === 'undefined') {
198
118
  return Promise.resolve(null);
199
119
  }
@@ -309,42 +229,42 @@ const loadGoogleMapsApi = apiKey => {
309
229
  });
310
230
 
311
231
  return window.__shopGoogleMapsPromise;
312
- };
313
-
314
- const resolveMarkerIconUrl = url => {
315
- const normalizedUrl = String(url || '').trim();
316
-
317
- if (
318
- !normalizedUrl ||
319
- typeof window === 'undefined' ||
320
- typeof window.Image !== 'function'
321
- ) {
322
- return Promise.resolve('');
323
- }
324
-
325
- if (markerIconUrlValidationCache.has(normalizedUrl)) {
326
- return markerIconUrlValidationCache.get(normalizedUrl);
327
- }
328
-
329
- const validationPromise = new Promise(resolve => {
330
- const image = new window.Image();
331
- const finalize = value => {
332
- const resolvedValue = String(value || '').trim();
333
- markerIconUrlValidationCache.set(
334
- normalizedUrl,
335
- Promise.resolve(resolvedValue),
336
- );
337
- resolve(resolvedValue);
338
- };
339
-
340
- image.onload = () => finalize(normalizedUrl);
341
- image.onerror = () => finalize('');
342
- image.src = normalizedUrl;
343
- });
344
-
345
- markerIconUrlValidationCache.set(normalizedUrl, validationPromise);
346
- return validationPromise;
347
- };
232
+ };
233
+
234
+ const resolveMarkerIconUrl = url => {
235
+ const normalizedUrl = String(url || '').trim();
236
+
237
+ if (
238
+ !normalizedUrl ||
239
+ typeof window === 'undefined' ||
240
+ typeof window.Image !== 'function'
241
+ ) {
242
+ return Promise.resolve('');
243
+ }
244
+
245
+ if (markerIconUrlValidationCache.has(normalizedUrl)) {
246
+ return markerIconUrlValidationCache.get(normalizedUrl);
247
+ }
248
+
249
+ const validationPromise = new Promise(resolve => {
250
+ const image = new window.Image();
251
+ const finalize = value => {
252
+ const resolvedValue = String(value || '').trim();
253
+ markerIconUrlValidationCache.set(
254
+ normalizedUrl,
255
+ Promise.resolve(resolvedValue),
256
+ );
257
+ resolve(resolvedValue);
258
+ };
259
+
260
+ image.onload = () => finalize(normalizedUrl);
261
+ image.onerror = () => finalize('');
262
+ image.src = normalizedUrl;
263
+ });
264
+
265
+ markerIconUrlValidationCache.set(normalizedUrl, validationPromise);
266
+ return validationPromise;
267
+ };
348
268
 
349
269
  const fitMapToBounds = ({google, map, markerCount, userCoordinates}) => {
350
270
  const bounds = new google.maps.LatLngBounds();
@@ -374,43 +294,82 @@ const fitMapToBounds = ({google, map, markerCount, userCoordinates}) => {
374
294
  left: 32,
375
295
  });
376
296
 
377
- google.maps.event.addListenerOnce(map, 'idle', () => {
378
- if (markerCount === 1 && map.getZoom() > 15) {
379
- map.setZoom(15);
380
- }
381
- });
297
+ google.maps.event.addListenerOnce(map, 'idle', () => {
298
+ if (markerCount === 1 && map.getZoom() > 15) {
299
+ map.setZoom(15);
300
+ }
301
+ });
382
302
  },
383
303
  };
384
304
  };
385
305
 
386
- export default function DefaultGoogleMap({
387
- apiKey,
388
- markerPayloads = [],
389
- paths = [],
390
- userCoordinates = null,
391
- }) {
392
- const containerRef = useRef(null);
393
-
394
- useEffect(() => {
395
- if (Platform.OS !== 'web') {
396
- return undefined;
306
+ export default function DefaultGoogleMap({
307
+ apiKey,
308
+ markerPayloads = [],
309
+ paths = [],
310
+ userCoordinates = null,
311
+ popupTheme = null,
312
+ }) {
313
+ const containerRef = useRef(null);
314
+ const directionsRendererRef = useRef(null);
315
+ const activeRouteRequestIdRef = useRef(0);
316
+ const [selectedMarkerId, setSelectedMarkerId] = useState(null);
317
+ const [selectedRouteSummary, setSelectedRouteSummary] = useState(null);
318
+ const resolvedPopupTheme = useMemo(
319
+ () => resolveMapPopupTheme(popupTheme),
320
+ [popupTheme],
321
+ );
322
+ const selectedMarker = useMemo(
323
+ () => markerPayloads.find(item => item.id === selectedMarkerId) || null,
324
+ [markerPayloads, selectedMarkerId],
325
+ );
326
+
327
+ useEffect(() => {
328
+ if (!selectedMarkerId) {
329
+ setSelectedRouteSummary(null);
330
+ return;
331
+ }
332
+
333
+ if (!selectedMarker) {
334
+ setSelectedMarkerId(null);
335
+ setSelectedRouteSummary(null);
336
+ }
337
+ }, [selectedMarker, selectedMarkerId]);
338
+
339
+ const openExternalUrl = useCallback(url => {
340
+ const normalizedUrl = String(url || '').trim();
341
+ if (!normalizedUrl || typeof window === 'undefined') {
342
+ return;
343
+ }
344
+
345
+ window.open(normalizedUrl, '_blank', 'noopener,noreferrer');
346
+ }, []);
347
+
348
+ const closePopup = useCallback(() => {
349
+ activeRouteRequestIdRef.current += 1;
350
+ setSelectedMarkerId(null);
351
+ setSelectedRouteSummary(null);
352
+ directionsRendererRef.current?.set('directions', null);
353
+ }, []);
354
+
355
+ useEffect(() => {
356
+ if (Platform.OS !== 'web') {
357
+ return undefined;
397
358
  }
398
359
 
399
360
  const container = containerRef.current;
400
- if (
401
- !container ||
402
- !apiKey ||
403
- (markerPayloads.length === 0 && !(Array.isArray(paths) && paths.length > 0) && !userCoordinates)
404
- ) {
405
- return undefined;
406
- }
361
+ if (
362
+ !container ||
363
+ !apiKey ||
364
+ (markerPayloads.length === 0 && !(Array.isArray(paths) && paths.length > 0) && !userCoordinates)
365
+ ) {
366
+ return undefined;
367
+ }
407
368
 
408
369
  let cancelled = false;
409
370
 
410
- injectPopupStyles();
411
-
412
- loadGoogleMapsApi(apiKey)
413
- .then(async () => {
371
+ loadGoogleMapsApi(apiKey)
372
+ .then(async () => {
414
373
  if (cancelled || !container || !window.google?.maps) {
415
374
  return;
416
375
  }
@@ -425,97 +384,95 @@ export default function DefaultGoogleMap({
425
384
  zoomControl: false,
426
385
  disableDefaultUI: true,
427
386
  });
428
-
429
- const infoWindow = new google.maps.InfoWindow({maxWidth: 320});
430
387
  const hasUserCoordinates =
431
388
  Number.isFinite(userCoordinates?.latitude) &&
432
389
  Number.isFinite(userCoordinates?.longitude);
433
- const directionsService = new google.maps.DirectionsService();
434
- const directionsRenderer = directionsService
435
- && hasUserCoordinates
436
- ? new google.maps.DirectionsRenderer({
437
- map,
438
- suppressMarkers: true,
439
- preserveViewport: false,
440
- polylineOptions: {
441
- strokeColor: ROUTE_COLOR,
442
- strokeOpacity: 0.92,
443
- strokeWeight: 5,
444
- },
445
- })
446
- : null;
447
- let activeRouteRequestId = 0;
448
- const resolveMarkerPayload = async item => {
449
- const latitude = Number(item?.latitude);
450
- const longitude = Number(item?.longitude);
451
- const markerIconUrl = await resolveMarkerIconUrl(item?.markerIconUrl);
452
- const resolvedItem = markerIconUrl
453
- ? {
454
- ...item,
455
- markerIconUrl,
456
- }
457
- : item?.markerIconUrl
458
- ? {
459
- ...item,
460
- markerIconUrl: '',
461
- }
462
- : item;
463
-
464
- if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
465
- return {
466
- item: resolvedItem,
467
- position: {
468
- lat: latitude,
469
- lng: longitude,
470
- },
471
- };
472
- }
473
-
474
- if (!resolvedItem?.geocodeQuery) {
475
- return null;
476
- }
477
-
478
- const geocoder = new google.maps.Geocoder();
479
-
480
- return new Promise(resolve => {
481
- geocoder.geocode({address: resolvedItem.geocodeQuery}, (results, status) => {
482
- const location = results?.[0]?.geometry?.location;
483
-
484
- if (status !== 'OK' || !location) {
485
- resolve(null);
486
- return;
487
- }
488
-
489
- const resolvedLatitude = Number(location.lat());
490
- const resolvedLongitude = Number(location.lng());
491
-
492
- if (
493
- !Number.isFinite(resolvedLatitude) ||
494
- !Number.isFinite(resolvedLongitude)
495
- ) {
496
- resolve(null);
497
- return;
498
- }
499
-
500
- resolve({
501
- item: resolvedItem,
502
- position: {
503
- lat: resolvedLatitude,
504
- lng: resolvedLongitude,
505
- },
506
- });
507
- });
508
- });
509
- };
510
- const resolvedMarkers = (await Promise.all(
511
- markerPayloads.map(resolveMarkerPayload),
512
- )).filter(Boolean);
513
- const {bounds, finalize} = fitMapToBounds({
514
- google,
515
- map,
516
- markerCount: resolvedMarkers.length,
517
- userCoordinates,
518
- });
390
+ const directionsService = new google.maps.DirectionsService();
391
+ const directionsRenderer = directionsService
392
+ && hasUserCoordinates
393
+ ? new google.maps.DirectionsRenderer({
394
+ map,
395
+ suppressMarkers: true,
396
+ preserveViewport: false,
397
+ polylineOptions: {
398
+ strokeColor: ROUTE_COLOR,
399
+ strokeOpacity: 0.92,
400
+ strokeWeight: 5,
401
+ },
402
+ })
403
+ : null;
404
+ directionsRendererRef.current = directionsRenderer;
405
+ const resolveMarkerPayload = async item => {
406
+ const latitude = Number(item?.latitude);
407
+ const longitude = Number(item?.longitude);
408
+ const markerIconUrl = await resolveMarkerIconUrl(item?.markerIconUrl);
409
+ const resolvedItem = markerIconUrl
410
+ ? {
411
+ ...item,
412
+ markerIconUrl,
413
+ }
414
+ : item?.markerIconUrl
415
+ ? {
416
+ ...item,
417
+ markerIconUrl: '',
418
+ }
419
+ : item;
420
+
421
+ if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
422
+ return {
423
+ item: resolvedItem,
424
+ position: {
425
+ lat: latitude,
426
+ lng: longitude,
427
+ },
428
+ };
429
+ }
430
+
431
+ if (!resolvedItem?.geocodeQuery) {
432
+ return null;
433
+ }
434
+
435
+ const geocoder = new google.maps.Geocoder();
436
+
437
+ return new Promise(resolve => {
438
+ geocoder.geocode({address: resolvedItem.geocodeQuery}, (results, status) => {
439
+ const location = results?.[0]?.geometry?.location;
440
+
441
+ if (status !== 'OK' || !location) {
442
+ resolve(null);
443
+ return;
444
+ }
445
+
446
+ const resolvedLatitude = Number(location.lat());
447
+ const resolvedLongitude = Number(location.lng());
448
+
449
+ if (
450
+ !Number.isFinite(resolvedLatitude) ||
451
+ !Number.isFinite(resolvedLongitude)
452
+ ) {
453
+ resolve(null);
454
+ return;
455
+ }
456
+
457
+ resolve({
458
+ item: resolvedItem,
459
+ position: {
460
+ lat: resolvedLatitude,
461
+ lng: resolvedLongitude,
462
+ },
463
+ });
464
+ });
465
+ });
466
+ };
467
+ const resolvedMarkers = (await Promise.all(
468
+ markerPayloads.map(resolveMarkerPayload),
469
+ )).filter(Boolean);
470
+ const {bounds, finalize} = fitMapToBounds({
471
+ google,
472
+ map,
473
+ markerCount: resolvedMarkers.length,
474
+ userCoordinates,
475
+ });
519
476
 
520
477
  if (
521
478
  userCoordinates &&
@@ -533,12 +490,12 @@ export default function DefaultGoogleMap({
533
490
  });
534
491
  }
535
492
 
536
- resolvedMarkers.forEach(entry => {
537
- const item = entry.item;
538
- const position = entry.position;
539
- const markerOptions = {
540
- position,
541
- map,
493
+ resolvedMarkers.forEach(entry => {
494
+ const item = entry.item;
495
+ const position = entry.position;
496
+ const markerOptions = {
497
+ position,
498
+ map,
542
499
  title: item.title,
543
500
  animation: google.maps.Animation.DROP,
544
501
  };
@@ -550,24 +507,20 @@ export default function DefaultGoogleMap({
550
507
  };
551
508
  }
552
509
 
553
- const marker = new google.maps.Marker(markerOptions);
554
-
555
- bounds.extend(position);
556
-
557
- marker.addListener('click', () => {
558
- infoWindow.setContent(buildPopupContent(item, position));
559
- infoWindow.open({
560
- anchor: marker,
561
- map,
562
- shouldFocus: false,
563
- });
510
+ const marker = new google.maps.Marker(markerOptions);
511
+
512
+ bounds.extend(position);
513
+
514
+ marker.addListener('click', () => {
515
+ setSelectedMarkerId(item.id);
516
+ setSelectedRouteSummary(null);
564
517
 
565
518
  if (!directionsService || !directionsRenderer) {
566
519
  return;
567
520
  }
568
521
 
569
- const routeRequestId = activeRouteRequestId + 1;
570
- activeRouteRequestId = routeRequestId;
522
+ const routeRequestId = activeRouteRequestIdRef.current + 1;
523
+ activeRouteRequestIdRef.current = routeRequestId;
571
524
 
572
525
  directionsService.route(
573
526
  {
@@ -582,117 +535,278 @@ export default function DefaultGoogleMap({
582
535
  if (
583
536
  cancelled ||
584
537
  !directionsRenderer ||
585
- routeRequestId !== activeRouteRequestId
538
+ routeRequestId !== activeRouteRequestIdRef.current
586
539
  ) {
587
540
  return;
588
541
  }
589
542
 
590
543
  if (status === 'OK' && response) {
544
+ const activeLeg = response?.routes?.[0]?.legs?.[0] || null;
591
545
  directionsRenderer.setDirections(response);
546
+ setSelectedRouteSummary({
547
+ distanceLabel: String(activeLeg?.distance?.text || '').trim(),
548
+ durationLabel: String(activeLeg?.duration?.text || '').trim(),
549
+ });
592
550
  return;
593
551
  }
594
552
 
595
553
  directionsRenderer.set('directions', null);
554
+ setSelectedRouteSummary(null);
596
555
  },
597
556
  );
598
- });
599
- });
600
-
601
- const resolveRouteCoordinates = path => {
602
- const from = extractCoordinates(path?.from || path?.origin || path?.start);
603
- const to = extractCoordinates(path?.to || path?.destination || path?.end);
604
-
605
- if (!from || !to) {
606
- return Promise.resolve(null);
607
- }
608
-
609
- return new Promise(resolve => {
610
- directionsService.route(
611
- {
612
- origin: from,
613
- destination: to,
614
- travelMode: google.maps.TravelMode.DRIVING,
615
- },
616
- (response, status) => {
617
- if (cancelled) {
618
- resolve(null);
619
- return;
620
- }
621
-
622
- const overviewPath = response?.routes?.[0]?.overview_path;
623
- const coordinates =
624
- Array.isArray(overviewPath) && overviewPath.length > 1
625
- ? overviewPath
626
- .map(point => ({
627
- lat: Number(typeof point.lat === 'function' ? point.lat() : point.lat),
628
- lng: Number(typeof point.lng === 'function' ? point.lng() : point.lng),
629
- }))
630
- .filter(
631
- item => Number.isFinite(item.lat) && Number.isFinite(item.lng),
632
- )
633
- : [from, to];
634
-
635
- if (status !== 'OK' && coordinates.length < 2) {
636
- resolve(null);
637
- return;
638
- }
639
-
640
- resolve({
641
- path,
642
- coordinates,
643
- });
644
- },
645
- );
646
- });
647
- };
648
-
649
- const resolvedRoutes = await Promise.all(
650
- (Array.isArray(paths) ? paths : []).map(resolveRouteCoordinates),
651
- );
652
-
653
- if (cancelled) {
654
- return;
655
- }
656
-
657
- resolvedRoutes.filter(Boolean).forEach(entry => {
658
- const coordinates = Array.isArray(entry?.coordinates) ? entry.coordinates : [];
659
-
660
- if (coordinates.length < 2) {
661
- return;
662
- }
663
-
664
- coordinates.forEach(point => {
665
- bounds.extend(point);
666
- });
667
-
668
- new google.maps.Polyline({
669
- path: coordinates,
670
- geodesic: true,
671
- strokeColor: normalizeText(entry?.path?.color || '') || ROUTE_COLOR,
672
- strokeOpacity: 0.72,
673
- strokeWeight: 4,
674
- map,
675
- });
676
- });
677
-
678
- finalize();
679
- })
680
- .catch(() => {});
681
-
682
- return () => {
683
- cancelled = true;
684
- };
685
- }, [apiKey, markerPayloads, paths, userCoordinates]);
686
-
687
- if (
688
- Platform.OS !== 'web' ||
689
- !apiKey ||
690
- (markerPayloads.length === 0 &&
691
- !Boolean(userCoordinates) &&
692
- (!Array.isArray(paths) || paths.length === 0))
693
- ) {
694
- return null;
695
- }
696
-
697
- return <View ref={containerRef} style={styles.mapViewport} />;
698
- }
557
+ });
558
+ });
559
+
560
+ const resolveRouteCoordinates = path => {
561
+ const from = extractCoordinates(path?.from || path?.origin || path?.start);
562
+ const to = extractCoordinates(path?.to || path?.destination || path?.end);
563
+
564
+ if (!from || !to) {
565
+ return Promise.resolve(null);
566
+ }
567
+
568
+ return new Promise(resolve => {
569
+ directionsService.route(
570
+ {
571
+ origin: from,
572
+ destination: to,
573
+ travelMode: google.maps.TravelMode.DRIVING,
574
+ },
575
+ (response, status) => {
576
+ if (cancelled) {
577
+ resolve(null);
578
+ return;
579
+ }
580
+
581
+ const overviewPath = response?.routes?.[0]?.overview_path;
582
+ const coordinates =
583
+ Array.isArray(overviewPath) && overviewPath.length > 1
584
+ ? overviewPath
585
+ .map(point => ({
586
+ lat: Number(typeof point.lat === 'function' ? point.lat() : point.lat),
587
+ lng: Number(typeof point.lng === 'function' ? point.lng() : point.lng),
588
+ }))
589
+ .filter(
590
+ item => Number.isFinite(item.lat) && Number.isFinite(item.lng),
591
+ )
592
+ : [from, to];
593
+
594
+ if (status !== 'OK' && coordinates.length < 2) {
595
+ resolve(null);
596
+ return;
597
+ }
598
+
599
+ resolve({
600
+ path,
601
+ coordinates,
602
+ });
603
+ },
604
+ );
605
+ });
606
+ };
607
+
608
+ const resolvedRoutes = await Promise.all(
609
+ (Array.isArray(paths) ? paths : []).map(resolveRouteCoordinates),
610
+ );
611
+
612
+ if (cancelled) {
613
+ return;
614
+ }
615
+
616
+ resolvedRoutes.filter(Boolean).forEach(entry => {
617
+ const coordinates = Array.isArray(entry?.coordinates) ? entry.coordinates : [];
618
+
619
+ if (coordinates.length < 2) {
620
+ return;
621
+ }
622
+
623
+ coordinates.forEach(point => {
624
+ bounds.extend(point);
625
+ });
626
+
627
+ new google.maps.Polyline({
628
+ path: coordinates,
629
+ geodesic: true,
630
+ strokeColor: normalizeText(entry?.path?.color || '') || ROUTE_COLOR,
631
+ strokeOpacity: 0.72,
632
+ strokeWeight: 4,
633
+ map,
634
+ });
635
+ });
636
+
637
+ finalize();
638
+ })
639
+ .catch(() => {});
640
+
641
+ return () => {
642
+ cancelled = true;
643
+ directionsRendererRef.current = null;
644
+ };
645
+ }, [apiKey, markerPayloads, paths, userCoordinates]);
646
+
647
+ if (
648
+ Platform.OS !== 'web' ||
649
+ !apiKey ||
650
+ (markerPayloads.length === 0 &&
651
+ !Boolean(userCoordinates) &&
652
+ (!Array.isArray(paths) || paths.length === 0))
653
+ ) {
654
+ return null;
655
+ }
656
+
657
+ const selectedTitle = buildPopupTitle(selectedMarker);
658
+ const selectedTravelSummary = buildPopupTravelSummaryText(
659
+ selectedMarker,
660
+ selectedRouteSummary,
661
+ );
662
+ const selectedNavigationPosition = selectedMarker
663
+ ? {
664
+ latitude: Number(selectedMarker.latitude),
665
+ longitude: Number(selectedMarker.longitude),
666
+ }
667
+ : null;
668
+ const selectedAddressLines = extractAddressDisplayLines(selectedMarker);
669
+ const selectedGoogleMapsUrl = selectedMarker
670
+ ? selectedMarker.googleMapsUrl ||
671
+ buildGoogleMapsNavigationUrl(selectedNavigationPosition)
672
+ : '';
673
+ const selectedWazeUrl = selectedMarker
674
+ ? selectedMarker.wazeUrl || buildWazeNavigationUrl(selectedNavigationPosition)
675
+ : '';
676
+
677
+ return (
678
+ <View style={styles.mapRoot}>
679
+ <View ref={containerRef} style={styles.mapViewport} />
680
+ {selectedMarker ? (
681
+ <View pointerEvents="box-none" style={styles.popupOverlay}>
682
+ <View
683
+ style={[
684
+ styles.popupCard,
685
+ {
686
+ backgroundColor: resolvedPopupTheme.modalBackground,
687
+ shadowColor: resolvedPopupTheme.modalShadow,
688
+ },
689
+ ]}>
690
+ <View style={styles.popupHeaderRow}>
691
+ <View
692
+ style={[
693
+ styles.popupLogoWrap,
694
+ {borderColor: resolvedPopupTheme.dividerBorder},
695
+ ]}>
696
+ {selectedMarker.companyLogoUrl ? (
697
+ <Image
698
+ source={{uri: selectedMarker.companyLogoUrl}}
699
+ style={styles.popupLogoImage}
700
+ resizeMode="cover"
701
+ />
702
+ ) : null}
703
+ </View>
704
+ <View style={styles.popupTitleWrap}>
705
+ <Text
706
+ numberOfLines={1}
707
+ style={[
708
+ styles.popupTitle,
709
+ {color: resolvedPopupTheme.modalHeaderText},
710
+ ]}>
711
+ {selectedTitle}
712
+ </Text>
713
+ </View>
714
+ <TouchableOpacity
715
+ activeOpacity={0.8}
716
+ accessibilityLabel="Fechar detalhes da unidade"
717
+ onPress={closePopup}
718
+ style={styles.popupCloseButton}>
719
+ <Text
720
+ style={[
721
+ styles.popupCloseText,
722
+ {color: resolvedPopupTheme.modalHeaderText},
723
+ ]}>
724
+ ×
725
+ </Text>
726
+ </TouchableOpacity>
727
+ </View>
728
+
729
+ <View
730
+ style={[
731
+ styles.popupInfoCard,
732
+ {
733
+ backgroundColor: resolvedPopupTheme.pageBackground,
734
+ borderColor: resolvedPopupTheme.dividerBorder,
735
+ },
736
+ ]}>
737
+ {selectedAddressLines.primaryLine ? (
738
+ <Text
739
+ style={[
740
+ styles.popupLine,
741
+ {color: resolvedPopupTheme.modalText},
742
+ ]}>
743
+ {selectedAddressLines.primaryLine}
744
+ </Text>
745
+ ) : null}
746
+ {selectedAddressLines.cityStateLine ? (
747
+ <Text
748
+ style={[
749
+ styles.popupLine,
750
+ {color: resolvedPopupTheme.modalText},
751
+ ]}>
752
+ {selectedAddressLines.cityStateLine}
753
+ </Text>
754
+ ) : null}
755
+ {selectedAddressLines.postalCodeLine ? (
756
+ <Text
757
+ style={[
758
+ styles.popupLine,
759
+ {color: resolvedPopupTheme.modalText},
760
+ ]}>
761
+ {selectedAddressLines.postalCodeLine}
762
+ </Text>
763
+ ) : null}
764
+ {selectedTravelSummary ? (
765
+ <Text
766
+ style={[
767
+ styles.popupSummary,
768
+ {color: resolvedPopupTheme.textMuted},
769
+ ]}>
770
+ {selectedTravelSummary}
771
+ </Text>
772
+ ) : null}
773
+ </View>
774
+
775
+ <View style={styles.popupActionsRow}>
776
+ <TouchableOpacity
777
+ activeOpacity={0.85}
778
+ onPress={() => openExternalUrl(selectedGoogleMapsUrl)}
779
+ style={[
780
+ styles.popupActionButton,
781
+ {backgroundColor: resolvedPopupTheme.buttonBackground},
782
+ ]}>
783
+ <Text
784
+ style={[
785
+ styles.popupActionText,
786
+ {color: resolvedPopupTheme.buttonText},
787
+ ]}>
788
+ Abrir no Maps
789
+ </Text>
790
+ </TouchableOpacity>
791
+ <TouchableOpacity
792
+ activeOpacity={0.85}
793
+ onPress={() => openExternalUrl(selectedWazeUrl)}
794
+ style={[
795
+ styles.popupActionButton,
796
+ {backgroundColor: resolvedPopupTheme.buttonBackground},
797
+ ]}>
798
+ <Text
799
+ style={[
800
+ styles.popupActionText,
801
+ {color: resolvedPopupTheme.buttonText},
802
+ ]}>
803
+ Abrir no Waze
804
+ </Text>
805
+ </TouchableOpacity>
806
+ </View>
807
+ </View>
808
+ </View>
809
+ ) : null}
810
+ </View>
811
+ );
812
+ }