@jotul/jotul-widgets 1.0.5 → 1.1.0

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.
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import 'leaflet/dist/leaflet.css';
3
- import { useEffect, useMemo, useState } from 'react';
4
- import { icon } from 'leaflet';
3
+ import 'leaflet.markercluster/dist/MarkerCluster.css';
4
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
+ import { icon, latLng } from 'leaflet';
5
6
  import { MapContainer, Marker, TileLayer, Tooltip, useMap } from 'react-leaflet';
6
7
  import { DealerCardSkeleton } from './DealerCardSkeleton';
7
8
  import { DealerList } from './product-page/DealerList';
@@ -11,8 +12,25 @@ import { StatusBanner } from './product-page/StatusBanner';
11
12
  import { ArrowRightIcon } from '../icons/ArrowRightIcon';
12
13
  import dealerPin from '../images/dealer-pin.svg';
13
14
  import dealerPinExclusive from '../images/dealer-pin-exclusive.svg';
14
- import { R10 } from '../constants';
15
- const OSM_SMOOTH_TILE_URL = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png';
15
+ import { MARKER_CLUSTER_LINK_KM, MARKER_CLUSTER_MIN_GROUP, partitionDealersForMarkerClusterPlugin, } from '../utils/dealerMapClustering';
16
+ import { loadLeafletMarkerCluster } from '../utils/loadLeafletMarkerCluster';
17
+ import { markerClusterCountIconHtml } from '../utils/markerClusterIconHtml';
18
+ import { JOTUL_BRAND_PRIMARY_HEX, R10 } from '../constants';
19
+ const OSM_MINIMAL_TILE_URL = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png';
20
+ function mapPointsBoundsKey(points, defaultCenter) {
21
+ const body = points.length === 0
22
+ ? ''
23
+ : [...points]
24
+ .sort((a, b) => a.latitude !== b.latitude
25
+ ? a.latitude - b.latitude
26
+ : a.longitude !== b.longitude
27
+ ? a.longitude - b.longitude
28
+ : a.dealerName.localeCompare(b.dealerName))
29
+ .map((p) => `${p.dealerName}\t${p.latitude}\t${p.longitude}`)
30
+ .join('\n');
31
+ const dc = defaultCenter != null ? `${defaultCenter[0]},${defaultCenter[1]}` : '';
32
+ return `${body}|${dc}`;
33
+ }
16
34
  function readNumber(value) {
17
35
  if (typeof value === 'number' && Number.isFinite(value))
18
36
  return value;
@@ -66,7 +84,7 @@ function createDealerPinIcon(isExclusive, active) {
66
84
  className: 'jwi-map-pin',
67
85
  });
68
86
  }
69
- function FitMapBounds({ points, defaultCenter, }) {
87
+ function FitMapBounds({ boundsKey, points, defaultCenter, }) {
70
88
  const map = useMap();
71
89
  useEffect(() => {
72
90
  const all = points.map((p) => [p.latitude, p.longitude]);
@@ -75,11 +93,13 @@ function FitMapBounds({ points, defaultCenter, }) {
75
93
  if (all.length === 0)
76
94
  return;
77
95
  if (all.length === 1) {
78
- map.setView(all[0], 12, { animate: true });
96
+ map.setView(all[0], 12, { animate: false });
79
97
  return;
80
98
  }
81
- map.fitBounds(all, { padding: [30, 30], animate: true, maxZoom: 14 });
82
- }, [defaultCenter, map, points]);
99
+ // No animation must complete before FocusActiveDealer centers the selected pin (see FindDealerDrawerWidget).
100
+ map.fitBounds(all, { padding: [50, 50], animate: false, maxZoom: 19 });
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- boundsKey encodes points + defaultCenter; avoid refit on array identity churn
102
+ }, [boundsKey, map]);
83
103
  return null;
84
104
  }
