@jotul/jotul-widgets 1.2.6 → 2.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.
Files changed (60) hide show
  1. package/README.md +112 -28
  2. package/dist/JotulWidget.css +1 -1
  3. package/dist/JotulWidget.d.ts +1 -1
  4. package/dist/JotulWidget.js +356 -163
  5. package/dist/analytics/WidgetTrackingContext.d.ts +14 -0
  6. package/dist/analytics/WidgetTrackingContext.js +5 -0
  7. package/dist/analytics/gtm.d.ts +7 -0
  8. package/dist/analytics/gtm.js +17 -0
  9. package/dist/analytics/widgetTracking.d.ts +54 -0
  10. package/dist/analytics/widgetTracking.js +144 -0
  11. package/dist/api.d.ts +27 -1
  12. package/dist/api.js +74 -0
  13. package/dist/components/FindDealerDrawerWidget.d.ts +7 -4
  14. package/dist/components/FindDealerDrawerWidget.js +17 -14
  15. package/dist/components/InquiryField.d.ts +3 -1
  16. package/dist/components/InquiryField.js +19 -2
  17. package/dist/components/InquirySelectField.d.ts +13 -0
  18. package/dist/components/InquirySelectField.js +5 -0
  19. package/dist/components/ProductPageWidget.d.ts +7 -4
  20. package/dist/components/ProductPageWidget.js +12 -14
  21. package/dist/components/TurnstileField.d.ts +7 -0
  22. package/dist/components/TurnstileField.js +48 -0
  23. package/dist/components/WarrantyFormWidget.d.ts +12 -0
  24. package/dist/components/WarrantyFormWidget.js +98 -0
  25. package/dist/components/product-page/DealerList.d.ts +1 -1
  26. package/dist/components/product-page/DealerList.js +13 -5
  27. package/dist/components/product-page/InquiryForm.d.ts +6 -2
  28. package/dist/components/product-page/InquiryForm.js +21 -3
  29. package/dist/constants/turnstile.d.ts +8 -0
  30. package/dist/constants/turnstile.js +19 -0
  31. package/dist/hooks/useTurnstileSiteKey.d.ts +1 -0
  32. package/dist/hooks/useTurnstileSiteKey.js +38 -0
  33. package/dist/i18n/locales/cz.json +34 -1
  34. package/dist/i18n/locales/de.json +34 -1
  35. package/dist/i18n/locales/en.json +34 -1
  36. package/dist/i18n/locales/fi.json +34 -1
  37. package/dist/i18n/locales/fr.json +34 -1
  38. package/dist/i18n/locales/nl.json +34 -1
  39. package/dist/i18n/locales/no.json +34 -1
  40. package/dist/i18n/locales/pl.json +34 -1
  41. package/dist/i18n/locales/se.json +34 -1
  42. package/dist/i18n/widgetStrings.d.ts +33 -0
  43. package/dist/index.d.ts +4 -0
  44. package/dist/index.js +3 -0
  45. package/dist/turnstile.d.ts +18 -0
  46. package/dist/turnstile.js +31 -0
  47. package/dist/types.d.ts +56 -0
  48. package/dist/utils/inquiryCategories.d.ts +8 -0
  49. package/dist/utils/inquiryCategories.js +24 -0
  50. package/dist/utils/inquirySubmit.d.ts +24 -0
  51. package/dist/utils/inquirySubmit.js +35 -0
  52. package/dist/utils/urlDealerId.d.ts +2 -0
  53. package/dist/utils/urlDealerId.js +5 -0
  54. package/dist/utils/usMarket.d.ts +2 -0
  55. package/dist/utils/usMarket.js +9 -0
  56. package/dist/utils/warrantyForm.d.ts +38 -0
  57. package/dist/utils/warrantyForm.js +80 -0
  58. package/dist/utils.d.ts +10 -1
  59. package/dist/utils.js +46 -3
  60. package/package.json +5 -2
