@traky-app/ui 1.6.1 → 1.6.4
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/package.json +15 -1
- package/registry/traky/json/city-select.json +19 -0
- package/registry/traky/json/country-select.json +24 -0
- package/registry/traky/json/geo-search-select.json +24 -0
- package/registry/traky/json/geo-selectors.json +21 -0
- package/registry/traky/json/google-maps-address-picker.json +22 -0
- package/registry/traky/json/internal-chat-mention.json +1 -1
- package/registry/traky/json/internal-chat-panel.json +1 -1
- package/registry/traky/json/lib-geo-city-codes.json +14 -0
- package/registry/traky/json/lib-geo-countries.json +14 -0
- package/registry/traky/json/lib-geo-csc.json +17 -0
- package/registry/traky/json/lib-geo-locations.json +14 -0
- package/registry/traky/json/lib-geo-overrides.json +14 -0
- package/registry/traky/json/lib-geo-types.json +14 -0
- package/registry/traky/json/lib-google-maps-address.json +14 -0
- package/registry/traky/json/lib-phone-countries.json +14 -0
- package/registry/traky/json/lib-phone-e164.json +14 -0
- package/registry/traky/json/lib-utils.json +1 -1
- package/registry/traky/json/office-address-form-dialog.json +17 -0
- package/registry/traky/json/office-address-form.json +31 -0
- package/registry/traky/json/phone-number-input.json +25 -0
- package/registry/traky/json/state-select.json +19 -0
- package/registry/traky/lib/geo-city-codes.ts +234 -0
- package/registry/traky/lib/geo-countries.ts +75 -0
- package/registry/traky/lib/geo-csc.ts +82 -0
- package/registry/traky/lib/geo-locations.ts +171 -0
- package/registry/traky/lib/geo-overrides.ts +375 -0
- package/registry/traky/lib/geo-types.ts +34 -0
- package/registry/traky/lib/google-maps-address.ts +100 -0
- package/registry/traky/lib/phone-countries.ts +380 -0
- package/registry/traky/lib/phone-e164.ts +66 -0
- package/registry/traky/lib/utils.ts +24 -0
- package/registry/traky/registry.json +180 -0
- package/registry/traky/ui/city-select.tsx +113 -0
- package/registry/traky/ui/country-select.tsx +233 -0
- package/registry/traky/ui/geo-search-select.tsx +136 -0
- package/registry/traky/ui/geo-selectors.tsx +162 -0
- package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
- package/registry/traky/ui/internal-chat-mention.ts +34 -3
- package/registry/traky/ui/internal-chat-panel.tsx +147 -12
- package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
- package/registry/traky/ui/office-address-form.tsx +868 -0
- package/registry/traky/ui/phone-number-input.tsx +253 -0
- package/registry/traky/ui/state-select.tsx +107 -0
- package/src/lib/geo-city-codes.ts +234 -0
- package/src/lib/geo-countries.ts +75 -0
- package/src/lib/geo-csc.ts +82 -0
- package/src/lib/geo-locations.ts +171 -0
- package/src/lib/geo-overrides.ts +375 -0
- package/src/lib/geo-types.ts +34 -0
- package/src/lib/google-maps-address.ts +100 -0
- package/src/lib/phone-countries.ts +380 -0
- package/src/lib/phone-e164.ts +66 -0
- package/src/test/setup.ts +31 -0
- package/src/ui/__tests__/geo-selects.test.tsx +504 -0
- package/src/ui/city-select.tsx +113 -0
- package/src/ui/country-select.tsx +233 -0
- package/src/ui/geo-search-select.tsx +136 -0
- package/src/ui/geo-selectors.tsx +162 -0
- package/src/ui/google-maps-address-picker.tsx +422 -0
- package/src/ui/internal-chat-mention.ts +34 -3
- package/src/ui/internal-chat-panel.tsx +147 -12
- package/src/ui/office-address-form-dialog.tsx +16 -0
- package/src/ui/office-address-form.tsx +877 -0
- package/src/ui/phone-number-input.tsx +253 -0
- package/src/ui/state-select.tsx +107 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { CountrySelect, type Country } from "@/components/ui/country-select";
|
|
5
|
+
import { StateSelect } from "@/components/ui/state-select";
|
|
6
|
+
import { CitySelect } from "@/components/ui/city-select";
|
|
7
|
+
import type { GeoCityOption, GeoOption } from "@/lib/geo-types";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
import { Label } from "@/components/ui/label";
|
|
10
|
+
|
|
11
|
+
export type GeoSelectorLevel = "country" | "state" | "city";
|
|
12
|
+
|
|
13
|
+
export type GeoSelectorsValue = {
|
|
14
|
+
country: Country | null;
|
|
15
|
+
state: GeoOption | null;
|
|
16
|
+
city: GeoCityOption | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ALL_LEVELS: readonly GeoSelectorLevel[] = ["country", "state", "city"];
|
|
20
|
+
|
|
21
|
+
const EMPTY_VALUE: GeoSelectorsValue = {
|
|
22
|
+
country: null,
|
|
23
|
+
state: null,
|
|
24
|
+
city: null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type GeoSelectorsProps = {
|
|
28
|
+
/**
|
|
29
|
+
* Niveles visibles. Omite los que no quieras.
|
|
30
|
+
* Si no está "country", pasa `countryCode`.
|
|
31
|
+
* Si no está "state" y sí "city", pasa `stateCode`.
|
|
32
|
+
*/
|
|
33
|
+
levels?: readonly GeoSelectorLevel[];
|
|
34
|
+
/** País fijo cuando el select de país no se muestra. */
|
|
35
|
+
countryCode?: string | null;
|
|
36
|
+
/** Provincia fija cuando el select de provincia no se muestra. */
|
|
37
|
+
stateCode?: string | null;
|
|
38
|
+
onChange?: (value: GeoSelectorsValue) => void;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
className?: string;
|
|
41
|
+
idPrefix?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function GeoSelectors({
|
|
45
|
+
levels = ALL_LEVELS,
|
|
46
|
+
countryCode,
|
|
47
|
+
stateCode,
|
|
48
|
+
onChange,
|
|
49
|
+
disabled = false,
|
|
50
|
+
className,
|
|
51
|
+
idPrefix = "geo",
|
|
52
|
+
}: GeoSelectorsProps) {
|
|
53
|
+
const showCountry = levels.includes("country");
|
|
54
|
+
const showState = levels.includes("state");
|
|
55
|
+
const showCity = levels.includes("city");
|
|
56
|
+
const [selection, setSelection] = useState<GeoSelectorsValue>(EMPTY_VALUE);
|
|
57
|
+
|
|
58
|
+
const resolvedCountry = showCountry
|
|
59
|
+
? (selection.country?.code ?? "")
|
|
60
|
+
: (countryCode?.trim().toUpperCase() ?? "");
|
|
61
|
+
const resolvedState = showState
|
|
62
|
+
? (selection.state?.code ?? "")
|
|
63
|
+
: (stateCode?.trim() ?? "");
|
|
64
|
+
|
|
65
|
+
const externalCountry = countryCode?.trim().toUpperCase() ?? "";
|
|
66
|
+
const externalState = stateCode?.trim() ?? "";
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (showCountry) return;
|
|
70
|
+
setSelection((prev) => {
|
|
71
|
+
if (!prev.state && !prev.city) return prev;
|
|
72
|
+
return { ...prev, state: null, city: null };
|
|
73
|
+
});
|
|
74
|
+
}, [externalCountry, showCountry]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (showState) return;
|
|
78
|
+
setSelection((prev) => {
|
|
79
|
+
if (!prev.city) return prev;
|
|
80
|
+
return { ...prev, city: null };
|
|
81
|
+
});
|
|
82
|
+
}, [externalState, showState]);
|
|
83
|
+
|
|
84
|
+
const publish = (next: GeoSelectorsValue) => {
|
|
85
|
+
setSelection(next);
|
|
86
|
+
onChange?.(next);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const visible = [showCountry, showState, showCity].filter(Boolean).length;
|
|
90
|
+
if (visible === 0) return null;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
className={cn(
|
|
95
|
+
"grid gap-4",
|
|
96
|
+
visible >= 3
|
|
97
|
+
? "md:grid-cols-3"
|
|
98
|
+
: visible === 2
|
|
99
|
+
? "md:grid-cols-2"
|
|
100
|
+
: "max-w-sm",
|
|
101
|
+
className,
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{showCountry ? (
|
|
105
|
+
<div className="space-y-1">
|
|
106
|
+
<Label htmlFor={`${idPrefix}-country`}>País</Label>
|
|
107
|
+
<CountrySelect
|
|
108
|
+
id={`${idPrefix}-country`}
|
|
109
|
+
className="w-full"
|
|
110
|
+
size="lg"
|
|
111
|
+
value={selection.country?.code}
|
|
112
|
+
disabled={disabled}
|
|
113
|
+
onChange={(country) =>
|
|
114
|
+
publish({ country, state: null, city: null })
|
|
115
|
+
}
|
|
116
|
+
allowClear
|
|
117
|
+
onClear={() => publish(EMPTY_VALUE)}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
) : null}
|
|
121
|
+
|
|
122
|
+
{showState ? (
|
|
123
|
+
<div className="space-y-1">
|
|
124
|
+
<Label htmlFor={`${idPrefix}-state`}>Estado / Región / Municipio</Label>
|
|
125
|
+
<StateSelect
|
|
126
|
+
id={`${idPrefix}-state`}
|
|
127
|
+
countryCode={resolvedCountry}
|
|
128
|
+
value={selection.state?.code ?? ""}
|
|
129
|
+
disabled={disabled}
|
|
130
|
+
onChange={(state) =>
|
|
131
|
+
publish({
|
|
132
|
+
country: selection.country,
|
|
133
|
+
state,
|
|
134
|
+
city: null,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
) : null}
|
|
140
|
+
|
|
141
|
+
{showCity ? (
|
|
142
|
+
<div className="space-y-1">
|
|
143
|
+
<Label htmlFor={`${idPrefix}-city`}>Ciudad / Pueblo / Distrito</Label>
|
|
144
|
+
<CitySelect
|
|
145
|
+
id={`${idPrefix}-city`}
|
|
146
|
+
countryCode={resolvedCountry}
|
|
147
|
+
stateCode={resolvedState}
|
|
148
|
+
value={selection.city?.code ?? selection.city?.name ?? ""}
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
onChange={(city) =>
|
|
151
|
+
publish({
|
|
152
|
+
country: selection.country,
|
|
153
|
+
state: selection.state,
|
|
154
|
+
city,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
) : null}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
4
|
+
import { useJsApiLoader, GoogleMap, Marker } from "@react-google-maps/api"
|
|
5
|
+
import { IconLoader } from "@tabler/icons-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
import {
|
|
9
|
+
parseGoogleAddressComponents,
|
|
10
|
+
type GoogleMapsAddressSelection,
|
|
11
|
+
} from "@/lib/google-maps-address"
|
|
12
|
+
import { Label } from "@/components/ui/label"
|
|
13
|
+
|
|
14
|
+
const MAP_LIBRARIES: ("places")[] = ["places"]
|
|
15
|
+
const DEFAULT_CENTER = { lat: -1.8312, lng: -78.1834 }
|
|
16
|
+
const DEFAULT_ZOOM = 6
|
|
17
|
+
const SELECTED_ZOOM = 16
|
|
18
|
+
const MAP_CONTAINER_STYLE = { width: "100%", height: "min(52dvh, 360px)" }
|
|
19
|
+
|
|
20
|
+
function applyGooglePlacesLightTheme(
|
|
21
|
+
element: google.maps.places.PlaceAutocompleteElement,
|
|
22
|
+
) {
|
|
23
|
+
element.style.setProperty("color-scheme", "light")
|
|
24
|
+
element.style.setProperty("background-color", "#ffffff")
|
|
25
|
+
element.style.setProperty("color", "#141414")
|
|
26
|
+
element.style.setProperty("border", "1px solid #e2e8f0")
|
|
27
|
+
element.style.setProperty("border-radius", "0.375rem")
|
|
28
|
+
element.style.setProperty("width", "100%")
|
|
29
|
+
element.style.setProperty("min-height", "2.75rem")
|
|
30
|
+
element.style.setProperty("font-size", "0.875rem")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parsePlaceInstance(
|
|
34
|
+
place: google.maps.places.Place,
|
|
35
|
+
): GoogleMapsAddressSelection | null {
|
|
36
|
+
const lat = place.location?.lat()
|
|
37
|
+
const lng = place.location?.lng()
|
|
38
|
+
if (lat == null || lng == null) return null
|
|
39
|
+
|
|
40
|
+
const parsed = parseGoogleAddressComponents(
|
|
41
|
+
place.addressComponents as Parameters<
|
|
42
|
+
typeof parseGoogleAddressComponents
|
|
43
|
+
>[0],
|
|
44
|
+
place.displayName || place.formattedAddress || "",
|
|
45
|
+
lat,
|
|
46
|
+
lng,
|
|
47
|
+
)
|
|
48
|
+
if (!parsed) return null
|
|
49
|
+
return {
|
|
50
|
+
...parsed,
|
|
51
|
+
formattedAddress: place.formattedAddress?.trim() || undefined,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type GoogleMapsAddressPickerProps = {
|
|
56
|
+
onLocationSelect: (address: GoogleMapsAddressSelection) => void
|
|
57
|
+
onLocationClear?: () => void
|
|
58
|
+
regionCode?: string
|
|
59
|
+
className?: string
|
|
60
|
+
initialLatitude?: number
|
|
61
|
+
initialLongitude?: number
|
|
62
|
+
initialFormattedAddress?: string
|
|
63
|
+
/** Prioridad sobre `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY`. */
|
|
64
|
+
apiKey?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function GoogleMapsAddressPickerInner({
|
|
68
|
+
onLocationSelect,
|
|
69
|
+
onLocationClear,
|
|
70
|
+
regionCode,
|
|
71
|
+
className,
|
|
72
|
+
initialLatitude,
|
|
73
|
+
initialLongitude,
|
|
74
|
+
initialFormattedAddress,
|
|
75
|
+
}: Omit<GoogleMapsAddressPickerProps, "apiKey">) {
|
|
76
|
+
const autocompleteHostRef = useRef<HTMLDivElement>(null)
|
|
77
|
+
const autocompleteElementRef =
|
|
78
|
+
useRef<google.maps.places.PlaceAutocompleteElement | null>(null)
|
|
79
|
+
const geocoderRef = useRef<google.maps.Geocoder | null>(null)
|
|
80
|
+
|
|
81
|
+
const [searchLabel, setSearchLabel] = useState("")
|
|
82
|
+
const [isGeocoding, setIsGeocoding] = useState(false)
|
|
83
|
+
const [geocodeError, setGeocodeError] = useState<string | null>(null)
|
|
84
|
+
const [marker, setMarker] = useState<google.maps.LatLngLiteral | null>(() => {
|
|
85
|
+
if (
|
|
86
|
+
typeof initialLatitude === "number" &&
|
|
87
|
+
typeof initialLongitude === "number"
|
|
88
|
+
) {
|
|
89
|
+
return { lat: initialLatitude, lng: initialLongitude }
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
})
|
|
93
|
+
const [mapCenter, setMapCenter] = useState<google.maps.LatLngLiteral>(
|
|
94
|
+
marker ?? DEFAULT_CENTER,
|
|
95
|
+
)
|
|
96
|
+
const [mapZoom, setMapZoom] = useState(marker ? SELECTED_ZOOM : DEFAULT_ZOOM)
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const lat =
|
|
100
|
+
typeof initialLatitude === "number" && Number.isFinite(initialLatitude)
|
|
101
|
+
? initialLatitude
|
|
102
|
+
: null
|
|
103
|
+
const lng =
|
|
104
|
+
typeof initialLongitude === "number" && Number.isFinite(initialLongitude)
|
|
105
|
+
? initialLongitude
|
|
106
|
+
: null
|
|
107
|
+
if (lat == null || lng == null) return
|
|
108
|
+
const position = { lat, lng }
|
|
109
|
+
setMarker(position)
|
|
110
|
+
setMapCenter(position)
|
|
111
|
+
setMapZoom(SELECTED_ZOOM)
|
|
112
|
+
}, [initialLatitude, initialLongitude])
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const label = initialFormattedAddress?.trim()
|
|
116
|
+
if (!label) return
|
|
117
|
+
setSearchLabel(label)
|
|
118
|
+
if (autocompleteElementRef.current) {
|
|
119
|
+
autocompleteElementRef.current.value = label
|
|
120
|
+
}
|
|
121
|
+
}, [initialFormattedAddress])
|
|
122
|
+
|
|
123
|
+
const mapOptions = useMemo<google.maps.MapOptions>(
|
|
124
|
+
() => ({
|
|
125
|
+
streetViewControl: false,
|
|
126
|
+
mapTypeControl: false,
|
|
127
|
+
fullscreenControl: false,
|
|
128
|
+
zoomControl: true,
|
|
129
|
+
}),
|
|
130
|
+
[],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const applySelection = useCallback(
|
|
134
|
+
(address: GoogleMapsAddressSelection, label: string) => {
|
|
135
|
+
const position = { lat: address.latitude, lng: address.longitude }
|
|
136
|
+
setMarker(position)
|
|
137
|
+
setMapCenter(position)
|
|
138
|
+
setMapZoom(SELECTED_ZOOM)
|
|
139
|
+
setSearchLabel(label)
|
|
140
|
+
setGeocodeError(null)
|
|
141
|
+
if (autocompleteElementRef.current) {
|
|
142
|
+
autocompleteElementRef.current.value = label
|
|
143
|
+
}
|
|
144
|
+
onLocationSelect({
|
|
145
|
+
...address,
|
|
146
|
+
formattedAddress: label.trim() || address.formattedAddress,
|
|
147
|
+
})
|
|
148
|
+
},
|
|
149
|
+
[onLocationSelect],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const reverseGeocode = useCallback(
|
|
153
|
+
(position: google.maps.LatLngLiteral) =>
|
|
154
|
+
new Promise<{ address: GoogleMapsAddressSelection; label: string } | null>(
|
|
155
|
+
(resolve) => {
|
|
156
|
+
if (!geocoderRef.current) {
|
|
157
|
+
geocoderRef.current = new google.maps.Geocoder()
|
|
158
|
+
}
|
|
159
|
+
geocoderRef.current.geocode({ location: position }, (results, status) => {
|
|
160
|
+
if (status !== "OK" || !results?.[0]) {
|
|
161
|
+
resolve(null)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const result = results[0]
|
|
165
|
+
const parsed = parseGoogleAddressComponents(
|
|
166
|
+
result.address_components,
|
|
167
|
+
result.formatted_address || "",
|
|
168
|
+
position.lat,
|
|
169
|
+
position.lng,
|
|
170
|
+
)
|
|
171
|
+
if (!parsed) {
|
|
172
|
+
resolve(null)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
const label = result.formatted_address?.trim() || ""
|
|
176
|
+
resolve({
|
|
177
|
+
address: {
|
|
178
|
+
...parsed,
|
|
179
|
+
formattedAddress: label || undefined,
|
|
180
|
+
},
|
|
181
|
+
label,
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
},
|
|
185
|
+
),
|
|
186
|
+
[],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const handlePositionChange = useCallback(
|
|
190
|
+
async (position: google.maps.LatLngLiteral) => {
|
|
191
|
+
setMarker(position)
|
|
192
|
+
setMapCenter(position)
|
|
193
|
+
setMapZoom(SELECTED_ZOOM)
|
|
194
|
+
setIsGeocoding(true)
|
|
195
|
+
setGeocodeError(null)
|
|
196
|
+
|
|
197
|
+
const resolved = await reverseGeocode(position)
|
|
198
|
+
setIsGeocoding(false)
|
|
199
|
+
|
|
200
|
+
if (resolved) {
|
|
201
|
+
applySelection(resolved.address, resolved.label)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setGeocodeError(
|
|
206
|
+
"No pudimos resolver esa ubicación. Elige una dirección en el buscador o activa Geocoding API en Google Cloud.",
|
|
207
|
+
)
|
|
208
|
+
},
|
|
209
|
+
[applySelection, reverseGeocode],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const host = autocompleteHostRef.current
|
|
214
|
+
if (!host) return
|
|
215
|
+
|
|
216
|
+
let cancelled = false
|
|
217
|
+
let placeAutocomplete: google.maps.places.PlaceAutocompleteElement | null =
|
|
218
|
+
null
|
|
219
|
+
|
|
220
|
+
const onSelect: EventListener = (event) => {
|
|
221
|
+
void (async () => {
|
|
222
|
+
const selectEvent =
|
|
223
|
+
event as google.maps.places.PlacePredictionSelectEvent
|
|
224
|
+
const place = selectEvent.placePrediction.toPlace()
|
|
225
|
+
await place.fetchFields({
|
|
226
|
+
fields: [
|
|
227
|
+
"displayName",
|
|
228
|
+
"formattedAddress",
|
|
229
|
+
"location",
|
|
230
|
+
"addressComponents",
|
|
231
|
+
],
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const parsed = parsePlaceInstance(place)
|
|
235
|
+
if (!parsed) return
|
|
236
|
+
|
|
237
|
+
const label =
|
|
238
|
+
place.formattedAddress?.trim() ||
|
|
239
|
+
[
|
|
240
|
+
parsed.streetNumber,
|
|
241
|
+
parsed.street,
|
|
242
|
+
parsed.city,
|
|
243
|
+
parsed.state,
|
|
244
|
+
parsed.postalCode,
|
|
245
|
+
]
|
|
246
|
+
.filter(Boolean)
|
|
247
|
+
.join(", ")
|
|
248
|
+
|
|
249
|
+
applySelection(parsed, label)
|
|
250
|
+
})()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
void (async () => {
|
|
254
|
+
await google.maps.importLibrary("places")
|
|
255
|
+
|
|
256
|
+
if (cancelled || !autocompleteHostRef.current) return
|
|
257
|
+
|
|
258
|
+
const region = regionCode?.trim().toLowerCase()
|
|
259
|
+
placeAutocomplete = new google.maps.places.PlaceAutocompleteElement({
|
|
260
|
+
...(region ? { includedRegionCodes: [region] } : {}),
|
|
261
|
+
placeholder: "Busca tu dirección en Google Maps…",
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
placeAutocomplete.className = "gmp-place-autocomplete-host block w-full"
|
|
265
|
+
applyGooglePlacesLightTheme(placeAutocomplete)
|
|
266
|
+
|
|
267
|
+
const savedLabel = initialFormattedAddress?.trim()
|
|
268
|
+
if (savedLabel) {
|
|
269
|
+
placeAutocomplete.value = savedLabel
|
|
270
|
+
setSearchLabel(savedLabel)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
host.replaceChildren(placeAutocomplete)
|
|
274
|
+
autocompleteElementRef.current = placeAutocomplete
|
|
275
|
+
placeAutocomplete.addEventListener("gmp-select", onSelect)
|
|
276
|
+
})()
|
|
277
|
+
|
|
278
|
+
return () => {
|
|
279
|
+
cancelled = true
|
|
280
|
+
if (placeAutocomplete) {
|
|
281
|
+
placeAutocomplete.removeEventListener("gmp-select", onSelect)
|
|
282
|
+
}
|
|
283
|
+
host.replaceChildren()
|
|
284
|
+
autocompleteElementRef.current = null
|
|
285
|
+
}
|
|
286
|
+
}, [applySelection, initialFormattedAddress, regionCode])
|
|
287
|
+
|
|
288
|
+
const handleClear = () => {
|
|
289
|
+
setMarker(null)
|
|
290
|
+
setSearchLabel("")
|
|
291
|
+
setGeocodeError(null)
|
|
292
|
+
setIsGeocoding(false)
|
|
293
|
+
setMapCenter(DEFAULT_CENTER)
|
|
294
|
+
setMapZoom(DEFAULT_ZOOM)
|
|
295
|
+
if (autocompleteElementRef.current) {
|
|
296
|
+
autocompleteElementRef.current.value = ""
|
|
297
|
+
}
|
|
298
|
+
onLocationClear?.()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div className={cn("space-y-3", className)}>
|
|
303
|
+
<div className="space-y-1.5">
|
|
304
|
+
<Label>Buscar dirección</Label>
|
|
305
|
+
<div
|
|
306
|
+
ref={autocompleteHostRef}
|
|
307
|
+
className="gmp-autocomplete-wrapper w-full rounded-md bg-background"
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<div className="overflow-hidden rounded-lg border border-border">
|
|
312
|
+
<GoogleMap
|
|
313
|
+
mapContainerStyle={MAP_CONTAINER_STYLE}
|
|
314
|
+
center={mapCenter}
|
|
315
|
+
zoom={mapZoom}
|
|
316
|
+
onClick={(event) => {
|
|
317
|
+
const latLng = event.latLng
|
|
318
|
+
if (!latLng) return
|
|
319
|
+
void handlePositionChange({ lat: latLng.lat(), lng: latLng.lng() })
|
|
320
|
+
}}
|
|
321
|
+
options={mapOptions}
|
|
322
|
+
>
|
|
323
|
+
{marker ? (
|
|
324
|
+
<Marker
|
|
325
|
+
position={marker}
|
|
326
|
+
draggable
|
|
327
|
+
onDragEnd={(event) => {
|
|
328
|
+
const latLng = event.latLng
|
|
329
|
+
if (!latLng) return
|
|
330
|
+
void handlePositionChange({
|
|
331
|
+
lat: latLng.lat(),
|
|
332
|
+
lng: latLng.lng(),
|
|
333
|
+
})
|
|
334
|
+
}}
|
|
335
|
+
/>
|
|
336
|
+
) : null}
|
|
337
|
+
</GoogleMap>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
{geocodeError ? (
|
|
341
|
+
<p className="text-xs text-destructive">{geocodeError}</p>
|
|
342
|
+
) : null}
|
|
343
|
+
|
|
344
|
+
{marker ? (
|
|
345
|
+
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
346
|
+
<span className="min-w-0 break-words">
|
|
347
|
+
{isGeocoding ? (
|
|
348
|
+
<>
|
|
349
|
+
<IconLoader className="mr-1 inline size-3 animate-spin" />
|
|
350
|
+
Actualizando dirección…
|
|
351
|
+
</>
|
|
352
|
+
) : (
|
|
353
|
+
searchLabel ||
|
|
354
|
+
`${marker.lat.toFixed(5)}, ${marker.lng.toFixed(5)}`
|
|
355
|
+
)}
|
|
356
|
+
</span>
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
className="shrink-0 text-primary underline underline-offset-2"
|
|
360
|
+
onClick={handleClear}
|
|
361
|
+
>
|
|
362
|
+
Limpiar
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
) : (
|
|
366
|
+
<p className="text-xs text-muted-foreground">
|
|
367
|
+
Busca una dirección o haz clic en el mapa para fijar el punto.
|
|
368
|
+
</p>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function resolveGoogleMapsApiKey(apiKey?: string): string {
|
|
375
|
+
if (apiKey?.trim()) return apiKey.trim()
|
|
376
|
+
if (typeof process !== "undefined" && process.env) {
|
|
377
|
+
return process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?.trim() ?? ""
|
|
378
|
+
}
|
|
379
|
+
return ""
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function GoogleMapsAddressPicker({
|
|
383
|
+
apiKey,
|
|
384
|
+
...props
|
|
385
|
+
}: GoogleMapsAddressPickerProps) {
|
|
386
|
+
const resolvedKey = resolveGoogleMapsApiKey(apiKey)
|
|
387
|
+
|
|
388
|
+
const { isLoaded, loadError } = useJsApiLoader({
|
|
389
|
+
googleMapsApiKey: resolvedKey,
|
|
390
|
+
libraries: MAP_LIBRARIES,
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
if (!resolvedKey) {
|
|
394
|
+
return (
|
|
395
|
+
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
|
396
|
+
Configura{" "}
|
|
397
|
+
<code className="text-xs">NEXT_PUBLIC_GOOGLE_MAPS_API_KEY</code> (solo
|
|
398
|
+
desarrollo) para usar búsqueda y mapa de Google.
|
|
399
|
+
</div>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (loadError) {
|
|
404
|
+
return (
|
|
405
|
+
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
|
406
|
+
No se pudo cargar Google Maps. Habilita Maps JavaScript API, Places API
|
|
407
|
+
(New) y Geocoding API para esta clave.
|
|
408
|
+
</div>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!isLoaded) {
|
|
413
|
+
return (
|
|
414
|
+
<div className="flex h-[min(52dvh,360px)] items-center justify-center rounded-lg border bg-muted/30 text-sm text-muted-foreground">
|
|
415
|
+
<IconLoader className="mr-2 size-4 animate-spin" />
|
|
416
|
+
Cargando mapa…
|
|
417
|
+
</div>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return <GoogleMapsAddressPickerInner {...props} />
|
|
422
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Icon } from "@tabler/icons-react";
|
|
2
2
|
|
|
3
|
-
export type InternalChatMentionKind = "guide" | "package";
|
|
3
|
+
export type InternalChatMentionKind = "guide" | "package" | "ai";
|
|
4
4
|
|
|
5
5
|
export type InternalChatMentionItem = {
|
|
6
6
|
id: string;
|
|
@@ -15,6 +15,8 @@ export type InternalChatMentionCategoryMeta = {
|
|
|
15
15
|
label: string;
|
|
16
16
|
hint: string;
|
|
17
17
|
keywords: string[];
|
|
18
|
+
/** Visible en el menú de tipo pero no seleccionable (p. ej. próximamente). */
|
|
19
|
+
disabled?: boolean;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
export type InternalChatMentionListEntry =
|
|
@@ -24,12 +26,26 @@ export type InternalChatMentionListEntry =
|
|
|
24
26
|
label: string;
|
|
25
27
|
hint: string;
|
|
26
28
|
icon: Icon;
|
|
29
|
+
disabled?: boolean;
|
|
27
30
|
}
|
|
28
31
|
| { type: "item"; data: InternalChatMentionItem };
|
|
29
32
|
|
|
33
|
+
export type InternalChatMentionCatalogState = {
|
|
34
|
+
open: boolean;
|
|
35
|
+
phase: "category" | "item";
|
|
36
|
+
kind?: InternalChatMentionKind;
|
|
37
|
+
filter: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type BuildMentionListOptions = {
|
|
41
|
+
/** Si true, los ítems ya vienen filtrados del servidor (sin filtro local por texto). */
|
|
42
|
+
serverFilteredItems?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
30
45
|
export const DEFAULT_MENTION_KIND_ORDER: InternalChatMentionKind[] = [
|
|
31
46
|
"guide",
|
|
32
47
|
"package",
|
|
48
|
+
"ai",
|
|
33
49
|
];
|
|
34
50
|
|
|
35
51
|
export const DEFAULT_MENTION_KIND_LABEL: Record<
|
|
@@ -38,6 +54,7 @@ export const DEFAULT_MENTION_KIND_LABEL: Record<
|
|
|
38
54
|
> = {
|
|
39
55
|
guide: "Guías",
|
|
40
56
|
package: "Paquetes",
|
|
57
|
+
ai: "Asistencia de IA",
|
|
41
58
|
};
|
|
42
59
|
|
|
43
60
|
export type InternalChatMentionConfig = {
|
|
@@ -64,6 +81,9 @@ function resolveKindAlias(
|
|
|
64
81
|
paquete: "package",
|
|
65
82
|
paquetes: "package",
|
|
66
83
|
package: "package",
|
|
84
|
+
ia: "ai",
|
|
85
|
+
ai: "ai",
|
|
86
|
+
asistencia: "ai",
|
|
67
87
|
...config.kindAliases,
|
|
68
88
|
};
|
|
69
89
|
|
|
@@ -129,6 +149,7 @@ function filterCategoryEntries(
|
|
|
129
149
|
label: config.categoryMeta[kind].label,
|
|
130
150
|
hint: config.categoryMeta[kind].hint,
|
|
131
151
|
icon: config.kindIcons[kind],
|
|
152
|
+
disabled: config.categoryMeta[kind].disabled,
|
|
132
153
|
}));
|
|
133
154
|
if (!q) return rows;
|
|
134
155
|
const byAlias = resolveKindAlias(q, config);
|
|
@@ -149,10 +170,11 @@ function filterItemsByKind(
|
|
|
149
170
|
kind: InternalChatMentionKind,
|
|
150
171
|
filterText: string,
|
|
151
172
|
catalog: InternalChatMentionItem[],
|
|
173
|
+
skipTextFilter = false,
|
|
152
174
|
): InternalChatMentionListEntry[] {
|
|
153
175
|
const q = filterText.trim().toLowerCase();
|
|
154
176
|
let items = catalog.filter((i) => i.kind === kind);
|
|
155
|
-
if (q) {
|
|
177
|
+
if (!skipTextFilter && q) {
|
|
156
178
|
items = items.filter(
|
|
157
179
|
(item) =>
|
|
158
180
|
item.label.toLowerCase().includes(q) ||
|
|
@@ -168,13 +190,22 @@ export function buildMentionList(
|
|
|
168
190
|
guideItems: InternalChatMentionItem[],
|
|
169
191
|
packageItems: InternalChatMentionItem[],
|
|
170
192
|
config: InternalChatMentionConfig,
|
|
193
|
+
options?: BuildMentionListOptions,
|
|
171
194
|
): InternalChatMentionListEntry[] {
|
|
172
195
|
const seg = parseMentionSegment(query, config);
|
|
173
196
|
if (seg.phase === "category") {
|
|
174
197
|
return filterCategoryEntries(seg.filter, config);
|
|
175
198
|
}
|
|
199
|
+
if (seg.kind === "ai" || config.categoryMeta[seg.kind]?.disabled) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
176
202
|
const pool = seg.kind === "guide" ? guideItems : packageItems;
|
|
177
|
-
return filterItemsByKind(
|
|
203
|
+
return filterItemsByKind(
|
|
204
|
+
seg.kind,
|
|
205
|
+
seg.filter,
|
|
206
|
+
pool,
|
|
207
|
+
Boolean(options?.serverFilteredItems),
|
|
208
|
+
);
|
|
178
209
|
}
|
|
179
210
|
|
|
180
211
|
export function roomInitialLetter(name: string): string {
|