85
105
  function FocusActiveDealer({ point }) {
@@ -87,20 +107,143 @@ function FocusActiveDealer({ point }) {
87
107
  useEffect(() => {
88
108
  if (point == null)
89
109
  return;
90
- map.setView([point.latitude, point.longitude], Math.max(map.getZoom(), 13), {
91
- animate: true,
92
- });
110
+ const target = latLng(point.latitude, point.longitude);
111
+ map.setView(target, Math.max(map.getZoom(), 13), { animate: true });
93
112
  }, [map, point]);
94
113
  return null;
95
114
  }
96
- export function ProductPageWidget({ t, buttonStyling, isSearching, locationError, searchResult, inquiryValues, inquiryError, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onQuerySubmit, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, onClosePopup, }) {
115
+ function ClusteredMapMarkers({ points, pointsBoundsKey, clusterThemeKey, clusterBrandFill, clusterBrandLabel, activeDealerName, onSelectDealer, onUnavailable, }) {
116
+ const map = useMap();
117
+ const markersByDealerRef = useRef(new Map());
118
+ const pointsRef = useRef(points);
119
+ const activeNameRef = useRef(activeDealerName);
120
+ const onSelectRef = useRef(onSelectDealer);
121
+ const onUnavailableRef = useRef(onUnavailable);
122
+ pointsRef.current = points;
123
+ activeNameRef.current = activeDealerName;
124
+ onSelectRef.current = onSelectDealer;
125
+ onUnavailableRef.current = onUnavailable;
126
+ function applyPinsForSelection() {
127
+ const active = activeNameRef.current;
128
+ for (const p of pointsRef.current) {
129
+ const marker = markersByDealerRef.current.get(p.dealerName);
130
+ if (marker != null) {
131
+ marker.setIcon(createDealerPinIcon(p.isExclusive, active === p.dealerName));
132
+ }
133
+ }
134
+ }
135
+ useEffect(() => {
136
+ applyPinsForSelection();
137
+ }, [activeDealerName]);
138
+ useEffect(() => {
139
+ let disposed = false;
140
+ markersByDealerRef.current.clear();
141
+ let clusterGroup = null;
142
+ let plainGroup = null;
143
+ async function mountClusters() {
144
+ try {
145
+ const leafletModule = await import('leaflet');
146
+ if (disposed)
147
+ return;
148
+ const { clustered, standalone } = partitionDealersForMarkerClusterPlugin(pointsRef.current, {
149
+ linkKm: MARKER_CLUSTER_LINK_KM,
150
+ minGroupSize: MARKER_CLUSTER_MIN_GROUP,
151
+ });
152
+ const leafletMaybeDefault = leafletModule;
153
+ const leaf = (leafletMaybeDefault.default ??
154
+ leafletMaybeDefault);
155
+ if (typeof leaf.featureGroup !== 'function') {
156
+ onUnavailableRef.current?.();
157
+ return;
158
+ }
159
+ function makeMarker(point) {
160
+ const marker = leaf.marker([point.latitude, point.longitude], {
161
+ icon: createDealerPinIcon(point.isExclusive, activeNameRef.current === point.dealerName),
162
+ });
163
+ marker.on('click', () => {
164
+ onSelectRef.current?.({
165
+ dealerName: point.dealerName,
166
+ latitude: point.latitude,
167
+ longitude: point.longitude,
168
+ });
169
+ });
170
+ marker.bindTooltip(point.dealerName);
171
+ markersByDealerRef.current.set(point.dealerName, marker);
172
+ return marker;
173
+ }
174
+ if (standalone.length > 0) {
175
+ plainGroup = leaf.featureGroup();
176
+ for (const point of standalone) {
177
+ plainGroup.addLayer(makeMarker(point));
178
+ }
179
+ plainGroup.addTo(map);
180
+ }
181
+ if (clustered.length > 0) {
182
+ await loadLeafletMarkerCluster(leaf);
183
+ if (disposed)
184
+ return;
185
+ if (typeof leaf.markerClusterGroup !== 'function') {
186
+ onUnavailableRef.current?.();
187
+ return;
188
+ }
189
+ clusterGroup = leaf.markerClusterGroup({
190
+ showCoverageOnHover: false,
191
+ spiderfyOnMaxZoom: true,
192
+ spiderLegPolylineOptions: {
193
+ weight: 1.75,
194
+ color: clusterBrandFill,
195
+ opacity: 0.38,
196
+ },
197
+ iconCreateFunction: (cluster) => {
198
+ const n = cluster.getChildCount();
199
+ return leaf.divIcon({
200
+ html: markerClusterCountIconHtml(n, clusterBrandFill, clusterBrandLabel),
201
+ className: '',
202
+ iconSize: leaf.point(40, 40),
203
+ });
204
+ },
205
+ maxClusterRadius: (zoom) => zoom <= 6 ? 140 : zoom <= 8 ? 125 : zoom <= 10 ? 100 : zoom <= 11 ? 72 : 45,
206
+ disableClusteringAtZoom: 12,
207
+ chunkedLoading: true,
208
+ });
209
+ for (const point of clustered) {
210
+ clusterGroup.addLayer(makeMarker(point));
211
+ }
212
+ clusterGroup.addTo(map);
213
+ }
214
+ applyPinsForSelection();
215
+ }
216
+ catch {
217
+ onUnavailableRef.current?.();
218
+ }
219
+ }
220
+ void mountClusters();
221
+ return () => {
222
+ disposed = true;
223
+ if (clusterGroup != null) {
224
+ clusterGroup.clearLayers();
225
+ clusterGroup.removeFrom(map);
226
+ }
227
+ if (plainGroup != null) {
228
+ plainGroup.clearLayers();
229
+ plainGroup.removeFrom(map);
230
+ }
231
+ markersByDealerRef.current.clear();
232
+ };
233
+ }, [map, pointsBoundsKey, clusterThemeKey]);
234
+ return null;
235
+ }
236
+ export function ProductPageWidget({ t, buttonStyling, isSearching, locationError, searchResult, mapSearchResult, inquiryValues, inquiryError, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onQuerySubmit, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, onMapDealerSelect, onClosePopup, }) {
97
237
  const dealers = (searchResult?.dealers ?? []);
98
- const total = searchResult?.total ?? dealers.length;
238
+ const mapDealers = (mapSearchResult?.dealers ?? dealers);
239
+ const total = dealers.length;
99
240
  const inquiryFormOpen = inquiryValues != null;
100
241
  const [viewMode, setViewMode] = useState('list');
101
242
  const [activeDealerName, setActiveDealerName] = useState(null);
102
243
  const [mobileMapExpanded, setMobileMapExpanded] = useState(false);
103
244
  const [isMobileViewport, setIsMobileViewport] = useState(false);
245
+ const [visibleDealerCount, setVisibleDealerCount] = useState(10);
246
+ const [clusterUnavailable, setClusterUnavailable] = useState(false);
104
247
  useEffect(() => {
105
248
  if (viewMode !== 'map') {
106
249
  setMobileMapExpanded(false);
@@ -124,7 +267,23 @@ export function ProductPageWidget({ t, buttonStyling, isSearching, locationError
124
267
  media.addEventListener('change', update);
125
268
  return () => media.removeEventListener('change', update);
126
269
  }, []);
127
- const mapPoints = useMemo(() => dealers.map((dealer) => getDealerMapPoint(dealer)).filter((v) => v != null), [dealers]);
270
+ useEffect(() => {
271
+ setVisibleDealerCount(10);
272
+ }, [dealers]);
273
+ const visibleDealers = useMemo(() => dealers.slice(0, Math.max(10, visibleDealerCount)), [dealers, visibleDealerCount]);
274
+ useEffect(() => {
275
+ if (activeDealerName == null)
276
+ return;
277
+ const activeIndex = dealers.findIndex((dealer) => getDealerName(dealer) === activeDealerName);
278
+ if (activeIndex < 0 || activeIndex < visibleDealerCount)
279
+ return;
280
+ const nextCount = Math.ceil((activeIndex + 1) / 10) * 10;
281
+ setVisibleDealerCount(nextCount);
282
+ }, [activeDealerName, dealers, visibleDealerCount]);
283
+ const mapPoints = useMemo(() => mapDealers.map((dealer) => getDealerMapPoint(dealer)).filter((v) => v != null), [mapDealers]);
284
+ useEffect(() => {
285
+ setClusterUnavailable(false);
286
+ }, [mapPoints]);
128
287
  const defaultCenter = useMemo(() => {
129
288
  const first = mapPoints[0];
130
289
  if (first != null)
@@ -135,22 +294,45 @@ export function ProductPageWidget({ t, buttonStyling, isSearching, locationError
135
294
  return null;
136
295
  return [latitude, longitude];
137
296
  }, [mapPoints, searchResult?.origin?.latitude, searchResult?.origin?.longitude]);
297
+ const mapBoundsKey = useMemo(() => mapPointsBoundsKey(mapPoints, defaultCenter), [mapPoints, defaultCenter]);
138
298
  useEffect(() => {
139
299
  setActiveDealerName(dealers.length > 0 ? getDealerName(dealers[0]) : null);
140
300
  }, [dealers]);
141
301
  const activeMapPoint = useMemo(() => mapPoints.find((p) => p.dealerName === activeDealerName) ?? null, [activeDealerName, mapPoints]);
302
+ const mapClusterTheme = useMemo(() => {
303
+ const fill = buttonStyling?.backgroundColor?.trim() || JOTUL_BRAND_PRIMARY_HEX;
304
+ const label = buttonStyling?.textColor?.trim() || '#ffffff';
305
+ return {
306
+ fill,
307
+ label,
308
+ key: `${fill}\0${label}`,
309
+ };
310
+ }, [buttonStyling?.backgroundColor, buttonStyling?.textColor]);
142
311
  const listMapPills = (_jsxs("div", { className: "jwi-inline-flex jwi-items-center jwi-gap-1 jwi-rounded-full jwi-p-1", children: [_jsx("button", { type: "button", onClick: () => setViewMode('list'), className: `jwi-cursor-pointer jwi-rounded-full jwi-px-4 jwi-py-1.5 jwi-text-sm jwi-font-medium ${viewMode === 'list'
143
312
  ? 'jwi-bg-[#f0f0f0] jwi-text-[#000000]'
144
313
  : 'jwi-bg-transparent jwi-text-[#555555]'}`, children: t.listView }), _jsx("button", { type: "button", onClick: () => setViewMode('map'), className: `jwi-cursor-pointer jwi-rounded-full jwi-px-4 jwi-py-1.5 jwi-text-sm jwi-font-medium ${viewMode === 'map'
145
314
  ? 'jwi-bg-[#f0f0f0] jwi-text-[#000000]'
146
315
  : 'jwi-bg-transparent jwi-text-[#555555]'}`, children: t.mapView })] }));
147
- const mapCanvas = (_jsxs(MapContainer, { center: defaultCenter ?? [59.9139, 10.7522], zoom: 10, className: "jwi-h-full jwi-w-full", zoomControl: true, children: [_jsx(TileLayer, { attribution: '\u00A9 <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a> contributors \u00A9 <a href="https://carto.com/attributions" target="_blank" rel="noreferrer">CARTO</a>', url: OSM_SMOOTH_TILE_URL }), _jsx(FitMapBounds, { points: mapPoints, defaultCenter: defaultCenter }), _jsx(FocusActiveDealer, { point: activeMapPoint }), mapPoints.map((point) => {
148
- const isActive = activeDealerName === point.dealerName;
149
- return (_jsx(Marker, { position: [point.latitude, point.longitude], icon: createDealerPinIcon(point.isExclusive, isActive), eventHandlers: {
150
- click: () => setActiveDealerName(point.dealerName),
151
- }, children: _jsx(Tooltip, { children: point.dealerName }) }, `${point.dealerName}-${point.latitude}-${point.longitude}`));
152
- })] }));
316
+ const mapCanvas = (_jsxs(MapContainer, { center: defaultCenter ?? [59.9139, 10.7522], zoom: 6, className: "jwi-h-full jwi-w-full", zoomControl: true, children: [_jsx(TileLayer, { attribution: '\u00A9 <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a> contributors \u00A9 <a href="https://carto.com/attributions" target="_blank" rel="noreferrer">CARTO</a>', url: OSM_MINIMAL_TILE_URL, maxZoom: 19 }), _jsx(FitMapBounds, { boundsKey: mapBoundsKey, points: mapPoints, defaultCenter: defaultCenter }), _jsx(FocusActiveDealer, { point: activeMapPoint }), _jsx(ClusteredMapMarkers, { points: mapPoints, pointsBoundsKey: mapBoundsKey, clusterThemeKey: mapClusterTheme.key, clusterBrandFill: mapClusterTheme.fill, clusterBrandLabel: mapClusterTheme.label, activeDealerName: activeDealerName, onUnavailable: () => setClusterUnavailable(true), onSelectDealer: (dealer) => {
317
+ setActiveDealerName(dealer.dealerName);
318
+ onMapDealerSelect?.(dealer);
319
+ } }), clusterUnavailable &&
320
+ mapPoints.map((point) => {
321
+ const isActive = activeDealerName === point.dealerName;
322
+ return (_jsx(Marker, { position: [point.latitude, point.longitude], icon: createDealerPinIcon(point.isExclusive, isActive), eventHandlers: {
323
+ click: () => {
324
+ setActiveDealerName(point.dealerName);
325
+ onMapDealerSelect?.({
326
+ dealerName: point.dealerName,
327
+ latitude: point.latitude,
328
+ longitude: point.longitude,
329
+ });
330
+ },
331
+ }, children: _jsx(Tooltip, { children: point.dealerName }) }, `${point.dealerName}-${point.latitude}-${point.longitude}`));
332
+ })] }));
153
333
  const showInquiryInMapPopup = inquiryFormOpen && searchResult?.ok && viewMode === 'map';
334
+ const canShowMore = visibleDealerCount < dealers.length;
335
+ const showMoreButton = canShowMore ? (_jsx("button", { type: "button", onClick: () => setVisibleDealerCount((count) => Math.min(count + 10, dealers.length)), className: "jwi-mt-3 jwi-inline-flex jwi-w-full jwi-cursor-pointer jwi-items-center jwi-justify-center jwi-rounded-md jwi-border jwi-border-[#d8d2c7] jwi-bg-white jwi-px-3 jwi-py-2 jwi-text-sm jwi-font-medium jwi-text-[#111111]", children: "Show more" })) : null;
154
336
  const handleDealerFocus = (dealerName) => {
155
337
  setActiveDealerName(dealerName);
156
338
  };
@@ -164,5 +346,5 @@ export function ProductPageWidget({ t, buttonStyling, isSearching, locationError
164
346
  if (inquiryFormOpen && searchResult?.ok && viewMode !== 'map') {
165
347
  return (_jsx("div", { className: "jwi-flex jwi-w-full jwi-flex-col jwi-gap-3", children: _jsx(InquiryForm, { t: t, buttonStyling: buttonStyling, inquiryValues: inquiryValues, inquiryError: inquiryError, onInquiryClose: onInquiryClose, onInquirySubmit: onInquirySubmit, onInquiryFieldChange: onInquiryFieldChange }) }));
166
348
  }
167
- return (_jsxs(_Fragment, { children: [viewMode === 'list' && (_jsxs("div", { className: `jwi-flex jwi-w-full jwi-flex-col jwi-gap-3 ${R10} jwi-border jwi-border-[#e6e1d7] jwi-bg-white jwi-p-6`, children: [_jsx(LocationSearch, { t: t, isManualSearchEnabled: isManualSearchEnabled, query: query, suggestions: suggestions, suggestionsOpen: suggestionsOpen, isSuggestionsLoading: isSuggestionsLoading, onQueryChange: onQueryChange, onQuerySubmit: onQuerySubmit, onSuggestionSelect: onSuggestionSelect, onDismissSuggestions: onDismissSuggestions }), locationError != null && !isManualSearchEnabled && (_jsx(StatusBanner, { tone: "error", children: locationError })), searchResult?.ok === false && (_jsx(StatusBanner, { tone: "error", children: searchResult.error ?? '' })), isInquirySubmitted && _jsx(StatusBanner, { tone: "success", children: t.inquirySentSuccess }), isSearching && (_jsxs("div", { className: "jwi-flex jwi-flex-col", children: [_jsx("div", { className: "jwi-w-full jwi-flex-shrink-0 jwi-border-b jwi-border-[#e6e1d7] jwi-pb-3", children: _jsx("div", { className: "jwi-h-5 jwi-w-48 jwi-animate-pulse jwi-rounded-full jwi-bg-[#ece8df]" }) }), _jsxs("div", { className: "jwi-mt-3 jwi-mr-[-12px] jwi-flex jwi-max-h-[min(60vh,480px)] jwi-flex-col jwi-gap-4 jwi-overflow-y-auto jwi-pr-[12px]", children: [_jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {})] })] })), searchResult?.ok && !isSearching && viewMode === 'list' && (_jsx(DealerList, { dealers: dealers, total: total, selectedDealerName: selectedDealerName, headerRight: listMapPills, t: t, buttonStyling: buttonStyling, onStartInquiry: onStartInquiry }))] })), searchResult?.ok && viewMode === 'map' && (_jsxs(_Fragment, { children: [!isMobileViewport && (_jsx("div", { className: "jwi-fixed jwi-inset-0 jwi-z-[9999] jwi-flex jwi-items-center jwi-justify-center jwi-bg-black/45 jwi-p-4", onClick: () => setViewMode('list'), children: _jsxs("div", { className: `jwi-relative jwi-flex jwi-h-[min(85vh,860px)] jwi-w-[min(96vw,1200px)] jwi-scale-100 jwi-flex-col jwi-overflow-hidden ${R10} jwi-bg-white jwi-shadow-[0_20px_60px_rgba(0,0,0,0.25)] jwi-transition-all md:jwi-flex-row`, onClick: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", onClick: closeMapPopup, className: "jwi-absolute jwi-right-3 jwi-top-3 jwi-z-[1200] jwi-inline-flex jwi-h-9 jwi-w-9 jwi-cursor-pointer jwi-items-center jwi-justify-center jwi-rounded-full jwi-border jwi-border-[#d8d2c7] jwi-bg-white jwi-text-xl jwi-leading-none jwi-text-[#111111] jwi-shadow-[0_2px_8px_rgba(0,0,0,0.12)]", "aria-label": t.closeMap, children: "\u00D7" }), _jsx("div", { className: "jwi-flex jwi-h-full jwi-min-h-0 jwi-w-full jwi-flex-col jwi-overflow-hidden md:jwi-w-[48%] md:jwi-border-r md:jwi-border-[#ece8df]", children: _jsx("div", { className: "jwi-flex jwi-h-full jwi-min-h-0 jwi-w-full jwi-flex-col jwi-gap-3 jwi-overflow-hidden jwi-bg-white jwi-p-6", children: showInquiryInMapPopup ? (_jsx(InquiryForm, { t: t, buttonStyling: buttonStyling, inquiryValues: inquiryValues, inquiryError: inquiryError, embedded: true, onInquiryClose: onInquiryClose, onInquirySubmit: onInquirySubmit, onInquiryFieldChange: onInquiryFieldChange })) : (_jsxs(_Fragment, { children: [_jsx(LocationSearch, { t: t, isManualSearchEnabled: isManualSearchEnabled, query: query, suggestions: suggestions, suggestionsOpen: suggestionsOpen, isSuggestionsLoading: isSuggestionsLoading, onQueryChange: onQueryChange, onQuerySubmit: onQuerySubmit, onSuggestionSelect: onSuggestionSelect, onDismissSuggestions: onDismissSuggestions }), locationError != null && !isManualSearchEnabled && (_jsx(StatusBanner, { tone: "error", children: locationError })), isInquirySubmitted && (_jsx(StatusBanner, { tone: "success", children: t.inquirySentSuccess })), _jsx(DealerList, { dealers: dealers, total: total, selectedDealerName: selectedDealerName, activeDealerName: activeDealerName, fitAvailableHeight: true, autoScrollToActive: true, maxHeightClassName: "jwi-max-h-none", headerRight: listMapPills, t: t, buttonStyling: buttonStyling, onStartInquiry: onStartInquiry, onSelectDealer: handleDealerFocus })] })) }) }), _jsx("div", { className: "jwi-relative jwi-h-[45%] jwi-w-full md:jwi-h-full md:jwi-w-[52%]", children: mapCanvas })] }) })), isMobileViewport && (_jsx("div", { className: "jwi-fixed jwi-inset-0 jwi-z-[9999] jwi-bg-white", children: _jsxs("div", { className: "jwi-relative jwi-flex jwi-h-full jwi-flex-col", children: [_jsx("div", { className: "jwi-flex jwi-justify-end jwi-bg-white jwi-px-4 jwi-pt-3", children: _jsx("button", { type: "button", onClick: closeMapPopup, className: "jwi-inline-flex jwi-h-9 jwi-w-9 jwi-cursor-pointer jwi-items-center jwi-justify-center jwi-rounded-full jwi-border jwi-border-[#d8d2c7] jwi-bg-white jwi-text-xl jwi-leading-none jwi-text-[#111111]", "aria-label": t.closeMap, children: "\u00D7" }) }), _jsx("div", { className: "jwi-min-h-0 jwi-flex-1 jwi-overflow-y-auto jwi-bg-white jwi-p-4 jwi-pb-24", children: showInquiryInMapPopup ? (_jsx(InquiryForm, { t: t, buttonStyling: buttonStyling, inquiryValues: inquiryValues, inquiryError: inquiryError, embedded: true, onInquiryClose: onInquiryClose, onInquirySubmit: onInquirySubmit, onInquiryFieldChange: onInquiryFieldChange })) : (_jsxs(_Fragment, { children: [_jsx(LocationSearch, { t: t, isManualSearchEnabled: isManualSearchEnabled, query: query, suggestions: suggestions, suggestionsOpen: suggestionsOpen, isSuggestionsLoading: isSuggestionsLoading, onQueryChange: onQueryChange, onQuerySubmit: onQuerySubmit, onSuggestionSelect: onSuggestionSelect, onDismissSuggestions: onDismissSuggestions }), locationError != null && !isManualSearchEnabled && (_jsx(StatusBanner, { tone: "error", children: locationError })), isInquirySubmitted && (_jsx(StatusBanner, { tone: "success", children: t.inquirySentSuccess })), _jsx(DealerList, { dealers: dealers, total: total, selectedDealerName: selectedDealerName, activeDealerName: activeDealerName, maxHeightClassName: "jwi-max-h-none", autoScrollToActive: true, enableInternalScroll: false, headerRight: listMapPills, t: t, buttonStyling: buttonStyling, onStartInquiry: onStartInquiry, onSelectDealer: handleDealerFocus })] })) }), !showInquiryInMapPopup && !mobileMapExpanded && (_jsxs("button", { type: "button", onClick: () => setMobileMapExpanded((open) => !open), className: "jwi-absolute jwi-inset-x-0 jwi-bottom-0 jwi-z-20 jwi-flex jwi-h-14 jwi-items-center jwi-justify-center jwi-gap-2 jwi-rounded-t-[16px] jwi-border-t jwi-border-[#e6e1d7] jwi-bg-white jwi-text-sm jwi-font-semibold jwi-text-[#111111] jwi-shadow-[0_-6px_20px_rgba(0,0,0,0.12)]", children: [t.openMap, _jsx("span", { className: "jwi-inline-flex", style: { transform: 'rotate(-90deg)' }, children: _jsx(ArrowRightIcon, { className: "jwi-h-4 jwi-w-4 jwi-shrink-0" }) })] })), mobileMapExpanded && !showInquiryInMapPopup && (_jsxs("div", { className: "jwi-absolute jwi-inset-x-0 jwi-bottom-0 jwi-z-30 jwi-h-[78vh] jwi-overflow-hidden jwi-rounded-t-[16px] jwi-bg-white jwi-shadow-[0_-12px_36px_rgba(0,0,0,0.22)]", children: [_jsxs("button", { type: "button", onClick: () => setMobileMapExpanded(false), className: "jwi-flex jwi-h-12 jwi-w-full jwi-items-center jwi-justify-center jwi-gap-2 jwi-border-b jwi-border-[#e6e1d7] jwi-bg-white jwi-text-sm jwi-font-semibold jwi-text-[#111111]", children: [t.closeMapMobile, _jsx("span", { className: "jwi-inline-flex", style: { transform: 'rotate(90deg)' }, children: _jsx(ArrowRightIcon, { className: "jwi-h-4 jwi-w-4 jwi-shrink-0" }) })] }), _jsx("div", { className: "jwi-h-[calc(78vh-48px)] jwi-w-full", children: mapCanvas })] }))] }) }))] }))] }));
349
+ return (_jsxs(_Fragment, { children: [viewMode === 'list' && (_jsxs("div", { className: `jwi-flex jwi-w-full jwi-flex-col jwi-gap-3 ${R10} jwi-border jwi-border-[#e6e1d7] jwi-bg-white jwi-p-6`, children: [_jsx(LocationSearch, { t: t, isManualSearchEnabled: isManualSearchEnabled, query: query, suggestions: suggestions, suggestionsOpen: suggestionsOpen, isSuggestionsLoading: isSuggestionsLoading, onQueryChange: onQueryChange, onQuerySubmit: onQuerySubmit, onSuggestionSelect: onSuggestionSelect, onDismissSuggestions: onDismissSuggestions }), locationError != null && !isManualSearchEnabled && (_jsx(StatusBanner, { tone: "error", children: locationError })), searchResult?.ok === false && (_jsx(StatusBanner, { tone: "error", children: searchResult.error ?? '' })), isInquirySubmitted && _jsx(StatusBanner, { tone: "success", children: t.inquirySentSuccess }), isSearching && (_jsxs("div", { className: "jwi-flex jwi-flex-col", children: [_jsx("div", { className: "jwi-w-full jwi-flex-shrink-0 jwi-border-b jwi-border-[#e6e1d7] jwi-pb-3", children: _jsx("div", { className: "jwi-h-5 jwi-w-48 jwi-animate-pulse jwi-rounded-full jwi-bg-[#ece8df]" }) }), _jsxs("div", { className: "jwi-mt-3 jwi-mr-[-12px] jwi-flex jwi-max-h-[min(60vh,480px)] jwi-flex-col jwi-gap-4 jwi-overflow-y-auto jwi-pr-[12px]", children: [_jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {})] })] })), searchResult?.ok && !isSearching && viewMode === 'list' && (_jsx(DealerList, { dealers: visibleDealers, total: total, selectedDealerName: selectedDealerName, headerRight: listMapPills, t: t, buttonStyling: buttonStyling, onStartInquiry: onStartInquiry })), showMoreButton] })), searchResult?.ok && viewMode === 'map' && (_jsxs(_Fragment, { children: [!isMobileViewport && (_jsx("div", { className: "jwi-fixed jwi-inset-0 jwi-z-[9999] jwi-flex jwi-items-center jwi-justify-center jwi-bg-black/45 jwi-p-4", onClick: () => setViewMode('list'), children: _jsxs("div", { className: `jwi-relative jwi-flex jwi-h-[min(85vh,860px)] jwi-w-[min(96vw,1200px)] jwi-scale-100 jwi-flex-col jwi-overflow-hidden ${R10} jwi-bg-white jwi-shadow-[0_20px_60px_rgba(0,0,0,0.25)] jwi-transition-all md:jwi-flex-row`, onClick: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", onClick: closeMapPopup, className: "jwi-absolute jwi-right-3 jwi-top-3 jwi-z-[1200] jwi-inline-flex jwi-h-9 jwi-w-9 jwi-cursor-pointer jwi-items-center jwi-justify-center jwi-rounded-full jwi-border jwi-border-[#d8d2c7] jwi-bg-white jwi-text-xl jwi-leading-none jwi-text-[#111111] jwi-shadow-[0_2px_8px_rgba(0,0,0,0.12)]", "aria-label": t.closeMap, children: "\u00D7" }), _jsx("div", { className: "jwi-flex jwi-h-full jwi-min-h-0 jwi-w-full jwi-flex-col jwi-overflow-hidden md:jwi-w-[48%] md:jwi-border-r md:jwi-border-[#ece8df]", children: _jsx("div", { className: "jwi-flex jwi-h-full jwi-min-h-0 jwi-w-full jwi-flex-col jwi-gap-3 jwi-overflow-hidden jwi-bg-white jwi-p-6", children: showInquiryInMapPopup ? (_jsx(InquiryForm, { t: t, buttonStyling: buttonStyling, inquiryValues: inquiryValues, inquiryError: inquiryError, embedded: true, onInquiryClose: onInquiryClose, onInquirySubmit: onInquirySubmit, onInquiryFieldChange: onInquiryFieldChange })) : (_jsxs(_Fragment, { children: [_jsx(LocationSearch, { t: t, isManualSearchEnabled: isManualSearchEnabled, query: query, suggestions: suggestions, suggestionsOpen: suggestionsOpen, isSuggestionsLoading: isSuggestionsLoading, onQueryChange: onQueryChange, onQuerySubmit: onQuerySubmit, onSuggestionSelect: onSuggestionSelect, onDismissSuggestions: onDismissSuggestions }), locationError != null && !isManualSearchEnabled && (_jsx(StatusBanner, { tone: "error", children: locationError })), isInquirySubmitted && (_jsx(StatusBanner, { tone: "success", children: t.inquirySentSuccess })), isSearching ? (_jsxs("div", { className: "jwi-flex jwi-flex-col", children: [_jsx("div", { className: "jwi-w-full jwi-flex-shrink-0 jwi-border-b jwi-border-[#e6e1d7] jwi-pb-3", children: _jsx("div", { className: "jwi-h-5 jwi-w-48 jwi-animate-pulse jwi-rounded-full jwi-bg-[#ece8df]" }) }), _jsxs("div", { className: "jwi-mt-3 jwi-mr-[-12px] jwi-flex jwi-h-full jwi-flex-col jwi-gap-4 jwi-overflow-y-auto jwi-pr-[12px]", children: [_jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {})] })] })) : (_jsxs(_Fragment, { children: [_jsx(DealerList, { dealers: visibleDealers, total: total, selectedDealerName: selectedDealerName, activeDealerName: activeDealerName, fitAvailableHeight: true, autoScrollToActive: true, maxHeightClassName: "jwi-max-h-none", headerRight: listMapPills, t: t, buttonStyling: buttonStyling, onStartInquiry: onStartInquiry, onSelectDealer: handleDealerFocus }), showMoreButton] }))] })) }) }), _jsx("div", { className: "jwi-relative jwi-h-[45%] jwi-w-full md:jwi-h-full md:jwi-w-[52%]", children: mapCanvas })] }) })), isMobileViewport && (_jsx("div", { className: "jwi-fixed jwi-inset-0 jwi-z-[9999] jwi-bg-white", children: _jsxs("div", { className: "jwi-relative jwi-flex jwi-h-full jwi-flex-col", children: [_jsx("div", { className: "jwi-flex jwi-justify-end jwi-bg-white jwi-px-4 jwi-pt-3", children: _jsx("button", { type: "button", onClick: closeMapPopup, className: "jwi-inline-flex jwi-h-9 jwi-w-9 jwi-cursor-pointer jwi-items-center jwi-justify-center jwi-rounded-full jwi-border jwi-border-[#d8d2c7] jwi-bg-white jwi-text-xl jwi-leading-none jwi-text-[#111111]", "aria-label": t.closeMap, children: "\u00D7" }) }), _jsx("div", { className: "jwi-min-h-0 jwi-flex-1 jwi-overflow-y-auto jwi-bg-white jwi-p-4 jwi-pb-24", children: showInquiryInMapPopup ? (_jsx(InquiryForm, { t: t, buttonStyling: buttonStyling, inquiryValues: inquiryValues, inquiryError: inquiryError, embedded: true, onInquiryClose: onInquiryClose, onInquirySubmit: onInquirySubmit, onInquiryFieldChange: onInquiryFieldChange })) : (_jsxs(_Fragment, { children: [_jsx(LocationSearch, { t: t, isManualSearchEnabled: isManualSearchEnabled, query: query, suggestions: suggestions, suggestionsOpen: suggestionsOpen, isSuggestionsLoading: isSuggestionsLoading, onQueryChange: onQueryChange, onQuerySubmit: onQuerySubmit, onSuggestionSelect: onSuggestionSelect, onDismissSuggestions: onDismissSuggestions }), locationError != null && !isManualSearchEnabled && (_jsx(StatusBanner, { tone: "error", children: locationError })), isInquirySubmitted && (_jsx(StatusBanner, { tone: "success", children: t.inquirySentSuccess })), isSearching ? (_jsxs("div", { className: "jwi-flex jwi-flex-col", children: [_jsx("div", { className: "jwi-w-full jwi-flex-shrink-0 jwi-border-b jwi-border-[#e6e1d7] jwi-pb-3", children: _jsx("div", { className: "jwi-h-5 jwi-w-48 jwi-animate-pulse jwi-rounded-full jwi-bg-[#ece8df]" }) }), _jsxs("div", { className: "jwi-mt-3 jwi-flex jwi-flex-col jwi-gap-4", children: [_jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {})] })] })) : (_jsxs(_Fragment, { children: [_jsx(DealerList, { dealers: visibleDealers, total: total, selectedDealerName: selectedDealerName, activeDealerName: activeDealerName, maxHeightClassName: "jwi-max-h-none", autoScrollToActive: true, enableInternalScroll: false, headerRight: listMapPills, t: t, buttonStyling: buttonStyling, onStartInquiry: onStartInquiry, onSelectDealer: handleDealerFocus }), showMoreButton] }))] })) }), !showInquiryInMapPopup && !mobileMapExpanded && (_jsxs("button", { type: "button", onClick: () => setMobileMapExpanded((open) => !open), className: "jwi-absolute jwi-inset-x-0 jwi-bottom-0 jwi-z-20 jwi-flex jwi-h-14 jwi-items-center jwi-justify-center jwi-gap-2 jwi-rounded-t-[16px] jwi-border-t jwi-border-[#e6e1d7] jwi-bg-white jwi-text-sm jwi-font-semibold jwi-text-[#111111] jwi-shadow-[0_-6px_20px_rgba(0,0,0,0.12)]", children: [t.openMap, _jsx("span", { className: "jwi-inline-flex", style: { transform: 'rotate(-90deg)' }, children: _jsx(ArrowRightIcon, { className: "jwi-h-4 jwi-w-4 jwi-shrink-0" }) })] })), mobileMapExpanded && !showInquiryInMapPopup && (_jsxs("div", { className: "jwi-absolute jwi-inset-x-0 jwi-bottom-0 jwi-z-30 jwi-h-[78vh] jwi-overflow-hidden jwi-rounded-t-[16px] jwi-bg-white jwi-shadow-[0_-12px_36px_rgba(0,0,0,0.22)]", children: [_jsxs("button", { type: "button", onClick: () => setMobileMapExpanded(false), className: "jwi-flex jwi-h-12 jwi-w-full jwi-items-center jwi-justify-center jwi-gap-2 jwi-border-b jwi-border-[#e6e1d7] jwi-bg-white jwi-text-sm jwi-font-semibold jwi-text-[#111111]", children: [t.closeMapMobile, _jsx("span", { className: "jwi-inline-flex", style: { transform: 'rotate(90deg)' }, children: _jsx(ArrowRightIcon, { className: "jwi-h-4 jwi-w-4 jwi-shrink-0" }) })] }), _jsx("div", { className: "jwi-h-[calc(78vh-48px)] jwi-w-full", children: mapCanvas })] }))] }) }))] }))] }));
168
350
  }
@@ -1,3 +1,7 @@
1
+ /** Primary Jotul brand red (CTAs, active list border, map clusters). */
2
+ export declare const JOTUL_BRAND_PRIMARY_HEX = "#ef2b18";
3
+ /** Slightly darker red for hover / cluster ring. */
4
+ export declare const JOTUL_BRAND_PRIMARY_DARK_HEX = "#d92817";
1
5
  /** 10px radius for widget chrome (matches design spec). */
2
6
  export declare const R10 = "jwi-rounded-[10px]";
3
7
  export declare const FIND_DEALER_BUTTON_CLASS = "jwi-inline-flex jwi-w-full jwi-min-h-[56px] jwi-cursor-pointer jwi-items-center jwi-justify-center jwi-rounded-[10px] jwi-border-0 jwi-bg-[#ef2b18] jwi-px-7 jwi-text-base jwi-font-medium jwi-text-white hover:jwi-bg-[#d92817] disabled:jwi-cursor-wait disabled:hover:jwi-bg-[#ef2b18]";
package/dist/constants.js CHANGED
@@ -1,3 +1,7 @@
1
+ /** Primary Jotul brand red (CTAs, active list border, map clusters). */
2
+ export const JOTUL_BRAND_PRIMARY_HEX = '#ef2b18';
3
+ /** Slightly darker red for hover / cluster ring. */
4
+ export const JOTUL_BRAND_PRIMARY_DARK_HEX = '#d92817';
1
5
  /** 10px radius for widget chrome (matches design spec). */
2
6
  export const R10 = 'jwi-rounded-[10px]';
3
7
  export const FIND_DEALER_BUTTON_CLASS = `jwi-inline-flex jwi-w-full jwi-min-h-[56px] jwi-cursor-pointer jwi-items-center jwi-justify-center ${R10} jwi-border-0 jwi-bg-[#ef2b18] jwi-px-7 jwi-text-base jwi-font-medium jwi-text-white hover:jwi-bg-[#d92817] disabled:jwi-cursor-wait disabled:hover:jwi-bg-[#ef2b18]`;
@@ -3,9 +3,14 @@
3
3
  <defs>
4
4
  <style>
5
5
  .cls-1 {
6
- fill: #ef2b18;
6
+ fill: #fff;
7
+ }
8
+
9
+ .cls-2 {
10
+ fill: #e20000;
7
11
  }
8
12
  </style>
9
13
  </defs>
10
- <path class="cls-1" d="M8,0C3.58,0,0,3.58,0,8s8,12,8,12c0,0,8-7.58,8-12S12.42,0,8,0ZM7.91,11.35c-1.8,0-3.26-1.46-3.26-3.26s1.46-3.26,3.26-3.26,3.26,1.46,3.26,3.26-1.46,3.26-3.26,3.26Z"/>
11
- </svg>
14
+ <path class="cls-2" d="M8,0C3.58,0,0,3.58,0,8s8,12,8,12c0,0,8-7.58,8-12S12.42,0,8,0Z"/>
15
+ <path class="cls-1" d="M9.2,8.46c-.05.33-.16.44-.63.97-.41.45-.33.93-.33.93-1.06-.45-1.26-1.57-1.26-1.57-.32.53-.09,1.25-.09,1.25-.25-.15-.72-.33-1.01-.49-.46-.25-.48-.52-.48-.52-.18.52.04.93.04.93,0,0-1.04-.34-.91-1.41.11-.91.85-1.33.85-1.33,0,0-.07.77.55,1.04,0,0-.06-.79.34-1.43.49-.77,1.26-1,1.6-1.03,0,0-.34.28-.28.84.06.47.29.66.26,1.26,0,0,.44-.17.65-.71.18-.46.08-.92.08-.92,0,0,.97.19,1.38.93.27.48.28,1.05.28,1.05.59-.27.49-1.04.49-1.04,0,0,.74.42.85,1.33.14,1.07-.9,1.39-.9,1.39,0,0,.24-.42.06-.93,0,0-.24.65-1.53.93,0,0,.43-.71.01-1.5Z"/>
16
+ </svg>
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties, ReactNode } from 'react';
2
- export type JotulWidgetType = 'productPage' | 'dealerFinder' | 'warrantyForm';
2
+ export type JotulWidgetType = 'productPage' | 'findDealerDrawer' | 'dealerFinder' | 'warrantyForm';
3
3
  export type WidgetAuthClientResponse = {
4
4
  apiVersion?: string;
5
5
  ok: boolean;
@@ -44,7 +44,7 @@ export type CheckWidgetAuthorizationOptions = {
44
44
  market?: string;
45
45
  brands?: string[];
46
46
  };
47
- /** CSS values for primary CTA buttons (find dealer, send inquiry, etc.). */
47
+ /** CSS values for primary CTAs; also used for map cluster bubbles (`backgroundColor` fill, `textColor` on the count). */
48
48
  export type JotulWidgetButtonStyling = {
49
49
  borderRadius?: string;
50
50
  backgroundColor?: string;
@@ -53,7 +53,7 @@ export type JotulWidgetButtonStyling = {
53
53
  export type JotulWidgetStyling = {
54
54
  button?: JotulWidgetButtonStyling;
55
55
  };
56
- export type ProductPageTriggerRenderProps = {
56
+ export type WidgetTriggerRenderProps = {
57
57
  onOpen: () => void;
58
58
  isLoading: boolean;
59
59
  label: string;
@@ -72,11 +72,17 @@ export type JotulWidgetProps = {
72
72
  brands?: string[];
73
73
  styling?: JotulWidgetStyling;
74
74
  /**
75
- * Optional custom trigger for `productPage` widgets.
75
+ * Optional custom trigger for widgets with an open action (`productPage`, `findDealerDrawer`).
76
76
  * - Pass a ReactNode for simple custom markup.
77
77
  * - Pass a render function to fully control button behavior and bind `onOpen`.
78
78
  */
79
- productPageTrigger?: ReactNode | ((props: ProductPageTriggerRenderProps) => ReactNode);
79
+ trigger?: ReactNode | ((props: WidgetTriggerRenderProps) => ReactNode);
80
+ /** @deprecated Use `trigger` instead. */
81
+ productPageTrigger?: ReactNode | ((props: WidgetTriggerRenderProps) => ReactNode);
82
+ /**
83
+ * @deprecated Use `trigger` instead.
84
+ */
85
+ findDealerDrawerTrigger?: ReactNode | ((props: WidgetTriggerRenderProps) => ReactNode);
80
86
  };
81
87
  export type DealerRecord = Record<string, unknown>;
82
88
  export type InquiryFormValues = {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Best-effort rgba() for inline map cluster styles. Hex #rgb / #rrggbb supported.
3
+ * Other values (e.g. `rgb()`, named colors) are returned unchanged (alpha ignored).
4
+ */
5
+ export declare function cssColorWithAlpha(color: string, alpha: number): string;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Parse #rgb / #rrggbb to channels. Returns null if not a hex color.
3
+ */
4
+ function parseHexRgb(value) {
5
+ if (!value.startsWith('#'))
6
+ return null;
7
+ let hex = value.slice(1);
8
+ if (hex.length === 3) {
9
+ hex = hex
10
+ .split('')
11
+ .map((c) => c + c)
12
+ .join('');
13
+ }
14
+ if (hex.length !== 6)
15
+ return null;
16
+ const num = Number.parseInt(hex, 16);
17
+ if (!Number.isFinite(num))
18
+ return null;
19
+ return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
20
+ }
21
+ /**
22
+ * Best-effort rgba() for inline map cluster styles. Hex #rgb / #rrggbb supported.
23
+ * Other values (e.g. `rgb()`, named colors) are returned unchanged (alpha ignored).
24
+ */
25
+ export function cssColorWithAlpha(color, alpha) {
26
+ const rgb = parseHexRgb(color.trim());
27
+ if (rgb != null) {
28
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
29
+ }
30
+ return color.trim();
31
+ }
@@ -0,0 +1,28 @@
1
+ /** Max distance between two dealers to treat them as in the same “pile” when grouping. */
2
+ export declare const MARKER_CLUSTER_LINK_KM = 0.5;
3
+ /**
4
+ * Connected-component minimum size before leaflet.markercluster may own those pins. `1` keeps every
5
+ * dealer in the plugin so low zoom uses normal pixel-distance clusters; higher values defer sparse
6
+ * groups to plain markers (fewer bubbles, more individual pins at country scale).
7
+ */
8
+ export declare const MARKER_CLUSTER_MIN_GROUP = 1;
9
+ export type MapClusterPoint = {
10
+ latitude: number;
11
+ longitude: number;
12
+ dealerName: string;
13
+ };
14
+ /** Great-circle distance in kilometres. */
15
+ export declare function dealerPairDistanceKm(a: MapClusterPoint, b: MapClusterPoint): number;
16
+ /**
17
+ * Split pins between the cluster plugin and plain `featureGroup`. With
18
+ * {@link MARKER_CLUSTER_MIN_GROUP} set to `1`, all points are returned in {@link clustered} and the
19
+ * plugin handles zoom-based merging; raising the minimum leaves small geographic components as
20
+ * standalone pins.
21
+ */
22
+ export declare function partitionDealersForMarkerClusterPlugin<P extends MapClusterPoint>(points: P[], opts: {
23
+ linkKm: number;
24
+ minGroupSize: number;
25
+ }): {
26
+ clustered: P[];
27
+ standalone: P[];
28
+ };
@@ -0,0 +1,71 @@
1
+ /** Max distance between two dealers to treat them as in the same “pile” when grouping. */
2
+ export const MARKER_CLUSTER_LINK_KM = 0.5;
3
+ /**
4
+ * Connected-component minimum size before leaflet.markercluster may own those pins. `1` keeps every
5
+ * dealer in the plugin so low zoom uses normal pixel-distance clusters; higher values defer sparse
6
+ * groups to plain markers (fewer bubbles, more individual pins at country scale).
7
+ */
8
+ export const MARKER_CLUSTER_MIN_GROUP = 1;
9
+ /** Great-circle distance in kilometres. */
10
+ export function dealerPairDistanceKm(a, b) {
11
+ const R = 6371;
12
+ const dLat = ((b.latitude - a.latitude) * Math.PI) / 180;
13
+ const dLon = ((b.longitude - a.longitude) * Math.PI) / 180;
14
+ const lat1 = (a.latitude * Math.PI) / 180;
15
+ const lat2 = (b.latitude * Math.PI) / 180;
16
+ const sinDLat = Math.sin(dLat / 2);
17
+ const sinDLon = Math.sin(dLon / 2);
18
+ const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon;
19
+ return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
20
+ }
21
+ /**
22
+ * Split pins between the cluster plugin and plain `featureGroup`. With
23
+ * {@link MARKER_CLUSTER_MIN_GROUP} set to `1`, all points are returned in {@link clustered} and the
24
+ * plugin handles zoom-based merging; raising the minimum leaves small geographic components as
25
+ * standalone pins.
26
+ */
27
+ export function partitionDealersForMarkerClusterPlugin(points, opts) {
28
+ const { linkKm, minGroupSize } = opts;
29
+ if (points.length === 0)
30
+ return { clustered: [], standalone: [] };
31
+ const n = points.length;
32
+ const adj = Array.from({ length: n }, () => []);
33
+ for (let i = 0; i < n; i++) {
34
+ for (let j = i + 1; j < n; j++) {
35
+ if (dealerPairDistanceKm(points[i], points[j]) <= linkKm) {
36
+ adj[i].push(j);
37
+ adj[j].push(i);
38
+ }
39
+ }
40
+ }
41
+ const clusteredIdx = new Set();
42
+ const standaloneIdx = new Set();
43
+ const seen = new Array(n).fill(false);
44
+ for (let start = 0; start < n; start++) {
45
+ if (seen[start])
46
+ continue;
47
+ const stack = [start];
48
+ const component = [];
49
+ seen[start] = true;
50
+ while (stack.length > 0) {
51
+ const u = stack.pop();
52
+ component.push(u);
53
+ for (const v of adj[u]) {
54
+ if (!seen[v]) {
55
+ seen[v] = true;
56
+ stack.push(v);
57
+ }
58
+ }
59
+ }
60
+ const target = component.length >= minGroupSize ? clusteredIdx : standaloneIdx;
61
+ for (const idx of component)
62
+ target.add(idx);
63
+ }
64
+ const clustered = [...clusteredIdx]
65
+ .sort((a, b) => a - b)
66
+ .map((idx) => points[idx]);
67
+ const standalone = [...standaloneIdx]
68
+ .sort((a, b) => a - b)
69
+ .map((idx) => points[idx]);
70
+ return { clustered, standalone };
71
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * leaflet.markercluster's build expects a global `L`. ESM `import('leaflet')` does not set `globalThis.L`,
3
+ * so temporarily assign the loaded namespace during the side-effect import.
4
+ */
5
+ export declare function loadLeafletMarkerCluster(leaf: Record<string, unknown>): Promise<void>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * leaflet.markercluster's build expects a global `L`. ESM `import('leaflet')` does not set `globalThis.L`,
3
+ * so temporarily assign the loaded namespace during the side-effect import.
4
+ */
5
+ export async function loadLeafletMarkerCluster(leaf) {
6
+ const g = globalThis;
7
+ const saved = g.L;
8
+ g.L = leaf;
9
+ try {
10
+ await import('leaflet.markercluster');
11
+ }
12
+ finally {
13
+ if (saved === undefined) {
14
+ Reflect.deleteProperty(g, 'L');
15
+ }
16
+ else {
17
+ g.L = saved;
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,2 @@
1
+ /** HTML for leaflet.markercluster count bubble; colors come from `styling.button` (background + text). */
2
+ export declare function markerClusterCountIconHtml(childCount: number, brandFillCss: string, brandLabelCss: string): string;
@@ -0,0 +1,7 @@
1
+ import { cssColorWithAlpha } from './cssColor';
2
+ /** HTML for leaflet.markercluster count bubble; colors come from `styling.button` (background + text). */
3
+ export function markerClusterCountIconHtml(childCount, brandFillCss, brandLabelCss) {
4
+ const outer = cssColorWithAlpha(brandFillCss, 0.38);
5
+ const inner = cssColorWithAlpha(brandFillCss, 0.92);
6
+ return `<div style="width:40px;height:40px;background-clip:padding-box;border-radius:20px;background-color:${outer};box-shadow:0 2px 10px rgba(17,17,17,.12)"><div style="width:30px;height:30px;margin:5px;text-align:center;border-radius:15px;font:600 12px system-ui,-apple-system,'Segoe UI',sans-serif;background-color:${inner};color:${brandLabelCss}"><span style="line-height:30px;display:inline-block;width:100%">${childCount}</span></div></div>`;
7
+ }
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DealerRecord, InquiryFormValues, JotulWidgetType } from './types';
1
+ import type { DealerRecord, DealerSearchResponse, InquiryFormValues, JotulWidgetType } from './types';
2
2
  import type { WidgetStrings } from './i18n/widgetStrings';
3
3
  export declare const VALID_WIDGET_TYPES: JotulWidgetType[];
4
4
  export declare function isWidgetType(value: string | undefined): value is JotulWidgetType;
@@ -6,6 +6,8 @@ export declare function asText(value: unknown): string | null;
6
6
  export declare function formatDistance(dealer: DealerRecord): string | null;
7
7
  export declare function getDealerKey(dealer: DealerRecord, index: number): string;
8
8
  export declare function getDealerName(dealer: DealerRecord, unknownLabel: string): string;
9
+ /** True when the tapped map pin matches a dealer already in the current successful search payload. */
10
+ export declare function isDealerInSearchResult(dealerLabel: string, searchResult: DealerSearchResponse | null, unknownDealerLabel: string): boolean;
9
11
  export declare function getDealerAddressLines(dealer: DealerRecord): string[];
10
12
  export declare function createInquiryFormValues(productName: string | undefined, dealerName: string): InquiryFormValues;
11
13
  export declare function isValidEmail(value: string): boolean;