@@ -0,0 +1,14 @@
1
+ import type { WidgetTracker } from './widgetTracking';
2
+ export declare const WidgetTrackingContext: import("react").Context<{
3
+ resetSession(): void;
4
+ trackWidgetOpened(): void;
5
+ trackDealerSearch(searchTerm: string, resultCount: number): void;
6
+ trackDealerListView(resultCount: number): void;
7
+ trackDealerSelect(dealerId: string | undefined, dealerName: string): void;
8
+ trackDealerPhoneClick(dealerId: string | undefined, dealerName: string): void;
9
+ trackFormStart(dealerId: string | undefined, dealerName: string): void;
10
+ trackFormStepComplete(field: "name" | "email" | "phone" | "comment"): void;
11
+ trackFormError(error: import("./widgetTracking").InquiryValidationError): void;
12
+ trackSuccessfulSubmit(): void;
13
+ } | null>;
14
+ export declare function useWidgetTracking(): WidgetTracker | null;
@@ -0,0 +1,5 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const WidgetTrackingContext = createContext(null);
3
+ export function useWidgetTracking() {
4
+ return useContext(WidgetTrackingContext);
5
+ }
@@ -0,0 +1,7 @@
1
+ declare global {
2
+ interface Window {
3
+ dataLayer?: Array<Record<string, unknown>>;
4
+ }
5
+ }
6
+ export declare function ensureDataLayer(): Array<Record<string, unknown>>;
7
+ export declare function pushDataLayerEvent(event: string, payload: Record<string, unknown>): void;
@@ -0,0 +1,17 @@
1
+ export function ensureDataLayer() {
2
+ if (typeof window === 'undefined') {
3
+ return [];
4
+ }
5
+ window.dataLayer = window.dataLayer || [];
6
+ return window.dataLayer;
7
+ }
8
+ export function pushDataLayerEvent(event, payload) {
9
+ if (typeof window === 'undefined')
10
+ return;
11
+ ensureDataLayer();
12
+ const entry = {
13
+ event,
14
+ ...payload,
15
+ };
16
+ window.dataLayer.push(entry);
17
+ }
@@ -0,0 +1,54 @@
1
+ export type WidgetFormId = 'findDealerDrawer' | 'productPage';
2
+ export type WidgetStepName = 'dealer_list' | 'form' | 'confirmation';
3
+ export type WidgetTrackingPayload = {
4
+ contact_reason?: string;
5
+ dealer_id?: string;
6
+ dealer_name?: string;
7
+ error_field?: string;
8
+ error_type?: string;
9
+ form_id?: string;
10
+ language?: string;
11
+ market?: string;
12
+ page_type?: string;
13
+ product_id?: string;
14
+ product_name?: string;
15
+ result_count?: number;
16
+ search_term?: string;
17
+ step_name?: WidgetStepName;
18
+ step_number?: number;
19
+ };
20
+ export type WidgetTrackingConfig = {
21
+ formId: WidgetFormId;
22
+ language?: string;
23
+ market?: string;
24
+ pageType: string;
25
+ productId?: string;
26
+ productName?: string;
27
+ };
28
+ export type WidgetTrackingStepState = {
29
+ inquiryValues: unknown | null;
30
+ isInquirySubmitted: boolean;
31
+ selectedDealerId?: string | null;
32
+ selectedDealerName?: string | null;
33
+ };
34
+ export type InquiryValidationError = {
35
+ field: 'name' | 'email' | 'phone';
36
+ type: 'required' | 'invalid_email';
37
+ };
38
+ export declare function getWidgetStep(state: WidgetTrackingStepState): {
39
+ step_name: WidgetStepName;
40
+ step_number: number;
41
+ };
42
+ export declare function createWidgetTracker(config: WidgetTrackingConfig, getState: () => WidgetTrackingStepState): {
43
+ resetSession(): void;
44
+ trackWidgetOpened(): void;
45
+ trackDealerSearch(searchTerm: string, resultCount: number): void;
46
+ trackDealerListView(resultCount: number): void;
47
+ trackDealerSelect(dealerId: string | undefined, dealerName: string): void;
48
+ trackDealerPhoneClick(dealerId: string | undefined, dealerName: string): void;
49
+ trackFormStart(dealerId: string | undefined, dealerName: string): void;
50
+ trackFormStepComplete(field: "name" | "email" | "phone" | "comment"): void;
51
+ trackFormError(error: InquiryValidationError): void;
52
+ trackSuccessfulSubmit(): void;
53
+ };
54
+ export type WidgetTracker = ReturnType<typeof createWidgetTracker>;
@@ -0,0 +1,144 @@
1
+ import { pushDataLayerEvent } from './gtm';
2
+ export function getWidgetStep(state) {
3
+ if (state.isInquirySubmitted && state.inquiryValues == null) {
4
+ return { step_name: 'confirmation', step_number: 3 };
5
+ }
6
+ if (state.inquiryValues != null) {
7
+ return { step_name: 'form', step_number: 2 };
8
+ }
9
+ return { step_name: 'dealer_list', step_number: 1 };
10
+ }
11
+ function omitUndefined(payload) {
12
+ const out = {};
13
+ for (const [key, value] of Object.entries(payload)) {
14
+ if (value !== undefined && value !== null && value !== '') {
15
+ out[key] = value;
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+ export function createWidgetTracker(config, getState) {
21
+ const basePayload = (overrides) => {
22
+ const state = getState();
23
+ const step = getWidgetStep(state);
24
+ return {
25
+ form_id: config.formId,
26
+ language: config.language,
27
+ market: config.market,
28
+ page_type: config.pageType,
29
+ product_id: config.productId,
30
+ product_name: config.productName,
31
+ dealer_id: state.selectedDealerId ?? undefined,
32
+ dealer_name: state.selectedDealerName ?? undefined,
33
+ step_name: step.step_name,
34
+ step_number: step.step_number,
35
+ ...overrides,
36
+ };
37
+ };
38
+ const push = (event, overrides) => {
39
+ pushDataLayerEvent(event, omitUndefined(basePayload(overrides)));
40
+ };
41
+ let lastDealerSearchKey = null;
42
+ let dealerListViewTracked = false;
43
+ return {
44
+ resetSession() {
45
+ lastDealerSearchKey = null;
46
+ dealerListViewTracked = false;
47
+ },
48
+ trackWidgetOpened() {
49
+ push('widget_opened', {
50
+ step_name: 'dealer_list',
51
+ step_number: 1,
52
+ });
53
+ },
54
+ trackDealerSearch(searchTerm, resultCount) {
55
+ const normalizedTerm = searchTerm.trim();
56
+ const dedupeKey = `${normalizedTerm}|${resultCount}`;
57
+ if (lastDealerSearchKey === dedupeKey)
58
+ return;
59
+ lastDealerSearchKey = dedupeKey;
60
+ push('dealer_search', {
61
+ search_term: normalizedTerm || undefined,
62
+ result_count: resultCount,
63
+ step_name: 'dealer_list',
64
+ step_number: 1,
65
+ });
66
+ },
67
+ trackDealerListView(resultCount) {
68
+ if (dealerListViewTracked)
69
+ return;
70
+ dealerListViewTracked = true;
71
+ push('dealer_list_view', {
72
+ result_count: resultCount,
73
+ step_name: 'dealer_list',
74
+ step_number: 1,
75
+ });
76
+ },
77
+ trackDealerSelect(dealerId, dealerName) {
78
+ push('dealer_select', {
79
+ dealer_id: dealerId,
80
+ dealer_name: dealerName,
81
+ step_name: 'dealer_list',
82
+ step_number: 1,
83
+ });
84
+ },
85
+ trackDealerPhoneClick(dealerId, dealerName) {
86
+ push('dealer_phone_click', {
87
+ dealer_id: dealerId,
88
+ dealer_name: dealerName,
89
+ step_name: 'dealer_list',
90
+ step_number: 1,
91
+ });
92
+ },
93
+ trackFormStart(dealerId, dealerName) {
94
+ push('form_start', {
95
+ dealer_id: dealerId,
96
+ dealer_name: dealerName,
97
+ step_name: 'form',
98
+ step_number: 2,
99
+ });
100
+ if (config.formId === 'findDealerDrawer') {
101
+ push('started_dealer_request', {
102
+ dealer_id: dealerId,
103
+ dealer_name: dealerName,
104
+ step_name: 'form',
105
+ step_number: 2,
106
+ });
107
+ }
108
+ else {
109
+ push('started_product_inquiry', {
110
+ dealer_id: dealerId,
111
+ dealer_name: dealerName,
112
+ step_name: 'form',
113
+ step_number: 2,
114
+ });
115
+ }
116
+ },
117
+ trackFormStepComplete(field) {
118
+ pushDataLayerEvent('form_step_complete', {
119
+ ...omitUndefined(basePayload({
120
+ step_name: 'form',
121
+ step_number: 2,
122
+ })),
123
+ field,
124
+ });
125
+ },
126
+ trackFormError(error) {
127
+ push('form_error', {
128
+ error_field: error.field,
129
+ error_type: error.type,
130
+ step_name: 'form',
131
+ step_number: 2,
132
+ });
133
+ },
134
+ trackSuccessfulSubmit() {
135
+ const contactReason = config.formId === 'findDealerDrawer' ? 'dealer_request' : 'product_inquiry';
136
+ const eventName = config.formId === 'findDealerDrawer' ? 'dealer_request' : 'product_inquiry';
137
+ push(eventName, {
138
+ contact_reason: contactReason,
139
+ step_name: 'confirmation',
140
+ step_number: 3,
141
+ });
142
+ },
143
+ };
144
+ }
package/dist/api.d.ts CHANGED
@@ -1,7 +1,33 @@
1
- import type { CheckWidgetAuthorizationOptions, DealerSearchResponse, LocationAutocompleteResponse, WidgetAuthClientResponse } from './types';
1
+ import type { CheckWidgetAuthorizationOptions, DealerByIdResponse, DealerSearchResponse, InquirySubmitResponse, LocationAutocompleteResponse, WarrantySubmitResponse, WidgetAuthClientResponse } from './types';
2
+ import type { InquirySubmitPayload } from './utils/inquirySubmit';
2
3
  /** Client-side default when JSON parse fails (English; localized in UI). */
3
4
  export declare const GENERIC_WIDGET_ERROR = "Dealer finder is currently unavailable. Please try again later.";
4
5
  export declare function checkWidgetAuthorization(options?: CheckWidgetAuthorizationOptions): Promise<WidgetAuthClientResponse>;
5
6
  export declare function searchDealersByPostalCode(postalCode: string, options?: CheckWidgetAuthorizationOptions, scope?: 'list' | 'map'): Promise<DealerSearchResponse>;
6
7
  export declare function searchDealersByCoordinates(latitude: number, longitude: number, options?: CheckWidgetAuthorizationOptions, scope?: 'list' | 'map'): Promise<DealerSearchResponse>;
8
+ export declare function fetchDealerById(dealerId: string, options?: CheckWidgetAuthorizationOptions): Promise<DealerByIdResponse>;
7
9
  export declare function searchLocationSuggestions(query: string, options?: CheckWidgetAuthorizationOptions): Promise<LocationAutocompleteResponse>;
10
+ export type WarrantySubmitPayload = {
11
+ name: string;
12
+ address: string;
13
+ city: string;
14
+ zipcode: string;
15
+ telephone: string;
16
+ email: string;
17
+ product: string;
18
+ dealer?: string;
19
+ purchaseDate?: string;
20
+ selfInstalled?: boolean;
21
+ installerName?: string;
22
+ source?: 'widget';
23
+ turnstileToken?: string;
24
+ };
25
+ export type { InquirySubmitPayload } from './utils/inquirySubmit';
26
+ export declare function submitInquiry(body: InquirySubmitPayload, options?: {
27
+ endpoint?: string;
28
+ fetcher?: typeof fetch;
29
+ }): Promise<InquirySubmitResponse>;
30
+ export declare function submitWarranty(body: WarrantySubmitPayload, options?: {
31
+ endpoint?: string;
32
+ fetcher?: typeof fetch;
33
+ }): Promise<WarrantySubmitResponse>;
package/dist/api.js CHANGED
@@ -113,6 +113,32 @@ export async function searchDealersByCoordinates(latitude, longitude, options, s
113
113
  return { ok: false, error: GENERIC_WIDGET_ERROR };
114
114
  }
115
115
  }
116
+ export async function fetchDealerById(dealerId, options) {
117
+ const endpoint = options?.endpoint ?? '/api/jotul/widget';
118
+ const fetcher = options?.fetcher ?? fetch;
119
+ const params = new URLSearchParams({
120
+ type: 'dealer',
121
+ dealerId: dealerId.trim(),
122
+ });
123
+ appendLocaleAndMarket(params, options);
124
+ let response;
125
+ try {
126
+ response = await fetcher(`${endpoint}?${params.toString()}`, {
127
+ method: 'GET',
128
+ headers: { Accept: 'application/json' },
129
+ cache: 'no-store',
130
+ });
131
+ }
132
+ catch {
133
+ return { ok: false, error: GENERIC_WIDGET_ERROR };
134
+ }
135
+ try {
136
+ return (await response.json());
137
+ }
138
+ catch {
139
+ return { ok: false, error: GENERIC_WIDGET_ERROR };
140
+ }
141
+ }
116
142
  export async function searchLocationSuggestions(query, options) {
117
143
  const endpoint = options?.endpoint ?? '/api/jotul/widget';
118
144
  const fetcher = options?.fetcher ?? fetch;
@@ -142,3 +168,51 @@ export async function searchLocationSuggestions(query, options) {
142
168
  return { ok: false, error: GENERIC_WIDGET_ERROR };
143
169
  }
144
170
  }
171
+ export async function submitInquiry(body, options) {
172
+ const endpoint = options?.endpoint ?? '/api/jotul/submission';
173
+ const fetcher = options?.fetcher ?? fetch;
174
+ let response;
175
+ try {
176
+ response = await fetcher(endpoint, {
177
+ method: 'POST',
178
+ headers: {
179
+ Accept: 'application/json',
180
+ 'Content-Type': 'application/json',
181
+ },
182
+ body: JSON.stringify(body),
183
+ });
184
+ }
185
+ catch {
186
+ return { ok: false, error: GENERIC_WIDGET_ERROR };
187
+ }
188
+ try {
189
+ return (await response.json());
190
+ }
191
+ catch {
192
+ return { ok: false, error: GENERIC_WIDGET_ERROR };
193
+ }
194
+ }
195
+ export async function submitWarranty(body, options) {
196
+ const endpoint = options?.endpoint ?? '/api/jotul/warranty';
197
+ const fetcher = options?.fetcher ?? fetch;
198
+ let response;
199
+ try {
200
+ response = await fetcher(endpoint, {
201
+ method: 'POST',
202
+ headers: {
203
+ Accept: 'application/json',
204
+ 'Content-Type': 'application/json',
205
+ },
206
+ body: JSON.stringify(body),
207
+ });
208
+ }
209
+ catch {
210
+ return { ok: false, error: GENERIC_WIDGET_ERROR };
211
+ }
212
+ try {
213
+ return (await response.json());
214
+ }
215
+ catch {
216
+ return { ok: false, error: GENERIC_WIDGET_ERROR };
217
+ }
218
+ }
@@ -1,7 +1,7 @@
1
1
  import 'leaflet/dist/leaflet.css';
2
2
  import 'leaflet.markercluster/dist/MarkerCluster.css';
3
3
  import type { WidgetStrings } from '../i18n/widgetStrings';
4
- import type { DealerSearchResponse, InquiryFormValues, JotulWidgetBorderStyling, JotulWidgetButtonStyling, JotulWidgetScope, LocationSuggestion } from '../types';
4
+ import type { DealerRecord, DealerSearchResponse, InquiryFormValues, JotulWidgetBorderStyling, JotulWidgetButtonStyling, JotulWidgetScope, LocationSuggestion } from '../types';
5
5
  export type FindDealerDrawerWidgetProps = {
6
6
  t: WidgetStrings;
7
7
  buttonStyling?: JotulWidgetButtonStyling;
@@ -14,6 +14,9 @@ export type FindDealerDrawerWidgetProps = {
14
14
  mapSearchResult?: DealerSearchResponse | null;
15
15
  inquiryValues: InquiryFormValues | null;
16
16
  inquiryError: string | null;
17
+ isSubmittingInquiry?: boolean;
18
+ turnstileSiteKey?: string;
19
+ turnstileResetKey?: number;
17
20
  isInquirySubmitted: boolean;
18
21
  selectedDealerName: string | null;
19
22
  isManualSearchEnabled: boolean;
@@ -26,9 +29,9 @@ export type FindDealerDrawerWidgetProps = {
26
29
  onSuggestionSelect: (suggestion: LocationSuggestion) => void;
27
30
  onDismissSuggestions: () => void;
28
31
  onInquiryClose: () => void;
29
- onInquirySubmit: () => void;
32
+ onInquirySubmit: (turnstileToken: string | null) => void;
30
33
  onInquiryFieldChange: (key: keyof InquiryFormValues, value: string) => void;
31
- onStartInquiry: (dealerName: string) => void;
34
+ onStartInquiry: (dealer: DealerRecord) => void;
32
35
  onMapDealerSelect?: (dealer: {
33
36
  dealerName: string;
34
37
  latitude: number;
@@ -36,4 +39,4 @@ export type FindDealerDrawerWidgetProps = {
36
39
  }) => void;
37
40
  onClose: () => void;
38
41
  };
39
- export declare function FindDealerDrawerWidget({ t, buttonStyling, borderStyling, markets, scope, isSearching, locationError, searchResult, mapSearchResult, inquiryValues, inquiryError, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onQuerySubmit, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, onMapDealerSelect, onClose, }: FindDealerDrawerWidgetProps): import("react/jsx-runtime").JSX.Element;
42
+ export declare function FindDealerDrawerWidget({ t, buttonStyling, borderStyling, markets, scope, isSearching, locationError, searchResult, mapSearchResult, inquiryValues, inquiryError, isSubmittingInquiry, turnstileSiteKey, turnstileResetKey, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onQuerySubmit, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, onMapDealerSelect, onClose, }: FindDealerDrawerWidgetProps): import("react/jsx-runtime").JSX.Element;
@@ -18,7 +18,7 @@ import { MARKER_CLUSTER_LINK_KM, MARKER_CLUSTER_MIN_GROUP, partitionDealersForMa
18
18
  import { loadLeafletMarkerCluster } from '../utils/loadLeafletMarkerCluster';
19
19
  import { markerClusterCountIconHtml } from '../utils/markerClusterIconHtml';
20
20
  import { JOTUL_BRAND_PRIMARY_HEX } from '../constants';
21
- import { isExclusiveDealer } from '../utils';
21
+ import { isExclusiveDealer, getDealerName } from '../utils';
22
22
  const OSM_MINIMAL_TILE_URL = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png';
23
23
  const EMPTY_DEALERS = [];
24
24
  function mapPointsBoundsKey(points, defaultCenter) {
@@ -43,17 +43,18 @@ function readNumber(value) {
43
43
  }
44
44
  return null;
45
45
  }
46
- function getDealerName(dealer) {
47
- const raw = dealer.name;
48
- return typeof raw === 'string' && raw.trim() ? raw.trim() : 'Unknown dealer';
49
- }
50
- function getDealerMapPoint(dealer) {
46
+ function getDealerMapPoint(dealer, unknownDealerLabel) {
51
47
  const latitude = readNumber(dealer.latitude);
52
48
  const longitude = readNumber(dealer.longitude);
53
49
  if (latitude == null || longitude == null)
54
50
  return null;
55
51
  const isExclusive = isExclusiveDealer(dealer);
56
- return { dealerName: getDealerName(dealer), latitude, longitude, isExclusive };
52
+ return {
53
+ dealerName: getDealerName(dealer, unknownDealerLabel),
54
+ latitude,
55
+ longitude,
56
+ isExclusive,
57
+ };
57
58
  }
58
59
  function toAssetSrc(value) {
59
60
  return typeof value === 'string' ? value : value.src;
@@ -224,7 +225,7 @@ function ClusteredMapMarkers({ points, pointsBoundsKey, clusterThemeKey, cluster
224
225
  }, [map, pointsBoundsKey, clusterThemeKey]);
225
226
  return null;
226
227
  }
227
- export function FindDealerDrawerWidget({ t, buttonStyling, borderStyling, markets, scope, isSearching, locationError, searchResult, mapSearchResult, inquiryValues, inquiryError, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onQuerySubmit, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, onMapDealerSelect, onClose, }) {
228
+ export function FindDealerDrawerWidget({ t, buttonStyling, borderStyling, markets, scope, isSearching, locationError, searchResult, mapSearchResult, inquiryValues, inquiryError, isSubmittingInquiry = false, turnstileSiteKey, turnstileResetKey = 0, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onQuerySubmit, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, onMapDealerSelect, onClose, }) {
228
229
  const rawDealers = (searchResult?.dealers ?? EMPTY_DEALERS);
229
230
  const dealers = useMemo(() => (scope === 'ildstedet' ? rawDealers.filter(isExclusiveDealer) : rawDealers), [rawDealers, scope]);
230
231
  const rawMapDealers = (mapSearchResult?.dealers ?? rawDealers);
@@ -246,8 +247,8 @@ export function FindDealerDrawerWidget({ t, buttonStyling, borderStyling, market
246
247
  return () => media.removeEventListener('change', update);
247
248
  }, []);
248
249
  useEffect(() => {
249
- setActiveDealerName(dealers.length > 0 ? getDealerName(dealers[0]) : null);
250
- }, [dealers]);
250
+ setActiveDealerName(dealers.length > 0 ? getDealerName(dealers[0], t.unknownDealer) : null);
251
+ }, [dealers, t.unknownDealer]);
251
252
  useEffect(() => {
252
253
  setVisibleDealerCount(10);
253
254
  }, [dealers]);
@@ -255,13 +256,15 @@ export function FindDealerDrawerWidget({ t, buttonStyling, borderStyling, market
255
256
  useEffect(() => {
256
257
  if (activeDealerName == null)
257
258
  return;
258
- const activeIndex = dealers.findIndex((dealer) => getDealerName(dealer) === activeDealerName);
259
+ const activeIndex = dealers.findIndex((dealer) => getDealerName(dealer, t.unknownDealer) === activeDealerName);
259
260
  if (activeIndex < 0 || activeIndex < visibleDealerCount)
260
261
  return;
261
262
  const nextCount = Math.ceil((activeIndex + 1) / 10) * 10;
262
263
  setVisibleDealerCount(nextCount);
263
264
  }, [activeDealerName, dealers, visibleDealerCount]);
264
- const mapPoints = useMemo(() => mapDealers.map((dealer) => getDealerMapPoint(dealer)).filter((v) => v != null), [mapDealers]);
265
+ const mapPoints = useMemo(() => mapDealers
266
+ .map((dealer) => getDealerMapPoint(dealer, t.unknownDealer))
267
+ .filter((v) => v != null), [mapDealers, t.unknownDealer]);
265
268
  useEffect(() => {
266
269
  setClusterUnavailable(false);
267
270
  }, [mapPoints]);
@@ -289,7 +292,7 @@ export function FindDealerDrawerWidget({ t, buttonStyling, borderStyling, market
289
292
  const showInquirySuccessScreen = isInquirySubmitted && !inquiryFormOpen;
290
293
  const successPanel = (_jsx("div", { className: "jwi-flex jwi-w-full jwi-items-center jwi-justify-center jwi-bg-white jwi-p-2", children: _jsxs("div", { className: "jwi-flex jwi-w-full jwi-max-w-[520px] jwi-flex-col jwi-items-center jwi-gap-4 jwi-rounded-[10px] jwi-bg-white jwi-p-8 jwi-text-center", children: [_jsx("div", { className: "jwi-text-2xl jwi-font-semibold jwi-text-[#111111]", children: t.inquiryThankYouTitle }), _jsx("div", { className: "jwi-text-sm jwi-leading-6 jwi-text-[#333333]", children: t.inquirySentSuccess }), _jsx("button", { type: "button", onClick: onClose, className: "jwi-mt-2 jwi-inline-flex jwi-cursor-pointer jwi-items-center jwi-justify-center jwi-rounded-md jwi-border jwi-border-[#d8d2c7] jwi-bg-white jwi-px-5 jwi-py-2.5 jwi-text-sm jwi-font-medium jwi-text-[#111111]", children: t.goBack })] }) }));
291
294
  const canShowMore = visibleDealerCount < dealers.length;
292
- 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;
295
+ 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: t.showMore })) : null;
293
296
  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, markets: markets, activeDealerName: activeDealerName, onUnavailable: () => setClusterUnavailable(true), onSelectDealer: (dealer) => {
294
297
  setActiveDealerName(dealer.dealerName);
295
298
  onMapDealerSelect?.(dealer);
@@ -307,7 +310,7 @@ export function FindDealerDrawerWidget({ t, buttonStyling, borderStyling, market
307
310
  },
308
311
  }, children: _jsx(Tooltip, { children: point.dealerName }) }, `${point.dealerName}-${point.latitude}-${point.longitude}`));
309
312
  })] }));
