@xapp/chat-widget 1.96.1 → 1.96.3
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/dist/index.css +1 -1
- package/dist/index.es.js +321 -27
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +321 -27
- package/dist/index.js.map +1 -1
- package/dist/xapp-chat-widget.css +1 -1
- package/dist/xapp-chat-widget.js +6 -4
- package/dist/xapp-chat-widget.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -68,6 +68,277 @@ function isActionBarPaymentButton(button) {
|
|
|
68
68
|
return button.type === "payment";
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
var buildChatWidgetChannel$1 = {};
|
|
72
|
+
|
|
73
|
+
var color = {};
|
|
74
|
+
|
|
75
|
+
/*! Copyright (c) 2026, XAPP AI */
|
|
76
|
+
Object.defineProperty(color, "__esModule", { value: true });
|
|
77
|
+
color.parseRgb = parseRgb;
|
|
78
|
+
color.invertColorHEX = invertColorHEX;
|
|
79
|
+
color.invertColorRgb = invertColorRgb;
|
|
80
|
+
color.invertColor = invertColor;
|
|
81
|
+
color.isSupportedColor = isSupportedColor;
|
|
82
|
+
color.readableTextOn = readableTextOn;
|
|
83
|
+
/**
|
|
84
|
+
* Colour maths for building a widget theme from a business's own branding.
|
|
85
|
+
*
|
|
86
|
+
* These lived in stentor-client, which meant the only way to work out a
|
|
87
|
+
* readable text colour was to be running in a browser. Studio needs the same
|
|
88
|
+
* answers when it builds an app server-side for a get.xapp.ai signup, and two
|
|
89
|
+
* implementations of "which colour reads on this background" would drift into
|
|
90
|
+
* two different-looking widgets.
|
|
91
|
+
*
|
|
92
|
+
* The inputs are colours scraped off a customer's website, so they are whatever
|
|
93
|
+
* their designer chose — any hex length, rgb or rgba, or nothing at all.
|
|
94
|
+
*/
|
|
95
|
+
/** Left-pads a hex component so "f" becomes "0f". */
|
|
96
|
+
function padZero(value, length = 2) {
|
|
97
|
+
const zeros = new Array(length).join("0");
|
|
98
|
+
return (zeros + value).slice(-length);
|
|
99
|
+
}
|
|
100
|
+
function parseRgb(colorString) {
|
|
101
|
+
const rgba = /^rgba?\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/;
|
|
102
|
+
const rgb = /^rgb?\((\d+),\s*(\d+),\s*(\d+)\)$/;
|
|
103
|
+
const withAlpha = colorString === null || colorString === void 0 ? void 0 : colorString.match(rgba);
|
|
104
|
+
if (withAlpha) {
|
|
105
|
+
return {
|
|
106
|
+
r: parseInt(withAlpha[1], 10),
|
|
107
|
+
g: parseInt(withAlpha[2], 10),
|
|
108
|
+
b: parseInt(withAlpha[3], 10),
|
|
109
|
+
a: parseFloat(withAlpha[4])
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const plain = colorString === null || colorString === void 0 ? void 0 : colorString.match(rgb);
|
|
113
|
+
if (plain) {
|
|
114
|
+
return { r: parseInt(plain[1], 10), g: parseInt(plain[2], 10), b: parseInt(plain[3], 10) };
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether text should be black or white to read on this background.
|
|
120
|
+
*
|
|
121
|
+
* The weighting is perceived brightness, not an average: the eye is far more
|
|
122
|
+
* sensitive to green than to blue, so a pure blue is dark to us even though its
|
|
123
|
+
* numeric average is mid-range.
|
|
124
|
+
*
|
|
125
|
+
* @see http://stackoverflow.com/a/3943023/112731
|
|
126
|
+
*/
|
|
127
|
+
function isLight(r, g, b) {
|
|
128
|
+
return r * 0.299 + g * 0.587 + b * 0.114 > 186;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Inverts a hex colour, or returns black/white for contrast when `bw` is set.
|
|
132
|
+
*
|
|
133
|
+
* Inverting is rarely what you want behind text — a mid-tone inverts to another
|
|
134
|
+
* mid-tone and neither reads. `bw` is the contrast answer.
|
|
135
|
+
*/
|
|
136
|
+
function invertColorHEX(hex, bw) {
|
|
137
|
+
if (!hex) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
let value = hex.indexOf("#") === 0 ? hex.slice(1) : hex;
|
|
141
|
+
if (value.length === 3) {
|
|
142
|
+
value = value[0] + value[0] + value[1] + value[1] + value[2] + value[2];
|
|
143
|
+
}
|
|
144
|
+
if (value.length !== 6) {
|
|
145
|
+
throw new Error("Invalid HEX color.");
|
|
146
|
+
}
|
|
147
|
+
const r = parseInt(value.slice(0, 2), 16);
|
|
148
|
+
const g = parseInt(value.slice(2, 4), 16);
|
|
149
|
+
const b = parseInt(value.slice(4, 6), 16);
|
|
150
|
+
if (bw) {
|
|
151
|
+
return isLight(r, g, b) ? "#000000" : "#FFFFFF";
|
|
152
|
+
}
|
|
153
|
+
return `#${padZero((255 - r).toString(16))}${padZero((255 - g).toString(16))}${padZero((255 - b).toString(16))}`;
|
|
154
|
+
}
|
|
155
|
+
/** As invertColorHEX, for rgb/rgba input. */
|
|
156
|
+
function invertColorRgb(rgb, bw) {
|
|
157
|
+
const parsed = parseRgb(rgb);
|
|
158
|
+
if (!parsed) {
|
|
159
|
+
return rgb;
|
|
160
|
+
}
|
|
161
|
+
const { r, g, b } = parsed;
|
|
162
|
+
if (bw) {
|
|
163
|
+
return isLight(r, g, b) ? "rgba(0, 0, 0, 1)" : "rgba(255, 255, 255, 1)";
|
|
164
|
+
}
|
|
165
|
+
return `rgba(${255 - r}, ${255 - g}, ${255 - b}, 1)`;
|
|
166
|
+
}
|
|
167
|
+
/** Inverts whichever colour notation was given. */
|
|
168
|
+
function invertColor(color, bw) {
|
|
169
|
+
if (!color) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
return color.startsWith("#") ? invertColorHEX(color, bw) : invertColorRgb(color, bw);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Whether we can work out what text reads on this colour.
|
|
176
|
+
*
|
|
177
|
+
* Hex and rgb/rgba only. A CSS named colour like `orange`, or `hsl(...)`, is
|
|
178
|
+
* perfectly valid CSS — we simply cannot compute its brightness, and a
|
|
179
|
+
* background whose contrast we cannot compute is one we must not use.
|
|
180
|
+
*/
|
|
181
|
+
function isSupportedColor(color) {
|
|
182
|
+
if (!color) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
if (color.startsWith("#")) {
|
|
186
|
+
const value = color.slice(1);
|
|
187
|
+
return (value.length === 3 || value.length === 6) && /^[0-9a-f]+$/i.test(value);
|
|
188
|
+
}
|
|
189
|
+
return parseRgb(color) !== null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The text colour that will read on this background: black or white, never
|
|
193
|
+
* anything else.
|
|
194
|
+
*
|
|
195
|
+
* That guarantee is the whole point. An earlier version leaned on invertColor,
|
|
196
|
+
* whose rgb path returns its input unchanged when it cannot parse it — so
|
|
197
|
+
* `readableTextOn("orange")` answered `"orange"`, and a header themed from a
|
|
198
|
+
* scraped named colour got text in exactly the background colour. Invisible,
|
|
199
|
+
* and by the one function meant to prevent it.
|
|
200
|
+
*
|
|
201
|
+
* Unparseable input falls back to black. That is a guess, so callers who
|
|
202
|
+
* control the background should check `isSupportedColor` first and use a
|
|
203
|
+
* colour whose contrast can actually be computed, rather than pairing a
|
|
204
|
+
* guessed foreground with an unknown background.
|
|
205
|
+
*/
|
|
206
|
+
function readableTextOn(background) {
|
|
207
|
+
if (!isSupportedColor(background)) {
|
|
208
|
+
return "#000000";
|
|
209
|
+
}
|
|
210
|
+
const rgb = background.startsWith("#") ? hexToRgb(background) : parseRgb(background);
|
|
211
|
+
if (!rgb) {
|
|
212
|
+
return "#000000";
|
|
213
|
+
}
|
|
214
|
+
return isLight(rgb.r, rgb.g, rgb.b) ? "#000000" : "#FFFFFF";
|
|
215
|
+
}
|
|
216
|
+
function hexToRgb(hex) {
|
|
217
|
+
let value = hex.slice(1);
|
|
218
|
+
if (value.length === 3) {
|
|
219
|
+
value = value[0] + value[0] + value[1] + value[1] + value[2] + value[2];
|
|
220
|
+
}
|
|
221
|
+
if (value.length !== 6) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
r: parseInt(value.slice(0, 2), 16),
|
|
226
|
+
g: parseInt(value.slice(2, 4), 16),
|
|
227
|
+
b: parseInt(value.slice(4, 6), 16)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
Object.defineProperty(buildChatWidgetChannel$1, "__esModule", { value: true });
|
|
232
|
+
buildChatWidgetChannel$1.buildChatWidgetChannel = buildChatWidgetChannel;
|
|
233
|
+
buildChatWidgetChannel$1.buildChatTheme = buildChatTheme;
|
|
234
|
+
const color_1 = color;
|
|
235
|
+
/** Fallback when a crawl found no branding. Deliberately neutral. */
|
|
236
|
+
const DEFAULT_PRIMARY = "#1AA7DC";
|
|
237
|
+
function buildChatWidgetChannel(props) {
|
|
238
|
+
const { appId, apiUrl, mcpUrl, formWidgetKey, phoneNumber } = props;
|
|
239
|
+
// Only a colour whose contrast can actually be computed, so the background
|
|
240
|
+
// and the text on it are always a matched pair rather than one scraped
|
|
241
|
+
// value and one guess.
|
|
242
|
+
const primaryColor = (0, color_1.isSupportedColor)(props.primaryColor) ? props.primaryColor : DEFAULT_PRIMARY;
|
|
243
|
+
return {
|
|
244
|
+
type: "chat-widget",
|
|
245
|
+
// MCP by default. The widget runs on a public page, so homeowner mode
|
|
246
|
+
// — which is unauthenticated and carries the appId in the url — is the
|
|
247
|
+
// only one that can work there; business_owner needs a token, and a
|
|
248
|
+
// token on a public page is not a token.
|
|
249
|
+
connection: {
|
|
250
|
+
type: "mcp",
|
|
251
|
+
serverUrl: `${mcpUrl}/${appId}`,
|
|
252
|
+
mcp: {
|
|
253
|
+
mode: "homeowner"
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
theme: buildChatTheme(primaryColor),
|
|
257
|
+
actionBar: buildActionBar({ apiUrl, formWidgetKey, phoneNumber })
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The dock of buttons that replaces the plain chat bubble.
|
|
262
|
+
*
|
|
263
|
+
* On by default, which is the part worth explaining: chat is not on the default
|
|
264
|
+
* tier, but the action bar is how someone reaches booking and the phone, and
|
|
265
|
+
* those are what the business is paying for.
|
|
266
|
+
*
|
|
267
|
+
* Order is deliberate. Booking is the thing we are selling, so it comes first;
|
|
268
|
+
* calling is the fallback for people who will not use a form; chat is last
|
|
269
|
+
* because it is the least likely to be enabled.
|
|
270
|
+
*/
|
|
271
|
+
function buildActionBar(props) {
|
|
272
|
+
const { apiUrl, formWidgetKey, phoneNumber } = props;
|
|
273
|
+
const buttons = [];
|
|
274
|
+
if (formWidgetKey) {
|
|
275
|
+
buttons.push({
|
|
276
|
+
id: "book",
|
|
277
|
+
type: "schedule",
|
|
278
|
+
label: "Book",
|
|
279
|
+
formConfig: {
|
|
280
|
+
api: {
|
|
281
|
+
url: apiUrl,
|
|
282
|
+
key: formWidgetKey
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (phoneNumber) {
|
|
288
|
+
buttons.push({
|
|
289
|
+
id: "call",
|
|
290
|
+
type: "phone",
|
|
291
|
+
label: "Call",
|
|
292
|
+
phoneNumber
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
buttons.push({
|
|
296
|
+
id: "chat",
|
|
297
|
+
type: "chat",
|
|
298
|
+
label: "Chat"
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
enabled: true,
|
|
302
|
+
position: "right",
|
|
303
|
+
buttons
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* A theme derived from the business's own colour.
|
|
308
|
+
*
|
|
309
|
+
* Only the text colour is computed rather than chosen: which of black or white
|
|
310
|
+
* reads on a given background is arithmetic, and getting it wrong ships a
|
|
311
|
+
* widget nobody can read.
|
|
312
|
+
*/
|
|
313
|
+
function buildChatTheme(primaryColor) {
|
|
314
|
+
const onPrimary = (0, color_1.readableTextOn)(primaryColor);
|
|
315
|
+
return {
|
|
316
|
+
zIndex: "10000",
|
|
317
|
+
border: {
|
|
318
|
+
color: primaryColor,
|
|
319
|
+
radius: "10px"
|
|
320
|
+
},
|
|
321
|
+
chatButton: {
|
|
322
|
+
background: primaryColor,
|
|
323
|
+
margin: {
|
|
324
|
+
bottom: "10px",
|
|
325
|
+
left: "10px",
|
|
326
|
+
right: "10px",
|
|
327
|
+
top: "10px"
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
content: {
|
|
331
|
+
background: "#ffffff"
|
|
332
|
+
},
|
|
333
|
+
header: {
|
|
334
|
+
background: primaryColor,
|
|
335
|
+
text: {
|
|
336
|
+
color: onPrimary
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
71
342
|
var ChannelData = {};
|
|
72
343
|
|
|
73
344
|
Object.defineProperty(ChannelData, "__esModule", { value: true });
|
|
@@ -133,7 +404,9 @@ function isOpenURLMenuItem(item) {
|
|
|
133
404
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
134
405
|
/*! Copyright (c) 2021, XAPPmedia */
|
|
135
406
|
__exportStar(ActionBar$1, exports);
|
|
407
|
+
__exportStar(buildChatWidgetChannel$1, exports);
|
|
136
408
|
__exportStar(ChannelData, exports);
|
|
409
|
+
__exportStar(color, exports);
|
|
137
410
|
__exportStar(Constants, exports);
|
|
138
411
|
__exportStar(Types, exports);
|
|
139
412
|
__exportStar(UserInfo, exports);
|
|
@@ -391,8 +664,19 @@ var ActionBarCta = function (_a) {
|
|
|
391
664
|
}, children: [require$$0.jsx("span", { className: "xapp-action-bar-cta__message", children: message }), dismissible && (require$$0.jsx("button", { className: "xapp-action-bar-cta__dismiss", onClick: handleDismiss, "aria-label": "Dismiss", type: "button", children: require$$0.jsx("svg", { width: "14", height: "14", viewBox: "0 0 14 14", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: require$$0.jsx("path", { d: "M13 1L1 13M1 1L13 13", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }) }))] }));
|
|
392
665
|
};
|
|
393
666
|
|
|
394
|
-
|
|
395
|
-
|
|
667
|
+
/**
|
|
668
|
+
* The same query the stylesheets use, as a string rather than a number.
|
|
669
|
+
*
|
|
670
|
+
* A bare width could not express this. A phone on its side is 740-863px wide, so any
|
|
671
|
+
* width-only threshold either excludes landscape phones or catches tablets - #1592 moved
|
|
672
|
+
* the stylesheets to `(max-width: 640px), (max-height: 480px) and (pointer: coarse)` for
|
|
673
|
+
* exactly that reason, and a JS constant of 640 or 768 cannot mirror it either way.
|
|
674
|
+
*
|
|
675
|
+
* Sharing the query text is what keeps the two halves from drifting: matchMedia evaluates
|
|
676
|
+
* the identical condition the CSS does, so there is one definition of "phone" and it lives
|
|
677
|
+
* in `_mixins.scss`. Keep this in step with the `mobile()` mixin. XappMedia/chat-widget#1594.
|
|
678
|
+
*/
|
|
679
|
+
var MOBILE_MEDIA_QUERY = "(max-width: 640px), (max-height: 480px) and (pointer: coarse)";
|
|
396
680
|
/**
|
|
397
681
|
* ChatWidget.scss sizes the chat panel against this variable. Nothing used to set it, so it always
|
|
398
682
|
* fell back to the hardcoded 80px in the SCSS - and the real mobile bar is taller than that once
|
|
@@ -405,33 +689,43 @@ var RESIZE_DEBOUNCE_MS = 150;
|
|
|
405
689
|
*/
|
|
406
690
|
var ACTION_BAR_HEIGHT_VAR = "--xapp-action-bar-height";
|
|
407
691
|
var ActionBar = function (_a) {
|
|
408
|
-
var _b, _c
|
|
409
|
-
var config = _a.config, visible = _a.visible, chatDisabled = _a.chatDisabled,
|
|
410
|
-
var
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
692
|
+
var _b, _c;
|
|
693
|
+
var config = _a.config, visible = _a.visible, chatDisabled = _a.chatDisabled, _d = _a.hasUserInteracted, hasUserInteracted = _d === void 0 ? false : _d, onChatClick = _a.onChatClick, onChatMinimize = _a.onChatMinimize, onFormClick = _a.onFormClick, onCtaDismiss = _a.onCtaDismiss;
|
|
694
|
+
var _e = React.useState(false), isMobile = _e[0], setIsMobile = _e[1];
|
|
695
|
+
// An explicit override can only be a width; without one we ask the same question the
|
|
696
|
+
// stylesheets ask, so the two cannot disagree. A survey of production on 2026-08-19
|
|
697
|
+
// found the override set by nobody - zero of 971 bundles, against 18 configured
|
|
698
|
+
// ActionBars - so in practice every bar takes the shared query.
|
|
699
|
+
// `!= null`, not a truthy check: 0 is a legitimate override meaning "never mobile", and
|
|
700
|
+
// treating it as unset would hand back the default responsive query - the opposite of
|
|
701
|
+
// what was asked for.
|
|
702
|
+
var mediaQuery = config.mobileBreakpoint != null
|
|
703
|
+
? "(max-width: ".concat(config.mobileBreakpoint, "px)")
|
|
704
|
+
: MOBILE_MEDIA_QUERY;
|
|
705
|
+
var position = (_b = config.position) !== null && _b !== void 0 ? _b : "right";
|
|
414
706
|
var barRef = React.useRef(null);
|
|
415
|
-
// Debounced resize handler
|
|
416
|
-
var handleResize = React.useCallback(function () {
|
|
417
|
-
if (resizeTimeoutRef.current) {
|
|
418
|
-
window.clearTimeout(resizeTimeoutRef.current);
|
|
419
|
-
}
|
|
420
|
-
resizeTimeoutRef.current = window.setTimeout(function () {
|
|
421
|
-
setIsMobile(window.innerWidth <= mobileBreakpoint);
|
|
422
|
-
}, RESIZE_DEBOUNCE_MS);
|
|
423
|
-
}, [mobileBreakpoint]);
|
|
424
707
|
React.useEffect(function () {
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
708
|
+
// matchMedia fires only when the answer actually changes, so there is nothing to
|
|
709
|
+
// debounce - unlike a resize listener, which fires continuously through a drag.
|
|
710
|
+
// Guarded because jsdom does not implement matchMedia, and a widget that throws on
|
|
711
|
+
// mount in a consumer's test suite is a worse failure than one that assumes desktop.
|
|
712
|
+
if (typeof window.matchMedia !== "function") {
|
|
713
|
+
return undefined;
|
|
714
|
+
}
|
|
715
|
+
var query = window.matchMedia(mediaQuery);
|
|
716
|
+
setIsMobile(query.matches);
|
|
717
|
+
var onChange = function (event) {
|
|
718
|
+
setIsMobile(event.matches);
|
|
433
719
|
};
|
|
434
|
-
|
|
720
|
+
// addListener is the pre-2021 Safari spelling. Still worth carrying: this widget
|
|
721
|
+
// runs on whatever browser a customer's visitor brings.
|
|
722
|
+
if (typeof query.addEventListener === "function") {
|
|
723
|
+
query.addEventListener("change", onChange);
|
|
724
|
+
return function () { return query.removeEventListener("change", onChange); };
|
|
725
|
+
}
|
|
726
|
+
query.addListener(onChange);
|
|
727
|
+
return function () { return query.removeListener(onChange); };
|
|
728
|
+
}, [mediaQuery]);
|
|
435
729
|
// Publish the bar's real height so the chat panel can size around it.
|
|
436
730
|
//
|
|
437
731
|
// useLayoutEffect, not useEffect: this commits before paint, so the panel never gets a
|
|
@@ -484,7 +778,7 @@ var ActionBar = function (_a) {
|
|
|
484
778
|
// - Chat must not be visible (user hasn't opened it)
|
|
485
779
|
// - Chat must not be disabled
|
|
486
780
|
var hasChatButton = config.buttons.some(lib.isActionBarChatButton);
|
|
487
|
-
var showCta = ((
|
|
781
|
+
var showCta = ((_c = config.cta) === null || _c === void 0 ? void 0 : _c.message) && !visible && !chatDisabled && hasChatButton;
|
|
488
782
|
var handleCtaClick = function () {
|
|
489
783
|
onChatClick();
|
|
490
784
|
};
|