@jotul/jotul-widgets 0.0.4 → 1.0.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 +7 -0
- package/dist/JotulWidget.css +1 -253
- package/dist/JotulWidget.d.ts +7 -35
- package/dist/JotulWidget.js +216 -149
- package/dist/api.d.ts +7 -0
- package/dist/api.js +118 -0
- package/dist/components/DealerCardSkeleton.d.ts +2 -0
- package/dist/components/DealerCardSkeleton.js +6 -0
- package/dist/components/FinderSearchRowSkeleton.d.ts +2 -0
- package/dist/components/FinderSearchRowSkeleton.js +6 -0
- package/dist/components/InquiryField.d.ts +10 -0
- package/dist/components/InquiryField.js +7 -0
- package/dist/components/ProductPageWidget.d.ts +26 -0
- package/dist/components/ProductPageWidget.js +16 -0
- package/dist/components/product-page/DealerList.d.ts +11 -0
- package/dist/components/product-page/DealerList.js +15 -0
- package/dist/components/product-page/InquiryForm.d.ts +12 -0
- package/dist/components/product-page/InquiryForm.js +14 -0
- package/dist/components/product-page/LocationSearch.d.ts +15 -0
- package/dist/components/product-page/LocationSearch.js +31 -0
- package/dist/components/product-page/StatusBanner.d.ts +6 -0
- package/dist/components/product-page/StatusBanner.js +8 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +3 -0
- package/dist/i18n/widgetStrings.d.ts +39 -0
- package/dist/i18n/widgetStrings.js +87 -0
- package/dist/icons/ArrowRightIcon.d.ts +5 -0
- package/dist/icons/ArrowRightIcon.js +4 -0
- package/dist/icons/ButtonSpinner.d.ts +5 -0
- package/dist/icons/ButtonSpinner.js +4 -0
- package/dist/icons/PinIcon.d.ts +5 -0
- package/dist/icons/PinIcon.js +4 -0
- package/dist/icons/TelephoneIcon.d.ts +5 -0
- package/dist/icons/TelephoneIcon.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +13 -0
- package/dist/utils.js +75 -0
- package/dist/widgets/ProductPageWidget.d.ts +1 -0
- package/dist/widgets/ProductPageWidget.js +1 -0
- package/package.json +11 -2
package/dist/JotulWidget.js
CHANGED
|
@@ -1,128 +1,129 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import './JotulWidget.css';
|
|
4
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function formatDistance(dealer) {
|
|
26
|
-
const distanceKm = asNumber(dealer.distanceKm);
|
|
27
|
-
if (distanceKm !== null) {
|
|
28
|
-
return `${Math.round(distanceKm)} km`;
|
|
29
|
-
}
|
|
30
|
-
const postalDistance = asNumber(dealer.postalDistance);
|
|
31
|
-
if (postalDistance !== null) {
|
|
32
|
-
return `${Math.round(postalDistance)} km`;
|
|
33
|
-
}
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
function getDealerKey(dealer, index) {
|
|
37
|
-
return String(dealer.dealerId ?? dealer.name ?? index);
|
|
38
|
-
}
|
|
39
|
-
function getDealerName(dealer) {
|
|
40
|
-
return asText(dealer.name) ?? asText(dealer.dealerId) ?? 'Unknown dealer';
|
|
41
|
-
}
|
|
42
|
-
function getDealerAddressLines(dealer) {
|
|
43
|
-
const address = asText(dealer.address);
|
|
44
|
-
const postalCode = asText(dealer.postalCode);
|
|
45
|
-
return [address, postalCode].filter((value) => Boolean(value));
|
|
46
|
-
}
|
|
47
|
-
export async function checkWidgetAuthorization(options) {
|
|
48
|
-
const endpoint = options?.endpoint ?? '/api/jotul/widget';
|
|
49
|
-
const fetcher = options?.fetcher ?? fetch;
|
|
50
|
-
let response;
|
|
51
|
-
try {
|
|
52
|
-
response = await fetcher(endpoint, {
|
|
53
|
-
method: 'GET',
|
|
54
|
-
headers: {
|
|
55
|
-
Accept: 'application/json',
|
|
56
|
-
},
|
|
57
|
-
cache: 'no-store',
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
return {
|
|
62
|
-
ok: false,
|
|
63
|
-
error: error instanceof Error ? error.message : 'Failed to reach widget auth route',
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
try {
|
|
67
|
-
return (await response.json());
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return {
|
|
71
|
-
ok: false,
|
|
72
|
-
error: 'Invalid API key',
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
export async function searchDealersByPostalCode(postalCode, options) {
|
|
77
|
-
const endpoint = options?.endpoint ?? '/api/jotul/widget';
|
|
78
|
-
const fetcher = options?.fetcher ?? fetch;
|
|
79
|
-
const params = new URLSearchParams({ postalCode: postalCode.trim() });
|
|
80
|
-
let response;
|
|
81
|
-
try {
|
|
82
|
-
response = await fetcher(`${endpoint}?${params.toString()}`, {
|
|
83
|
-
method: 'GET',
|
|
84
|
-
headers: {
|
|
85
|
-
Accept: 'application/json',
|
|
86
|
-
},
|
|
87
|
-
cache: 'no-store',
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
return {
|
|
92
|
-
ok: false,
|
|
93
|
-
error: error instanceof Error ? error.message : 'Failed to reach widget route',
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
return (await response.json());
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return {
|
|
101
|
-
ok: false,
|
|
102
|
-
error: 'Widget route returned invalid JSON',
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
function isWidgetType(value) {
|
|
107
|
-
return value != null && VALID_WIDGET_TYPES.includes(value);
|
|
108
|
-
}
|
|
109
|
-
function renderReadyState(type) {
|
|
110
|
-
switch (type) {
|
|
111
|
-
case 'productPage':
|
|
112
|
-
return null;
|
|
113
|
-
case 'dealerFinder':
|
|
114
|
-
return 'JotulWidget ready: dealerFinder';
|
|
115
|
-
case 'warrantyForm':
|
|
116
|
-
return 'JotulWidget ready: warrantyForm';
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
export function JotulWidget({ type, endpoint = '/api/jotul/widget', className, }) {
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
|
+
import { FinderSearchRowSkeleton } from './components/FinderSearchRowSkeleton';
|
|
6
|
+
import { ProductPageWidget } from './widgets/ProductPageWidget';
|
|
7
|
+
import { FIND_DEALER_BUTTON_CLASS } from './constants';
|
|
8
|
+
import { ButtonSpinner } from './icons/ButtonSpinner';
|
|
9
|
+
import { normalizeWidgetLocale, WIDGET_STRINGS } from './i18n/widgetStrings';
|
|
10
|
+
import { checkWidgetAuthorization, searchLocationSuggestions, searchDealersByCoordinates, searchDealersByPostalCode, } from './api';
|
|
11
|
+
import { createInquiryFormValues, getSafeWidgetErrorMessage, isValidEmail, isWidgetType, renderReadyState, } from './utils';
|
|
12
|
+
export { normalizeWidgetLocale } from './i18n/widgetStrings';
|
|
13
|
+
export { checkWidgetAuthorization, searchLocationSuggestions, searchDealersByCoordinates, searchDealersByPostalCode, };
|
|
14
|
+
const GEO_PERMISSION_DENIED = 1;
|
|
15
|
+
const GEO_POSITION_UNAVAILABLE = 2;
|
|
16
|
+
const GEO_TIMEOUT = 3;
|
|
17
|
+
const GEOLOCATION_OPTIONS = {
|
|
18
|
+
enableHighAccuracy: false,
|
|
19
|
+
timeout: 15000,
|
|
20
|
+
maximumAge: 300000,
|
|
21
|
+
};
|
|
22
|
+
export function JotulWidget({ type, endpoint = '/api/jotul/widget', className, productName, locale: localeProp, brands, }) {
|
|
23
|
+
const resolvedLocale = useMemo(() => normalizeWidgetLocale(localeProp), [localeProp]);
|
|
24
|
+
const t = WIDGET_STRINGS[resolvedLocale];
|
|
120
25
|
const [auth, setAuth] = useState(null);
|
|
121
|
-
const [isLoading, setIsLoading] = useState(
|
|
122
|
-
const [postalCode, setPostalCode] = useState('');
|
|
26
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
123
27
|
const [searchResult, setSearchResult] = useState(null);
|
|
124
28
|
const [isSearching, setIsSearching] = useState(false);
|
|
29
|
+
const [isSearchingSuggestions, setIsSearchingSuggestions] = useState(false);
|
|
30
|
+
const [locationError, setLocationError] = useState(null);
|
|
31
|
+
const [isManualLocationSearchEnabled] = useState(true);
|
|
32
|
+
const [locationQuery, setLocationQuery] = useState('');
|
|
33
|
+
const [locationSuggestions, setLocationSuggestions] = useState([]);
|
|
34
|
+
const [isSuggestionListOpen, setIsSuggestionListOpen] = useState(false);
|
|
35
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
36
|
+
const [shouldAutoLocateAfterAuth, setShouldAutoLocateAfterAuth] = useState(false);
|
|
37
|
+
const [selectedDealerName, setSelectedDealerName] = useState(null);
|
|
38
|
+
const [inquiryValues, setInquiryValues] = useState(null);
|
|
39
|
+
const [inquiryError, setInquiryError] = useState(null);
|
|
40
|
+
const [isInquirySubmitted, setIsInquirySubmitted] = useState(false);
|
|
41
|
+
const autocompleteCacheRef = useRef(new Map());
|
|
42
|
+
const dealerSearchOptions = useMemo(() => ({
|
|
43
|
+
endpoint,
|
|
44
|
+
locale: resolvedLocale,
|
|
45
|
+
brands,
|
|
46
|
+
}), [brands, endpoint, resolvedLocale]);
|
|
47
|
+
const runDealerSearchByCoordinates = useCallback(async (latitude, longitude) => {
|
|
48
|
+
setLocationError(null);
|
|
49
|
+
setSearchResult(null);
|
|
50
|
+
setIsSearching(true);
|
|
51
|
+
try {
|
|
52
|
+
const result = await searchDealersByCoordinates(latitude, longitude, dealerSearchOptions);
|
|
53
|
+
setSearchResult(result);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
setIsSearching(false);
|
|
57
|
+
}
|
|
58
|
+
}, [dealerSearchOptions]);
|
|
59
|
+
const runLocationSearch = useCallback(() => {
|
|
60
|
+
const messages = WIDGET_STRINGS[resolvedLocale];
|
|
61
|
+
setShouldAutoLocateAfterAuth(false);
|
|
62
|
+
setLocationError(null);
|
|
63
|
+
setSearchResult(null);
|
|
64
|
+
setLocationSuggestions([]);
|
|
65
|
+
setIsSearchingSuggestions(false);
|
|
66
|
+
setIsSuggestionListOpen(false);
|
|
67
|
+
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
68
|
+
setLocationError(messages.locationUnavailableBrowser);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
setIsSearching(true);
|
|
72
|
+
navigator.geolocation.getCurrentPosition(async (position) => {
|
|
73
|
+
setIsSearching(false);
|
|
74
|
+
await runDealerSearchByCoordinates(position.coords.latitude, position.coords.longitude);
|
|
75
|
+
setLocationQuery('');
|
|
76
|
+
setLocationSuggestions([]);
|
|
77
|
+
}, (err) => {
|
|
78
|
+
setIsSearching(false);
|
|
79
|
+
if (err.code === GEO_PERMISSION_DENIED) {
|
|
80
|
+
setLocationError(messages.locationPermissionDenied);
|
|
81
|
+
}
|
|
82
|
+
else if (err.code === GEO_POSITION_UNAVAILABLE) {
|
|
83
|
+
setLocationError(messages.locationPositionUnavailable);
|
|
84
|
+
}
|
|
85
|
+
else if (err.code === GEO_TIMEOUT) {
|
|
86
|
+
setLocationError(messages.locationTimeout);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
setLocationError(messages.locationGenericFailure);
|
|
90
|
+
}
|
|
91
|
+
}, GEOLOCATION_OPTIONS);
|
|
92
|
+
}, [resolvedLocale, runDealerSearchByCoordinates]);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const query = locationQuery.trim();
|
|
95
|
+
if (query.length < 3) {
|
|
96
|
+
setIsSearchingSuggestions(false);
|
|
97
|
+
setLocationSuggestions([]);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const cacheKey = query.toLowerCase();
|
|
101
|
+
const cached = autocompleteCacheRef.current.get(cacheKey);
|
|
102
|
+
if (cached != null) {
|
|
103
|
+
setLocationSuggestions(cached);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
let cancelled = false;
|
|
107
|
+
const timer = setTimeout(async () => {
|
|
108
|
+
setIsSearchingSuggestions(true);
|
|
109
|
+
const result = await searchLocationSuggestions(query, dealerSearchOptions);
|
|
110
|
+
if (cancelled)
|
|
111
|
+
return;
|
|
112
|
+
const suggestions = result.ok && Array.isArray(result.suggestions) ? result.suggestions : [];
|
|
113
|
+
autocompleteCacheRef.current.set(cacheKey, suggestions);
|
|
114
|
+
setLocationSuggestions(suggestions);
|
|
115
|
+
setIsSearchingSuggestions(false);
|
|
116
|
+
}, 350);
|
|
117
|
+
return () => {
|
|
118
|
+
cancelled = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
};
|
|
121
|
+
}, [dealerSearchOptions, locationQuery]);
|
|
125
122
|
useEffect(() => {
|
|
123
|
+
if (type === 'productPage' && !isOpen)
|
|
124
|
+
return;
|
|
125
|
+
if (auth != null)
|
|
126
|
+
return;
|
|
126
127
|
let cancelled = false;
|
|
127
128
|
async function run() {
|
|
128
129
|
setIsLoading(true);
|
|
@@ -136,7 +137,21 @@ export function JotulWidget({ type, endpoint = '/api/jotul/widget', className, }
|
|
|
136
137
|
return () => {
|
|
137
138
|
cancelled = true;
|
|
138
139
|
};
|
|
139
|
-
}, [endpoint]);
|
|
140
|
+
}, [auth, endpoint, isOpen, type]);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!shouldAutoLocateAfterAuth)
|
|
143
|
+
return;
|
|
144
|
+
if (type !== 'productPage' || !isOpen)
|
|
145
|
+
return;
|
|
146
|
+
if (auth == null || isLoading)
|
|
147
|
+
return;
|
|
148
|
+
if (!auth.ok || auth.authorized !== true) {
|
|
149
|
+
setShouldAutoLocateAfterAuth(false);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
setShouldAutoLocateAfterAuth(false);
|
|
153
|
+
runLocationSearch();
|
|
154
|
+
}, [auth, isLoading, isOpen, runLocationSearch, shouldAutoLocateAfterAuth, type]);
|
|
140
155
|
const typeState = useMemo(() => {
|
|
141
156
|
if (type == null)
|
|
142
157
|
return 'typeMissing';
|
|
@@ -144,36 +159,88 @@ export function JotulWidget({ type, endpoint = '/api/jotul/widget', className, }
|
|
|
144
159
|
return 'typeInvalid';
|
|
145
160
|
return 'typeReady';
|
|
146
161
|
}, [type]);
|
|
147
|
-
|
|
148
|
-
|
|
162
|
+
const widgetType = isWidgetType(type) ? type : undefined;
|
|
163
|
+
const shellClass = 'jwi-box-border jwi-flex jwi-w-[540px] jwi-max-w-full jwi-flex-col jwi-font-sans jwi-text-[#111111]';
|
|
164
|
+
const rootClass = className != null && className !== '' ? `${shellClass} ${className}` : shellClass;
|
|
165
|
+
if (typeState !== 'typeReady') {
|
|
166
|
+
return _jsx("div", { className: rootClass, children: t.invalidWidgetTypeError });
|
|
167
|
+
}
|
|
168
|
+
if (widgetType === 'productPage' && !isOpen) {
|
|
169
|
+
return (_jsx("div", { className: rootClass, children: _jsx("div", { className: "jwi-flex jwi-py-8 jwi-items-center jwi-justify-center", children: _jsx("button", { type: "button", onClick: () => {
|
|
170
|
+
setLocationError(null);
|
|
171
|
+
setSearchResult(null);
|
|
172
|
+
setLocationQuery('');
|
|
173
|
+
setLocationSuggestions([]);
|
|
174
|
+
setIsSuggestionListOpen(false);
|
|
175
|
+
setIsOpen(true);
|
|
176
|
+
if (auth?.ok && auth.authorized === true && !isLoading) {
|
|
177
|
+
setShouldAutoLocateAfterAuth(false);
|
|
178
|
+
runLocationSearch();
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
setShouldAutoLocateAfterAuth(true);
|
|
182
|
+
}
|
|
183
|
+
}, className: FIND_DEALER_BUTTON_CLASS, children: t.findDealer }) }) }));
|
|
184
|
+
}
|
|
185
|
+
const productPageAuthPending = widgetType === 'productPage' && isOpen && (auth === null || isLoading);
|
|
186
|
+
if (productPageAuthPending) {
|
|
187
|
+
return (_jsx("div", { className: rootClass, children: _jsx("div", { className: "jwi-flex jwi-py-8 jwi-items-center jwi-justify-center", children: _jsx("button", { type: "button", disabled: true, className: `${FIND_DEALER_BUTTON_CLASS} jwi-opacity-95`, children: _jsxs("span", { className: "jwi-inline-flex jwi-items-center jwi-gap-2", children: [_jsx(ButtonSpinner, {}), t.loading] }) }) }) }));
|
|
188
|
+
}
|
|
189
|
+
const waitingForAuth = auth === null && !(widgetType === 'productPage' && !isOpen);
|
|
190
|
+
if ((isLoading || waitingForAuth) && type !== 'productPage') {
|
|
191
|
+
return (_jsx("div", { className: rootClass, children: _jsx("div", { className: "jwi-flex jwi-w-full jwi-flex-col", children: _jsx(FinderSearchRowSkeleton, {}) }) }));
|
|
149
192
|
}
|
|
150
193
|
if (!auth?.ok || !auth.authorized) {
|
|
151
|
-
return (
|
|
152
|
-
}
|
|
153
|
-
if (
|
|
154
|
-
return _jsx("div", { className:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
194
|
+
return _jsx("div", { className: rootClass, children: getSafeWidgetErrorMessage(auth?.error, t) });
|
|
195
|
+
}
|
|
196
|
+
if (widgetType === 'productPage') {
|
|
197
|
+
return (_jsx("div", { className: rootClass, children: _jsx(ProductPageWidget, { t: t, isSearching: isSearching, locationError: locationError, searchResult: searchResult?.ok === false
|
|
198
|
+
? { ...searchResult, error: getSafeWidgetErrorMessage(searchResult.error, t) }
|
|
199
|
+
: searchResult, inquiryValues: inquiryValues, inquiryError: inquiryError, isInquirySubmitted: isInquirySubmitted, selectedDealerName: selectedDealerName, isManualSearchEnabled: isManualLocationSearchEnabled, query: locationQuery, suggestions: locationSuggestions, suggestionsOpen: isSuggestionListOpen, isSuggestionsLoading: isSearchingSuggestions, onQueryChange: (value) => {
|
|
200
|
+
setLocationQuery(value);
|
|
201
|
+
const trimmed = value.trim();
|
|
202
|
+
setIsSuggestionListOpen(trimmed.length > 0);
|
|
203
|
+
if (trimmed.length < 3) {
|
|
204
|
+
setLocationSuggestions([]);
|
|
205
|
+
}
|
|
206
|
+
}, onSuggestionSelect: (suggestion) => {
|
|
207
|
+
setLocationQuery(suggestion.label);
|
|
208
|
+
setLocationSuggestions([]);
|
|
209
|
+
setIsSearchingSuggestions(false);
|
|
210
|
+
setIsSuggestionListOpen(false);
|
|
211
|
+
void runDealerSearchByCoordinates(suggestion.latitude, suggestion.longitude);
|
|
212
|
+
}, onDismissSuggestions: () => {
|
|
213
|
+
setLocationSuggestions([]);
|
|
214
|
+
setIsSearchingSuggestions(false);
|
|
215
|
+
setIsSuggestionListOpen(false);
|
|
216
|
+
}, onInquiryClose: () => {
|
|
217
|
+
setSelectedDealerName(null);
|
|
218
|
+
setInquiryValues(null);
|
|
219
|
+
setInquiryError(null);
|
|
220
|
+
}, onInquirySubmit: () => {
|
|
221
|
+
if (inquiryValues == null)
|
|
222
|
+
return;
|
|
223
|
+
const trimmedName = inquiryValues.name.trim();
|
|
224
|
+
const trimmedEmail = inquiryValues.email.trim();
|
|
225
|
+
const trimmedPhone = inquiryValues.phone.trim();
|
|
226
|
+
if (!trimmedName || !trimmedEmail || !trimmedPhone) {
|
|
227
|
+
setInquiryError(t.formValidationRequired);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!isValidEmail(trimmedEmail)) {
|
|
231
|
+
setInquiryError(t.formValidationEmail);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
setInquiryError(null);
|
|
235
|
+
setIsInquirySubmitted(true);
|
|
236
|
+
setSelectedDealerName(null);
|
|
237
|
+
setInquiryValues(null);
|
|
238
|
+
}, onInquiryFieldChange: (key, value) => setInquiryValues((current) => current == null ? current : { ...current, [key]: value }), onStartInquiry: (dealerName) => {
|
|
239
|
+
setSelectedDealerName(dealerName);
|
|
240
|
+
setInquiryValues(createInquiryFormValues(productName, dealerName));
|
|
241
|
+
setInquiryError(null);
|
|
242
|
+
setIsInquirySubmitted(false);
|
|
243
|
+
} }) }));
|
|
244
|
+
}
|
|
245
|
+
return _jsx("div", { className: rootClass, children: renderReadyState(widgetType, t) });
|
|
179
246
|
}
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CheckWidgetAuthorizationOptions, DealerSearchResponse, LocationAutocompleteResponse, WidgetAuthClientResponse } from './types';
|
|
2
|
+
/** Client-side default when JSON parse fails (English; localized in UI). */
|
|
3
|
+
export declare const GENERIC_WIDGET_ERROR = "Dealer finder is currently unavailable. Please try again later.";
|
|
4
|
+
export declare function checkWidgetAuthorization(options?: CheckWidgetAuthorizationOptions): Promise<WidgetAuthClientResponse>;
|
|
5
|
+
export declare function searchDealersByPostalCode(postalCode: string, options?: CheckWidgetAuthorizationOptions): Promise<DealerSearchResponse>;
|
|
6
|
+
export declare function searchDealersByCoordinates(latitude: number, longitude: number, options?: CheckWidgetAuthorizationOptions): Promise<DealerSearchResponse>;
|
|
7
|
+
export declare function searchLocationSuggestions(query: string, options?: CheckWidgetAuthorizationOptions): Promise<LocationAutocompleteResponse>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Client-side default when JSON parse fails (English; localized in UI). */
|
|
2
|
+
export const GENERIC_WIDGET_ERROR = 'Dealer finder is currently unavailable. Please try again later.';
|
|
3
|
+
export async function checkWidgetAuthorization(options) {
|
|
4
|
+
const endpoint = options?.endpoint ?? '/api/jotul/widget';
|
|
5
|
+
const fetcher = options?.fetcher ?? fetch;
|
|
6
|
+
let response;
|
|
7
|
+
try {
|
|
8
|
+
response = await fetcher(endpoint, {
|
|
9
|
+
method: 'GET',
|
|
10
|
+
headers: { Accept: 'application/json' },
|
|
11
|
+
cache: 'no-store',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { ok: false, error: GENERIC_WIDGET_ERROR };
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return (await response.json());
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { ok: false, error: GENERIC_WIDGET_ERROR };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function searchDealersByPostalCode(postalCode, options) {
|
|
25
|
+
const endpoint = options?.endpoint ?? '/api/jotul/widget';
|
|
26
|
+
const fetcher = options?.fetcher ?? fetch;
|
|
27
|
+
const params = new URLSearchParams({ postalCode: postalCode.trim() });
|
|
28
|
+
if (options?.locale != null && options.locale.trim() !== '') {
|
|
29
|
+
params.set('locale', options.locale.trim());
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(options?.brands)) {
|
|
32
|
+
for (const brand of options.brands) {
|
|
33
|
+
const value = brand.trim();
|
|
34
|
+
if (value !== '')
|
|
35
|
+
params.append('brands', value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetcher(`${endpoint}?${params.toString()}`, {
|
|
41
|
+
method: 'GET',
|
|
42
|
+
headers: { Accept: 'application/json' },
|
|
43
|
+
cache: 'no-store',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { ok: false, error: GENERIC_WIDGET_ERROR };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return (await response.json());
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { ok: false, error: GENERIC_WIDGET_ERROR };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function searchDealersByCoordinates(latitude, longitude, options) {
|
|
57
|
+
const endpoint = options?.endpoint ?? '/api/jotul/widget';
|
|
58
|
+
const fetcher = options?.fetcher ?? fetch;
|
|
59
|
+
const params = new URLSearchParams({
|
|
60
|
+
latitude: String(latitude),
|
|
61
|
+
longitude: String(longitude),
|
|
62
|
+
});
|
|
63
|
+
if (options?.locale != null && options.locale.trim() !== '') {
|
|
64
|
+
params.set('locale', options.locale.trim());
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(options?.brands)) {
|
|
67
|
+
for (const brand of options.brands) {
|
|
68
|
+
const value = brand.trim();
|
|
69
|
+
if (value !== '')
|
|
70
|
+
params.append('brands', value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetcher(`${endpoint}?${params.toString()}`, {
|
|
76
|
+
method: 'GET',
|
|
77
|
+
headers: { Accept: 'application/json' },
|
|
78
|
+
cache: 'no-store',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { ok: false, error: GENERIC_WIDGET_ERROR };
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return (await response.json());
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { ok: false, error: GENERIC_WIDGET_ERROR };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export async function searchLocationSuggestions(query, options) {
|
|
92
|
+
const endpoint = options?.endpoint ?? '/api/jotul/widget';
|
|
93
|
+
const fetcher = options?.fetcher ?? fetch;
|
|
94
|
+
const params = new URLSearchParams({
|
|
95
|
+
autocomplete: '1',
|
|
96
|
+
q: query.trim(),
|
|
97
|
+
});
|
|
98
|
+
if (options?.locale != null && options.locale.trim() !== '') {
|
|
99
|
+
params.set('locale', options.locale.trim());
|
|
100
|
+
}
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetcher(`${endpoint}?${params.toString()}`, {
|
|
104
|
+
method: 'GET',
|
|
105
|
+
headers: { Accept: 'application/json' },
|
|
106
|
+
cache: 'no-store',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { ok: false, error: GENERIC_WIDGET_ERROR };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return (await response.json());
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { ok: false, error: GENERIC_WIDGET_ERROR };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { R10 } from '../constants';
|
|
3
|
+
/** Matches a dealer card: title + badge, two lines, CTA + phone row. */
|
|
4
|
+
export function DealerCardSkeleton() {
|
|
5
|
+
return (_jsxs("div", { className: `jwi-w-full jwi-animate-pulse ${R10} jwi-border jwi-border-[#e6e1d7] jwi-bg-white jwi-p-4 jwi-shadow-[0_1px_2px_rgba(17,17,17,0.03)]`, children: [_jsxs("div", { className: "jwi-flex jwi-items-start jwi-justify-between jwi-gap-3", children: [_jsx("div", { className: "jwi-h-5 jwi-max-w-[70%] jwi-flex-1 jwi-rounded-full jwi-bg-[#ece8df]" }), _jsx("div", { className: "jwi-h-6 jwi-w-14 jwi-shrink-0 jwi-rounded-full jwi-bg-[#ece8df]" })] }), _jsxs("div", { className: "jwi-mt-4 jwi-space-y-1.5", children: [_jsx("div", { className: "jwi-h-4 jwi-w-full jwi-rounded-full jwi-bg-[#ece8df]" }), _jsx("div", { className: "jwi-h-4 jwi-w-2/3 jwi-rounded-full jwi-bg-[#ece8df]" })] }), _jsxs("div", { className: "jwi-mt-4 jwi-flex jwi-flex-col jwi-gap-3 md:jwi-flex-row md:jwi-items-center md:jwi-justify-between", children: [_jsx("div", { className: `jwi-h-12 jwi-w-full jwi-max-w-[220px] ${R10} jwi-bg-[#ece8df]` }), _jsxs("div", { className: "jwi-flex jwi-min-w-0 jwi-items-center jwi-gap-2", children: [_jsx("div", { className: "jwi-h-3.5 jwi-w-3.5 jwi-shrink-0 jwi-rounded-full jwi-bg-[#ece8df]" }), _jsx("div", { className: "jwi-h-4 jwi-w-24 jwi-rounded-full jwi-bg-[#ece8df]" })] })] })] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { R10 } from '../constants';
|
|
3
|
+
/** Matches the finder chrome: bordered box, pin + hint + Find in one row. */
|
|
4
|
+
export function FinderSearchRowSkeleton() {
|
|
5
|
+
return (_jsx("div", { className: `jwi-w-full jwi-overflow-hidden ${R10} jwi-border jwi-border-[#d8d2c7] jwi-bg-white`, children: _jsxs("div", { className: "jwi-flex jwi-w-full jwi-min-w-0 jwi-flex-row jwi-items-stretch", children: [_jsxs("div", { className: "jwi-flex jwi-min-w-0 jwi-flex-1 jwi-items-center jwi-gap-3 jwi-py-4 jwi-pl-5 jwi-pr-3", children: [_jsx("div", { className: "jwi-h-4 jwi-w-4 jwi-shrink-0 jwi-animate-pulse jwi-rounded-full jwi-bg-[#ece8df]" }), _jsx("div", { className: "jwi-h-5 jwi-min-w-0 jwi-flex-1 jwi-animate-pulse jwi-rounded-full jwi-bg-[#ece8df]" })] }), _jsx("div", { className: "jwi-w-28 jwi-shrink-0 jwi-self-stretch jwi-animate-pulse jwi-bg-[#ece8df]" })] }) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type InquiryFieldProps = {
|
|
2
|
+
label: string;
|
|
3
|
+
value: string;
|
|
4
|
+
onChange: (value: string) => void;
|
|
5
|
+
type?: 'text' | 'email' | 'tel';
|
|
6
|
+
readOnly?: boolean;
|
|
7
|
+
multiline?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function InquiryField({ label, value, onChange, type, readOnly, multiline, }: InquiryFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { R10 } from '../constants';
|
|
3
|
+
export function InquiryField({ label, value, onChange, type = 'text', readOnly = false, multiline = false, }) {
|
|
4
|
+
const sharedClassName = `jwi-w-full ${R10} jwi-border jwi-border-[#d8d2c7] jwi-bg-white jwi-px-4 jwi-py-3 jwi-text-sm jwi-leading-[1.4] jwi-text-[#111111] jwi-outline-none placeholder:jwi-text-[#767676] focus:jwi-border-[#111111]`;
|
|
5
|
+
const readOnlyClassName = readOnly ? ' jwi-bg-[#f7f5ef]' : '';
|
|
6
|
+
return (_jsxs("label", { className: "jwi-flex jwi-flex-col jwi-gap-1.5", children: [_jsx("span", { className: "jwi-text-sm jwi-font-medium jwi-text-[#111111]", children: label }), multiline ? (_jsx("textarea", { value: value, onChange: (event) => onChange(event.target.value), readOnly: readOnly, rows: 4, className: `${sharedClassName}${readOnlyClassName} jwi-resize-y` })) : (_jsx("input", { type: type, value: value, onChange: (event) => onChange(event.target.value), readOnly: readOnly, className: `${sharedClassName}${readOnlyClassName}` }))] }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { WidgetStrings } from '../i18n/widgetStrings';
|
|
2
|
+
import type { DealerSearchResponse, InquiryFormValues, LocationSuggestion } from '../types';
|
|
3
|
+
type ProductPageWidgetProps = {
|
|
4
|
+
t: WidgetStrings;
|
|
5
|
+
isSearching: boolean;
|
|
6
|
+
locationError: string | null;
|
|
7
|
+
searchResult: DealerSearchResponse | null;
|
|
8
|
+
inquiryValues: InquiryFormValues | null;
|
|
9
|
+
inquiryError: string | null;
|
|
10
|
+
isInquirySubmitted: boolean;
|
|
11
|
+
selectedDealerName: string | null;
|
|
12
|
+
isManualSearchEnabled: boolean;
|
|
13
|
+
query: string;
|
|
14
|
+
suggestions: LocationSuggestion[];
|
|
15
|
+
suggestionsOpen: boolean;
|
|
16
|
+
isSuggestionsLoading: boolean;
|
|
17
|
+
onQueryChange: (value: string) => void;
|
|
18
|
+
onSuggestionSelect: (suggestion: LocationSuggestion) => void;
|
|
19
|
+
onDismissSuggestions: () => void;
|
|
20
|
+
onInquiryClose: () => void;
|
|
21
|
+
onInquirySubmit: () => void;
|
|
22
|
+
onInquiryFieldChange: (key: keyof InquiryFormValues, value: string) => void;
|
|
23
|
+
onStartInquiry: (dealerName: string) => void;
|
|
24
|
+
};
|
|
25
|
+
export declare function ProductPageWidget({ t, isSearching, locationError, searchResult, inquiryValues, inquiryError, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, }: ProductPageWidgetProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DealerCardSkeleton } from './DealerCardSkeleton';
|
|
3
|
+
import { DealerList } from './product-page/DealerList';
|
|
4
|
+
import { InquiryForm } from './product-page/InquiryForm';
|
|
5
|
+
import { LocationSearch } from './product-page/LocationSearch';
|
|
6
|
+
import { StatusBanner } from './product-page/StatusBanner';
|
|
7
|
+
import { R10 } from '../constants';
|
|
8
|
+
export function ProductPageWidget({ t, isSearching, locationError, searchResult, inquiryValues, inquiryError, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, }) {
|
|
9
|
+
const dealers = (searchResult?.dealers ?? []);
|
|
10
|
+
const total = searchResult?.total ?? dealers.length;
|
|
11
|
+
const inquiryFormOpen = inquiryValues != null;
|
|
12
|
+
if (inquiryFormOpen && searchResult?.ok) {
|
|
13
|
+
return (_jsx("div", { className: "jwi-flex jwi-w-full jwi-flex-col jwi-gap-3", children: _jsx(InquiryForm, { t: t, inquiryValues: inquiryValues, inquiryError: inquiryError, onInquiryClose: onInquiryClose, onInquirySubmit: onInquirySubmit, onInquiryFieldChange: onInquiryFieldChange }) }));
|
|
14
|
+
}
|
|
15
|
+
return (_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, 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 && (_jsx(DealerList, { dealers: dealers, total: total, selectedDealerName: selectedDealerName, t: t, onStartInquiry: onStartInquiry }))] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { WidgetStrings } from '../../i18n/widgetStrings';
|
|
2
|
+
import type { DealerRecord } from '../../types';
|
|
3
|
+
type DealerListProps = {
|
|
4
|
+
dealers: DealerRecord[];
|
|
5
|
+
total: number;
|
|
6
|
+
selectedDealerName: string | null;
|
|
7
|
+
t: WidgetStrings;
|
|
8
|
+
onStartInquiry: (dealerName: string) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function DealerList({ dealers, total, selectedDealerName, t, onStartInquiry, }: DealerListProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|