310
- const leftContent = (_jsx(_Fragment, { children: showInquirySuccessScreen ? (successPanel) : inquiryFormOpen ? (_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 })), searchResult?.ok === false && (_jsx(StatusBanner, { tone: "error", children: searchResult.error ?? '' })), 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_2s_ease-in-out_infinite] jwi-rounded-full jwi-bg-[#ece8df]" }) }), _jsxs("div", { className: "jwi-mt-3 jwi-mr-[-12px] jwi-flex jwi-flex-col jwi-gap-4 jwi-overflow-y-auto jwi-pr-[12px]", children: [_jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {})] })] })), searchResult?.ok && !isSearching && (_jsxs(_Fragment, { children: [_jsx(DealerList, { dealers: visibleDealers, total: total, selectedDealerName: selectedDealerName, activeDealerName: activeDealerName, fitAvailableHeight: !isMobileViewport, autoScrollToActive: true, enableInternalScroll: !isMobileViewport, maxHeightClassName: "jwi-max-h-none", t: t, buttonStyling: buttonStyling, borderStyling: borderStyling, markets: markets, onStartInquiry: onStartInquiry, onSelectDealer: setActiveDealerName }), showMoreButton] }))] })) }));
313
+ const leftContent = (_jsx(_Fragment, { children: showInquirySuccessScreen ? (successPanel) : inquiryFormOpen ? (_jsx(InquiryForm, { t: t, buttonStyling: buttonStyling, inquiryValues: inquiryValues, inquiryError: inquiryError, isSubmitting: isSubmittingInquiry, turnstileSiteKey: turnstileSiteKey, turnstileResetKey: turnstileResetKey, 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 })), searchResult?.ok === false && (_jsx(StatusBanner, { tone: "error", children: searchResult.error ?? '' })), 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_2s_ease-in-out_infinite] jwi-rounded-full jwi-bg-[#ece8df]" }) }), _jsxs("div", { className: "jwi-mt-3 jwi-mr-[-12px] jwi-flex jwi-flex-col jwi-gap-4 jwi-overflow-y-auto jwi-pr-[12px]", children: [_jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {}), _jsx(DealerCardSkeleton, {})] })] })), searchResult?.ok && !isSearching && (_jsxs(_Fragment, { children: [_jsx(DealerList, { dealers: visibleDealers, total: total, selectedDealerName: selectedDealerName, activeDealerName: activeDealerName, fitAvailableHeight: !isMobileViewport, autoScrollToActive: true, enableInternalScroll: !isMobileViewport, maxHeightClassName: "jwi-max-h-none", t: t, buttonStyling: buttonStyling, borderStyling: borderStyling, markets: markets, onStartInquiry: onStartInquiry, onSelectDealer: setActiveDealerName }), showMoreButton] }))] })) }));
311
314
  if (isMobileViewport) {
312
315
  return (_jsxs("div", { className: "jwi-relative jwi-flex jwi-h-full jwi-flex-col jwi-bg-white", children: [_jsx("div", { className: "jwi-flex jwi-justify-end jwi-bg-white jwi-px-4 jwi-pt-3", children: _jsx("button", { type: "button", onClick: onClose, 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: leftContent }), !inquiryFormOpen && !showInquirySuccessScreen && !mobileMapExpanded && (_jsxs("button", { type: "button", onClick: () => setMobileMapExpanded(true), 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 && !inquiryFormOpen && !showInquirySuccessScreen && (_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 })] }))] }));
313
316
  }
@@ -1,3 +1,4 @@
1
+ import type { InquiryFormValues } from '../types';
1
2
  type InquiryFieldProps = {
2
3
  label: string;
3
4
  value: string;
@@ -5,6 +6,7 @@ type InquiryFieldProps = {
5
6
  type?: 'text' | 'email' | 'tel';
6
7
  readOnly?: boolean;
7
8
  multiline?: boolean;
9
+ fieldName?: keyof InquiryFormValues;
8
10
  };
9
- export declare function InquiryField({ label, value, onChange, type, readOnly, multiline, }: InquiryFieldProps): import("react/jsx-runtime").JSX.Element;
11
+ export declare function InquiryField({ label, value, onChange, type, readOnly, multiline, fieldName, }: InquiryFieldProps): import("react/jsx-runtime").JSX.Element;
10
12
  export {};
@@ -1,7 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { R10 } from '../constants';
3
- export function InquiryField({ label, value, onChange, type = 'text', readOnly = false, multiline = false, }) {
3
+ import { useRef } from 'react';
4
+ import { useWidgetTracking } from '../analytics/WidgetTrackingContext';
5
+ export function InquiryField({ label, value, onChange, type = 'text', readOnly = false, multiline = false, fieldName, }) {
6
+ const tracking = useWidgetTracking();
7
+ const completedRef = useRef(false);
4
8
  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
9
  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}` }))] }));
10
+ const handleBlur = () => {
11
+ if (readOnly || !fieldName || completedRef.current)
12
+ return;
13
+ if (!value.trim())
14
+ return;
15
+ completedRef.current = true;
16
+ if (fieldName === 'name' ||
17
+ fieldName === 'email' ||
18
+ fieldName === 'phone' ||
19
+ fieldName === 'comment') {
20
+ tracking?.trackFormStepComplete(fieldName);
21
+ }
22
+ };
23
+ 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), onBlur: handleBlur, readOnly: readOnly, rows: 4, className: `${sharedClassName}${readOnlyClassName} jwi-resize-y` })) : (_jsx("input", { type: type, value: value, onChange: (event) => onChange(event.target.value), onBlur: handleBlur, readOnly: readOnly, className: `${sharedClassName}${readOnlyClassName}` }))] }));
7
24
  }
@@ -0,0 +1,13 @@
1
+ import type { InquiryRequestCategory } from '../types';
2
+ type InquirySelectOption = {
3
+ value: InquiryRequestCategory;
4
+ label: string;
5
+ };
6
+ type InquirySelectFieldProps = {
7
+ label: string;
8
+ value: InquiryRequestCategory;
9
+ options: InquirySelectOption[];
10
+ onChange: (value: InquiryRequestCategory) => void;
11
+ };
12
+ export declare function InquirySelectField({ label, value, options, onChange, }: InquirySelectFieldProps): import("react/jsx-runtime").JSX.Element;
13
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { R10 } from '../constants';
3
+ export function InquirySelectField({ label, value, options, onChange, }) {
4
+ 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 }), _jsx("select", { value: value, onChange: (event) => onChange(event.target.value), className: `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 focus:jwi-border-[#111111]`, children: options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value))) })] }));
5
+ }
@@ -1,7 +1,7 @@
1
1
  import 'leaflet/dist/leaflet.css';
2
2
  import 'leaflet.markercluster/dist/MarkerCluster.css';
3
3
  import type { WidgetStrings } from '../i18n/widgetStrings';
4
- import type { DealerSearchResponse, InquiryFormValues, JotulWidgetBorderStyling, JotulWidgetButtonStyling, JotulWidgetScope, LocationSuggestion } from '../types';
4
+ import type { DealerRecord, DealerSearchResponse, InquiryFormValues, JotulWidgetBorderStyling, JotulWidgetButtonStyling, JotulWidgetScope, LocationSuggestion } from '../types';
5
5
  export type ProductPageWidgetProps = {
6
6
  t: WidgetStrings;
7
7
  buttonStyling?: JotulWidgetButtonStyling;
@@ -14,6 +14,9 @@ export type ProductPageWidgetProps = {
14
14
  mapSearchResult?: DealerSearchResponse | null;
15
15
  inquiryValues: InquiryFormValues | null;
16
16
  inquiryError: string | null;
17
+ isSubmittingInquiry?: boolean;
18
+ turnstileSiteKey?: string;
19
+ turnstileResetKey?: number;
17
20
  isInquirySubmitted: boolean;
18
21
  selectedDealerName: string | null;
19
22
  isManualSearchEnabled: boolean;
@@ -26,9 +29,9 @@ export type ProductPageWidgetProps = {
26
29
  onSuggestionSelect: (suggestion: LocationSuggestion) => void;
27
30
  onDismissSuggestions: () => void;
28
31
  onInquiryClose: () => void;
29
- onInquirySubmit: () => void;
32
+ onInquirySubmit: (turnstileToken: string | null) => void;
30
33
  onInquiryFieldChange: (key: keyof InquiryFormValues, value: string) => void;
31
- onStartInquiry: (dealerName: string) => void;
34
+ onStartInquiry: (dealer: DealerRecord) => void;
32
35
  onMapDealerSelect?: (dealer: {
33
36
  dealerName: string;
34
37
  latitude: number;
@@ -36,4 +39,4 @@ export type ProductPageWidgetProps = {
36
39
  }) => void;
37
40
  onClosePopup?: () => void;
38
41
  };
39
- export declare function ProductPageWidget({ t, buttonStyling, borderStyling, markets, scope, isSearching, locationError, searchResult, mapSearchResult, inquiryValues, inquiryError, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onQuerySubmit, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, onMapDealerSelect, onClosePopup, }: ProductPageWidgetProps): import("react/jsx-runtime").JSX.Element;
42
+ export declare function ProductPageWidget({ t, buttonStyling, borderStyling, markets, scope, isSearching, locationError, searchResult, mapSearchResult, inquiryValues, inquiryError, isSubmittingInquiry, turnstileSiteKey, turnstileResetKey, isInquirySubmitted, selectedDealerName, isManualSearchEnabled, query, suggestions, suggestionsOpen, isSuggestionsLoading, onQueryChange, onQuerySubmit, onSuggestionSelect, onDismissSuggestions, onInquiryClose, onInquirySubmit, onInquiryFieldChange, onStartInquiry, onMapDealerSelect, onClosePopup, }: ProductPageWidgetProps): import("react/jsx-runtime").JSX.Element;