@jotul/jotul-widgets 1.0.5 → 1.2.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.
- package/README.md +21 -1
- package/dist/JotulWidget.css +1 -1
- package/dist/JotulWidget.d.ts +2 -2
- package/dist/JotulWidget.js +166 -36
- package/dist/api.d.ts +2 -2
- package/dist/api.js +13 -2
- package/dist/components/FindDealerDrawerWidget.d.ts +39 -0
- package/dist/components/FindDealerDrawerWidget.js +318 -0
- package/dist/components/ProductPageWidget.d.ts +11 -2
- package/dist/components/ProductPageWidget.js +215 -41
- package/dist/components/product-page/CampaignStatus.d.ts +6 -0
- package/dist/components/product-page/CampaignStatus.js +48 -0
- package/dist/components/product-page/DealerList.d.ts +4 -2
- package/dist/components/product-page/DealerList.js +12 -4
- package/dist/components/product-page/StatusBanner.d.ts +1 -1
- package/dist/components/product-page/StatusBanner.js +3 -1
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +4 -0
- package/dist/i18n/locales/cz.json +5 -1
- package/dist/i18n/locales/de.json +5 -1
- package/dist/i18n/locales/en.json +5 -1
- package/dist/i18n/locales/fi.json +5 -1
- package/dist/i18n/locales/fr.json +5 -1
- package/dist/i18n/locales/nl.json +5 -1
- package/dist/i18n/locales/no.json +5 -1
- package/dist/i18n/locales/pl.json +5 -1
- package/dist/i18n/locales/se.json +5 -1
- package/dist/i18n/widgetStrings.d.ts +4 -0
- package/dist/images/dealer-pin-ildstedet.svg +31 -0
- package/dist/images/dealer-pin.svg +8 -3
- package/dist/images/ildstedet-dealer.svg +702 -0
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +26 -5
- package/dist/utils/cssColor.d.ts +5 -0
- package/dist/utils/cssColor.js +31 -0
- package/dist/utils/dealerMapClustering.d.ts +28 -0
- package/dist/utils/dealerMapClustering.js +71 -0
- package/dist/utils/loadLeafletMarkerCluster.d.ts +5 -0
- package/dist/utils/loadLeafletMarkerCluster.js +20 -0
- package/dist/utils/markerClusterIconHtml.d.ts +2 -0
- package/dist/utils/markerClusterIconHtml.js +7 -0
- package/dist/utils.d.ts +3 -1
- package/dist/utils.js +10 -0
- package/package.json +5 -2
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { checkWidgetAuthorization, DEFAULT_WIDGET_LOCALE_TAG, normalizeWidgetLocale, resolveWidgetUiLocale, searchDealersByCoordinates, searchDealersByPostalCode, JotulWidget, type CheckWidgetAuthorizationOptions, type DealerSearchResponse, type JotulWidgetButtonStyling, type JotulWidgetLocale, type JotulWidgetProps, type JotulWidgetStyling, type JotulWidgetType, type WidgetAuthClientResponse, } from './JotulWidget';
|
|
1
|
+
export { checkWidgetAuthorization, DEFAULT_WIDGET_LOCALE_TAG, normalizeWidgetLocale, resolveWidgetUiLocale, searchDealersByCoordinates, searchDealersByPostalCode, JotulWidget, type CheckWidgetAuthorizationOptions, type DealerSearchResponse, type JotulWidgetBorderStyling, type JotulWidgetButtonStyling, type JotulWidgetLocale, type JotulWidgetProps, type JotulWidgetStyling, type JotulWidgetType, type WidgetAuthClientResponse, } from './JotulWidget';
|
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;
|
|
@@ -22,6 +22,13 @@ export type DealerSearchResponse = {
|
|
|
22
22
|
};
|
|
23
23
|
total?: number;
|
|
24
24
|
dealers?: Array<Record<string, unknown>>;
|
|
25
|
+
campaign?: {
|
|
26
|
+
name?: string | null;
|
|
27
|
+
slug?: string | null;
|
|
28
|
+
salesMessage?: string | null;
|
|
29
|
+
from?: string | null;
|
|
30
|
+
to?: string | null;
|
|
31
|
+
} | null;
|
|
25
32
|
error?: string;
|
|
26
33
|
};
|
|
27
34
|
export type LocationSuggestion = {
|
|
@@ -43,17 +50,23 @@ export type CheckWidgetAuthorizationOptions = {
|
|
|
43
50
|
/** ISO 3166-1 alpha-2 market/country (e.g. `NO`, `SE`) sent to the API as `market`. */
|
|
44
51
|
market?: string;
|
|
45
52
|
brands?: string[];
|
|
53
|
+
/** Optional Sanity campaign slug used to limit dealer results to campaign dealers. */
|
|
54
|
+
campaignSlug?: string;
|
|
46
55
|
};
|
|
47
|
-
/** CSS values for primary
|
|
56
|
+
/** CSS values for primary CTAs; also used for map cluster bubbles (`backgroundColor` fill, `textColor` on the count). */
|
|
48
57
|
export type JotulWidgetButtonStyling = {
|
|
49
58
|
borderRadius?: string;
|
|
50
59
|
backgroundColor?: string;
|
|
51
60
|
textColor?: string;
|
|
52
61
|
};
|
|
62
|
+
export type JotulWidgetBorderStyling = {
|
|
63
|
+
borderColor?: string;
|
|
64
|
+
};
|
|
53
65
|
export type JotulWidgetStyling = {
|
|
54
66
|
button?: JotulWidgetButtonStyling;
|
|
67
|
+
border?: JotulWidgetBorderStyling;
|
|
55
68
|
};
|
|
56
|
-
export type
|
|
69
|
+
export type WidgetTriggerRenderProps = {
|
|
57
70
|
onOpen: () => void;
|
|
58
71
|
isLoading: boolean;
|
|
59
72
|
label: string;
|
|
@@ -70,13 +83,21 @@ export type JotulWidgetProps = {
|
|
|
70
83
|
/** ISO 3166-1 alpha-2 market filter for dealers and geocoder country bias (`NO`, `SE`, …). */
|
|
71
84
|
market?: string;
|
|
72
85
|
brands?: string[];
|
|
86
|
+
/** Optional Sanity campaign slug used to limit dealer results to campaign dealers. */
|
|
87
|
+
campaignSlug?: string;
|
|
73
88
|
styling?: JotulWidgetStyling;
|
|
74
89
|
/**
|
|
75
|
-
* Optional custom trigger for `productPage`
|
|
90
|
+
* Optional custom trigger for widgets with an open action (`productPage`, `findDealerDrawer`).
|
|
76
91
|
* - Pass a ReactNode for simple custom markup.
|
|
77
92
|
* - Pass a render function to fully control button behavior and bind `onOpen`.
|
|
78
93
|
*/
|
|
79
|
-
|
|
94
|
+
trigger?: ReactNode | ((props: WidgetTriggerRenderProps) => ReactNode);
|
|
95
|
+
/** @deprecated Use `trigger` instead. */
|
|
96
|
+
productPageTrigger?: ReactNode | ((props: WidgetTriggerRenderProps) => ReactNode);
|
|
97
|
+
/**
|
|
98
|
+
* @deprecated Use `trigger` instead.
|
|
99
|
+
*/
|
|
100
|
+
findDealerDrawerTrigger?: ReactNode | ((props: WidgetTriggerRenderProps) => ReactNode);
|
|
80
101
|
};
|
|
81
102
|
export type DealerRecord = Record<string, unknown>;
|
|
82
103
|
export type InquiryFormValues = {
|
|
@@ -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,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;
|
package/dist/utils.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const VALID_WIDGET_TYPES = [
|
|
2
2
|
'productPage',
|
|
3
|
+
'findDealerDrawer',
|
|
3
4
|
'dealerFinder',
|
|
4
5
|
'warrantyForm',
|
|
5
6
|
];
|
|
@@ -29,6 +30,13 @@ export function getDealerKey(dealer, index) {
|
|
|
29
30
|
export function getDealerName(dealer, unknownLabel) {
|
|
30
31
|
return asText(dealer.name) ?? asText(dealer.dealerId) ?? unknownLabel;
|
|
31
32
|
}
|
|
33
|
+
/** True when the tapped map pin matches a dealer already in the current successful search payload. */
|
|
34
|
+
export function isDealerInSearchResult(dealerLabel, searchResult, unknownDealerLabel) {
|
|
35
|
+
if (searchResult?.ok !== true || !Array.isArray(searchResult.dealers))
|
|
36
|
+
return false;
|
|
37
|
+
const target = dealerLabel.trim();
|
|
38
|
+
return searchResult.dealers.some((entry) => getDealerName(entry, unknownDealerLabel) === target);
|
|
39
|
+
}
|
|
32
40
|
export function getDealerAddressLines(dealer) {
|
|
33
41
|
const address = asText(dealer.address);
|
|
34
42
|
const postalCode = asText(dealer.postalCode);
|
|
@@ -77,6 +85,8 @@ export function renderReadyState(type, t) {
|
|
|
77
85
|
switch (type) {
|
|
78
86
|
case 'productPage':
|
|
79
87
|
return null;
|
|
88
|
+
case 'findDealerDrawer':
|
|
89
|
+
return null;
|
|
80
90
|
case 'dealerFinder':
|
|
81
91
|
return t.readyDealerFinder;
|
|
82
92
|
case 'warrantyForm':
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jotul/jotul-widgets",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"sideEffects": [
|
|
7
|
-
"**/*.css"
|
|
7
|
+
"**/*.css",
|
|
8
|
+
"leaflet.markercluster"
|
|
8
9
|
],
|
|
9
10
|
"files": [
|
|
10
11
|
"dist",
|
|
@@ -20,12 +21,14 @@
|
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@types/leaflet": "^1.9.21",
|
|
24
|
+
"@types/leaflet.markercluster": "^1.5.6",
|
|
23
25
|
"autoprefixer": "^10.4.21",
|
|
24
26
|
"postcss": "^8.5.3",
|
|
25
27
|
"tailwindcss": "^3.4.17"
|
|
26
28
|
},
|
|
27
29
|
"dependencies": {
|
|
28
30
|
"leaflet": "^1.9.4",
|
|
31
|
+
"leaflet.markercluster": "^1.5.3",
|
|
29
32
|
"react-leaflet": "^5.0.0"
|
|
30
33
|
},
|
|
31
34
|
"scripts": {
|