livechat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +160 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/livechat/application_controller.rb +76 -0
  7. data/app/controllers/livechat/conversations_controller.rb +57 -0
  8. data/app/controllers/livechat/messages_controller.rb +21 -0
  9. data/app/controllers/livechat/visitor_controller.rb +108 -0
  10. data/app/controllers/livechat/widgets_controller.rb +36 -0
  11. data/app/helpers/livechat/widget_helper.rb +37 -0
  12. data/app/mailers/livechat/mailer.rb +51 -0
  13. data/app/models/livechat/application_record.rb +7 -0
  14. data/app/models/livechat/conversation.rb +108 -0
  15. data/app/models/livechat/message.rb +53 -0
  16. data/app/views/layouts/livechat/application.html.erb +97 -0
  17. data/app/views/livechat/conversations/index.html.erb +62 -0
  18. data/app/views/livechat/conversations/show.html.erb +73 -0
  19. data/app/views/livechat/mailer/new_agent_reply.text.erb +7 -0
  20. data/app/views/livechat/mailer/new_visitor_message.text.erb +7 -0
  21. data/config/locales/livechat.ar.yml +44 -0
  22. data/config/locales/livechat.bg.yml +44 -0
  23. data/config/locales/livechat.bn.yml +44 -0
  24. data/config/locales/livechat.de.yml +44 -0
  25. data/config/locales/livechat.el.yml +44 -0
  26. data/config/locales/livechat.en.yml +44 -0
  27. data/config/locales/livechat.es.yml +44 -0
  28. data/config/locales/livechat.fr.yml +44 -0
  29. data/config/locales/livechat.hi.yml +44 -0
  30. data/config/locales/livechat.hr.yml +44 -0
  31. data/config/locales/livechat.id.yml +44 -0
  32. data/config/locales/livechat.it.yml +44 -0
  33. data/config/locales/livechat.ja.yml +44 -0
  34. data/config/locales/livechat.ko.yml +44 -0
  35. data/config/locales/livechat.lb.yml +44 -0
  36. data/config/locales/livechat.nl.yml +44 -0
  37. data/config/locales/livechat.pl.yml +44 -0
  38. data/config/locales/livechat.pt.yml +44 -0
  39. data/config/locales/livechat.ro.yml +44 -0
  40. data/config/locales/livechat.ru.yml +44 -0
  41. data/config/locales/livechat.th.yml +44 -0
  42. data/config/locales/livechat.tr.yml +44 -0
  43. data/config/locales/livechat.uk.yml +44 -0
  44. data/config/locales/livechat.ur.yml +44 -0
  45. data/config/locales/livechat.vi.yml +44 -0
  46. data/config/locales/livechat.zh-CN.yml +44 -0
  47. data/config/routes.rb +27 -0
  48. data/lib/generators/livechat/install/install_generator.rb +42 -0
  49. data/lib/generators/livechat/install/templates/create_livechat_tables.rb.tt +36 -0
  50. data/lib/generators/livechat/install/templates/initializer.rb +59 -0
  51. data/lib/livechat/configuration.rb +103 -0
  52. data/lib/livechat/dashboard.js +58 -0
  53. data/lib/livechat/engine.rb +13 -0
  54. data/lib/livechat/notifications.rb +41 -0
  55. data/lib/livechat/version.rb +5 -0
  56. data/lib/livechat/widget.js +563 -0
  57. data/lib/livechat/widget.rb +115 -0
  58. data/lib/livechat.rb +56 -0
  59. metadata +125 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ # Fires the host hooks and the built-in emails after a message is saved.
5
+ # Email goes out only at the start of an unread stretch — the first message
6
+ # nobody has seen yet — so a burst of messages is one email, not ten.
7
+ # A failing hook must never break the chat, so hooks are rescued and logged.
8
+ module Notifications
9
+ class << self
10
+ def visitor_message(message)
11
+ run_hook { Livechat.config.on_visitor_message.call(message) }
12
+ return unless Livechat.config.emails_enabled?
13
+ return unless Livechat.config.agent_email_list.any?
14
+ return unless first_unread?(message, message.conversation.messages.from_visitor)
15
+
16
+ Mailer.new_visitor_message(message).deliver_later
17
+ end
18
+
19
+ def agent_message(message)
20
+ run_hook { Livechat.config.on_agent_message.call(message) }
21
+ return unless Livechat.config.emails_enabled?
22
+ return if message.conversation.visitor_email.blank?
23
+ return unless first_unread?(message, message.conversation.messages.from_agent)
24
+
25
+ Mailer.new_agent_reply(message).deliver_later
26
+ end
27
+
28
+ private
29
+
30
+ def first_unread?(message, side)
31
+ side.unread.where(id: ...message.id).none?
32
+ end
33
+
34
+ def run_hook
35
+ yield
36
+ rescue StandardError => e
37
+ Rails.logger.error("[livechat] notification hook failed: #{e.class}: #{e.message}")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,563 @@
1
+ /*
2
+ * livechat widget — self-contained, no framework, no build step.
3
+ *
4
+ * Reads its config from the <script type="application/json"
5
+ * data-livechat-config> the server renders — re-read on every render so a
6
+ * Turbo visit always reflects the current page's config.
7
+ *
8
+ * Transport is polling, on purpose: it needs nothing from the host app — no
9
+ * Action Cable client, no Turbo, no websocket route. ~4s while the panel is
10
+ * open, ~30s in the background for the launcher badge, and nothing at all
11
+ * for a guest who has never written (there is no thread to poll). Paused
12
+ * whenever the tab is hidden.
13
+ *
14
+ * Turbo Drive swaps the <body>, taking our DOM with it; render() re-mounts
15
+ * on every turbo:load and restores the open panel from sessionStorage.
16
+ */
17
+ (function () {
18
+ "use strict";
19
+
20
+ var config = readConfig();
21
+ if (!config || window.__livechatLoaded) return;
22
+ window.__livechatLoaded = true;
23
+
24
+ var Z = 2147482000;
25
+ var OPEN_POLL_MS = 4000;
26
+ var CLOSED_POLL_MS = 30000;
27
+
28
+ var root = null;
29
+ var listEl = null;
30
+ var badgeEl = null;
31
+ var formEl = null;
32
+ var inputEl = null;
33
+ var emailRowEl = null;
34
+ var errorEl = null;
35
+ var isOpen = false;
36
+ // The id of the newest message actually RENDERED into the list — never
37
+ // advanced by background polls, so reopening the panel refetches exactly
38
+ // the messages it hasn't shown yet.
39
+ var lastRenderedId = 0;
40
+ var lastAuthorKey = null; // grouping: show the author header only on change
41
+ var hasThread = false;
42
+ var hasEmail = false;
43
+ var sending = false;
44
+ var pollTimer = null;
45
+ var fetching = false;
46
+
47
+ function ready(fn) {
48
+ if (document.readyState === "loading") {
49
+ document.addEventListener("DOMContentLoaded", fn);
50
+ } else {
51
+ fn();
52
+ }
53
+ }
54
+
55
+ ready(function () {
56
+ document.addEventListener("keydown", function (event) {
57
+ if (event.key === "Escape" && isOpen) closePanel();
58
+ });
59
+ document.addEventListener("click", handleOpenerClick, true);
60
+ document.addEventListener("visibilitychange", function () {
61
+ if (!document.hidden) poll();
62
+ schedulePoll();
63
+ });
64
+
65
+ render();
66
+ document.addEventListener("turbo:load", render);
67
+
68
+ window.Livechat = { open: openPanel, close: closePanel };
69
+ });
70
+
71
+ function readConfig() {
72
+ var el = document.querySelector("script[data-livechat-config]");
73
+ if (!el) return null;
74
+ try {
75
+ return JSON.parse(el.textContent);
76
+ } catch (e) {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function render() {
82
+ config = readConfig() || config;
83
+ injectStyles();
84
+ mount();
85
+ applyBrandColor();
86
+
87
+ if (sessionGet("livechat_open") === "1") {
88
+ openPanel();
89
+ } else {
90
+ // One initial fetch decides everything: thread or not, badge count.
91
+ poll();
92
+ schedulePoll();
93
+ }
94
+ }
95
+
96
+ function handleOpenerClick(event) {
97
+ var opener = event.target && event.target.closest
98
+ ? event.target.closest("[data-livechat-open]")
99
+ : null;
100
+ if (!opener) return;
101
+ event.preventDefault();
102
+ event.stopPropagation();
103
+ openPanel();
104
+ }
105
+
106
+ // --- mounting ---------------------------------------------------------------
107
+
108
+ function mount() {
109
+ if (root && document.body.contains(root)) return;
110
+
111
+ isOpen = false;
112
+ lastRenderedId = 0;
113
+ lastAuthorKey = null;
114
+
115
+ root = document.createElement("div");
116
+ root.id = "lvc-root";
117
+ if (config.rtl) root.setAttribute("dir", "rtl");
118
+
119
+ if (config.launcher) root.appendChild(buildLauncher());
120
+ document.body.appendChild(root);
121
+ }
122
+
123
+ // config.accentColor rebrands the widget. Inline style properties on the
124
+ // root outrank the stylesheet (including its dark-mode override), so the
125
+ // brand color holds in both themes; text flips black/white for contrast.
126
+ function applyBrandColor() {
127
+ if (!root || !config.accentColor) return;
128
+ root.style.setProperty("--lvc-accent", config.accentColor);
129
+ root.style.setProperty("--lvc-accent-text", contrastText(config.accentColor));
130
+ }
131
+
132
+ function contrastText(color) {
133
+ var hex = String(color).replace(/^#/, "");
134
+ if (hex.length === 3) hex = hex.replace(/./g, function (c) { return c + c; });
135
+ if (!/^[0-9a-fA-F]{6}$/.test(hex)) return "#fff";
136
+ var luminance = 0.2126 * parseInt(hex.slice(0, 2), 16) +
137
+ 0.7152 * parseInt(hex.slice(2, 4), 16) +
138
+ 0.0722 * parseInt(hex.slice(4, 6), 16);
139
+ return luminance > 160 ? "#111418" : "#fff";
140
+ }
141
+
142
+ function buildLauncher() {
143
+ var button = document.createElement("button");
144
+ button.id = "lvc-launcher";
145
+ button.type = "button";
146
+ button.setAttribute("aria-label", config.labels.launcher);
147
+ button.title = config.labels.launcher;
148
+ button.innerHTML =
149
+ '<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">' +
150
+ '<path fill="currentColor" d="M12 3C6.5 3 2 6.9 2 11.7c0 2.7 1.4 5.1 3.7 6.7-.1' +
151
+ " 1-.6 2.2-1.6 3.2-.2.2 0 .5.2.5 1.9-.1 3.6-.9 4.7-1.7 1 .2 2 .3 3 .3 5.5 0 " +
152
+ '10-3.9 10-8.7S17.5 3 12 3z"/></svg>';
153
+
154
+ badgeEl = document.createElement("span");
155
+ badgeEl.id = "lvc-badge";
156
+ badgeEl.hidden = true;
157
+ badgeEl.setAttribute("aria-label", config.labels.unreadAria);
158
+ button.appendChild(badgeEl);
159
+
160
+ button.addEventListener("click", function () {
161
+ if (isOpen) closePanel();
162
+ else openPanel();
163
+ });
164
+ return button;
165
+ }
166
+
167
+ function buildPanel() {
168
+ var panel = document.createElement("div");
169
+ panel.id = "lvc-panel";
170
+ panel.setAttribute("role", "dialog");
171
+ panel.setAttribute("aria-label", config.appName);
172
+
173
+ var header = document.createElement("div");
174
+ header.id = "lvc-header";
175
+ var titles = document.createElement("div");
176
+ var title = document.createElement("strong");
177
+ title.textContent = config.appName;
178
+ var subtitle = document.createElement("span");
179
+ subtitle.textContent = config.labels.replyTime;
180
+ titles.appendChild(title);
181
+ titles.appendChild(subtitle);
182
+ header.appendChild(titles);
183
+
184
+ var close = document.createElement("button");
185
+ close.type = "button";
186
+ close.id = "lvc-close";
187
+ close.setAttribute("aria-label", config.labels.close);
188
+ close.innerHTML = "&times;";
189
+ close.addEventListener("click", closePanel);
190
+ header.appendChild(close);
191
+ panel.appendChild(header);
192
+
193
+ listEl = document.createElement("div");
194
+ listEl.id = "lvc-list";
195
+ listEl.setAttribute("role", "log");
196
+ listEl.setAttribute("aria-live", "polite");
197
+ var greeting = document.createElement("div");
198
+ greeting.className = "lvc-greeting";
199
+ greeting.textContent = config.labels.greeting;
200
+ listEl.appendChild(greeting);
201
+ panel.appendChild(listEl);
202
+
203
+ errorEl = document.createElement("div");
204
+ errorEl.id = "lvc-error";
205
+ errorEl.hidden = true;
206
+ panel.appendChild(errorEl);
207
+
208
+ emailRowEl = buildEmailRow();
209
+ panel.appendChild(emailRowEl);
210
+
211
+ formEl = document.createElement("form");
212
+ formEl.id = "lvc-form";
213
+ inputEl = document.createElement("textarea");
214
+ inputEl.rows = 1;
215
+ inputEl.placeholder = config.labels.placeholder;
216
+ inputEl.setAttribute("aria-label", config.labels.placeholder);
217
+ inputEl.addEventListener("input", autogrow);
218
+ inputEl.addEventListener("keydown", function (event) {
219
+ if (event.key === "Enter" && !event.shiftKey) {
220
+ event.preventDefault();
221
+ send();
222
+ }
223
+ });
224
+ var sendButton = document.createElement("button");
225
+ sendButton.type = "submit";
226
+ sendButton.setAttribute("aria-label", config.labels.send);
227
+ sendButton.title = config.labels.send;
228
+ // Paper airplane (Heroicons, MIT). CSS mirrors it for RTL.
229
+ sendButton.innerHTML =
230
+ '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">' +
231
+ '<path fill="currentColor" d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 ' +
232
+ "7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 " +
233
+ "60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 " +
234
+ '0 3.478 2.404Z"/></svg>';
235
+ formEl.appendChild(inputEl);
236
+ formEl.appendChild(sendButton);
237
+ formEl.addEventListener("submit", function (event) {
238
+ event.preventDefault();
239
+ send();
240
+ });
241
+ panel.appendChild(formEl);
242
+
243
+ return panel;
244
+ }
245
+
246
+ // Guests only: one quiet row under the thread asking for an email, shown
247
+ // once they have written and until they save one.
248
+ function buildEmailRow() {
249
+ var row = document.createElement("form");
250
+ row.id = "lvc-email";
251
+ row.hidden = true;
252
+
253
+ var label = document.createElement("span");
254
+ label.textContent = config.labels.emailPrompt;
255
+ var input = document.createElement("input");
256
+ input.type = "email";
257
+ input.required = true;
258
+ input.placeholder = config.labels.emailPlaceholder;
259
+ input.setAttribute("aria-label", config.labels.emailPlaceholder);
260
+ var save = document.createElement("button");
261
+ save.type = "submit";
262
+ save.textContent = config.labels.emailSave;
263
+
264
+ row.appendChild(label);
265
+ var controls = document.createElement("div");
266
+ controls.appendChild(input);
267
+ controls.appendChild(save);
268
+ row.appendChild(controls);
269
+
270
+ row.addEventListener("submit", function (event) {
271
+ event.preventDefault();
272
+ request("POST", "/email", { email: input.value }).then(function (response) {
273
+ if (response.ok) {
274
+ hasEmail = true;
275
+ label.textContent = config.labels.emailSaved;
276
+ controls.remove();
277
+ }
278
+ });
279
+ });
280
+ return row;
281
+ }
282
+
283
+ function autogrow() {
284
+ inputEl.style.height = "auto";
285
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + "px";
286
+ }
287
+
288
+ // --- open / close -----------------------------------------------------------
289
+
290
+ function openPanel() {
291
+ mount();
292
+ if (isOpen) return;
293
+ isOpen = true;
294
+ sessionSet("livechat_open", "1");
295
+
296
+ // The panel is built once and then hidden/shown — closing must never
297
+ // throw away the rendered thread or a half-written message.
298
+ var panel = document.getElementById("lvc-panel");
299
+ if (!panel) {
300
+ panel = buildPanel();
301
+ root.appendChild(panel);
302
+ }
303
+ panel.hidden = false;
304
+ setUnread(0);
305
+ poll();
306
+ schedulePoll();
307
+ if (inputEl) inputEl.focus();
308
+ }
309
+
310
+ function closePanel() {
311
+ isOpen = false;
312
+ sessionSet("livechat_open", "");
313
+ var panel = document.getElementById("lvc-panel");
314
+ if (panel) panel.hidden = true;
315
+ schedulePoll();
316
+ }
317
+
318
+ // --- polling ----------------------------------------------------------------
319
+
320
+ function schedulePoll() {
321
+ if (pollTimer) clearTimeout(pollTimer);
322
+ if (document.hidden) return;
323
+ // A guest who never wrote has no thread: nothing to poll for.
324
+ if (!isOpen && !hasThread && !config.authenticated) return;
325
+
326
+ pollTimer = setTimeout(function () {
327
+ poll();
328
+ schedulePoll();
329
+ }, isOpen ? OPEN_POLL_MS : CLOSED_POLL_MS);
330
+ }
331
+
332
+ function poll() {
333
+ if (fetching) return;
334
+ fetching = true;
335
+
336
+ request("GET", "/conversation?after=" + lastRenderedId)
337
+ .then(function (response) { return response.ok ? response.json() : null; })
338
+ .then(function (data) {
339
+ fetching = false;
340
+ if (!data) return;
341
+
342
+ hasThread = data.status !== null;
343
+ hasEmail = !!data.email;
344
+
345
+ if (isOpen) {
346
+ // Only an open panel renders (and thereby "consumes") messages;
347
+ // while closed, the poll feeds the badge and nothing else.
348
+ (data.messages || []).forEach(appendMessage);
349
+ if (data.unread > 0) request("POST", "/read");
350
+ setUnread(0);
351
+ syncEmailRow();
352
+ } else {
353
+ setUnread(data.unread || 0);
354
+ }
355
+ })
356
+ .catch(function () { fetching = false; });
357
+ }
358
+
359
+ function setUnread(count) {
360
+ if (!badgeEl) return;
361
+ badgeEl.hidden = !(count > 0);
362
+ badgeEl.textContent = count > 9 ? "9+" : String(count);
363
+ }
364
+
365
+ function syncEmailRow() {
366
+ if (!emailRowEl) return;
367
+ emailRowEl.hidden = !(hasThread && !hasEmail && !config.authenticated);
368
+ }
369
+
370
+ // --- messages ---------------------------------------------------------------
371
+
372
+ function appendMessage(message) {
373
+ if (!listEl || message.id <= lastRenderedId) return;
374
+ lastRenderedId = message.id;
375
+
376
+ if (message.author === "system") {
377
+ var line = document.createElement("div");
378
+ line.className = "lvc-system";
379
+ line.textContent = message.event === "resolved"
380
+ ? config.labels.eventResolved
381
+ : config.labels.eventReopened;
382
+ listEl.appendChild(line);
383
+ lastAuthorKey = null;
384
+ scrollToBottom();
385
+ return;
386
+ }
387
+
388
+ // The author header appears only when the sender changes — a run of
389
+ // messages from the same person reads as one block.
390
+ var authorKey = message.author + ":" + (message.label || "");
391
+ if (authorKey !== lastAuthorKey) {
392
+ var who = document.createElement("div");
393
+ who.className = "lvc-who lvc-who-" + message.author;
394
+ who.textContent = message.author === "visitor"
395
+ ? config.labels.you
396
+ : (message.label || config.labels.team);
397
+ listEl.appendChild(who);
398
+ lastAuthorKey = authorKey;
399
+ }
400
+
401
+ var bubble = document.createElement("div");
402
+ bubble.className = "lvc-msg lvc-" + message.author;
403
+ bubble.textContent = message.body;
404
+ bubble.title = new Date(message.at).toLocaleString();
405
+ listEl.appendChild(bubble);
406
+ scrollToBottom();
407
+ }
408
+
409
+ function scrollToBottom() {
410
+ if (listEl) listEl.scrollTop = listEl.scrollHeight;
411
+ }
412
+
413
+ function send() {
414
+ var body = (inputEl.value || "").trim();
415
+ if (!body || sending) return;
416
+ sending = true;
417
+ errorEl.hidden = true;
418
+ var button = formEl.querySelector("button");
419
+ button.disabled = true;
420
+
421
+ request("POST", "/messages", {
422
+ body: body,
423
+ page_url: window.location.href,
424
+ locale: config.locale
425
+ })
426
+ .then(function (response) {
427
+ return response.json().then(function (data) {
428
+ return { ok: response.ok, data: data };
429
+ });
430
+ })
431
+ .then(function (result) {
432
+ sending = false;
433
+ button.disabled = false;
434
+ if (result.ok) {
435
+ inputEl.value = "";
436
+ autogrow();
437
+ hasThread = true;
438
+ appendMessage(result.data.message);
439
+ syncEmailRow();
440
+ inputEl.focus();
441
+ } else {
442
+ showError((result.data.errors || [])[0] || config.labels.errorSend);
443
+ }
444
+ })
445
+ .catch(function () {
446
+ sending = false;
447
+ button.disabled = false;
448
+ showError(config.labels.errorSend);
449
+ });
450
+ }
451
+
452
+ function showError(text) {
453
+ errorEl.textContent = text;
454
+ errorEl.hidden = false;
455
+ }
456
+
457
+ // --- transport --------------------------------------------------------------
458
+
459
+ function request(method, path, body) {
460
+ var options = {
461
+ method: method,
462
+ headers: { "Accept": "application/json" },
463
+ credentials: "same-origin"
464
+ };
465
+ if (method !== "GET") {
466
+ var token = document.querySelector('meta[name="csrf-token"]');
467
+ if (token) options.headers["X-CSRF-Token"] = token.content;
468
+ options.headers["Content-Type"] = "application/json";
469
+ options.body = JSON.stringify(body || {});
470
+ }
471
+ return fetch(config.endpoint + path, options);
472
+ }
473
+
474
+ // --- session state (survives Turbo visits; try/catch for private modes) ------
475
+
476
+ function sessionGet(key) {
477
+ try { return window.sessionStorage.getItem(key); } catch (e) { return null; }
478
+ }
479
+
480
+ function sessionSet(key, value) {
481
+ try {
482
+ if (value) window.sessionStorage.setItem(key, value);
483
+ else window.sessionStorage.removeItem(key);
484
+ } catch (e) { /* storage unavailable — open state just won't persist */ }
485
+ }
486
+
487
+ // --- styles -------------------------------------------------------------------
488
+
489
+ function injectStyles() {
490
+ if (document.getElementById("lvc-styles")) return;
491
+ var css =
492
+ "#lvc-root{--lvc-accent:#2563eb;--lvc-accent-text:#fff;--lvc-surface:#fff;" +
493
+ "--lvc-text:#1c2024;--lvc-muted:#6b7280;--lvc-border:#e5e7eb;--lvc-bg:#f6f7f9;" +
494
+ "font:14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif;color:var(--lvc-text)}" +
495
+ "@media(prefers-color-scheme:dark){#lvc-root{--lvc-surface:#1a1f26;--lvc-text:#e6e8ea;" +
496
+ "--lvc-muted:#9aa2ab;--lvc-border:#2a313a;--lvc-bg:#111418;--lvc-accent:#3b82f6}}" +
497
+ "#lvc-root *{box-sizing:border-box;margin:0;padding:0}" +
498
+ "#lvc-launcher{position:fixed;bottom:20px;right:20px;z-index:" + Z + ";width:54px;height:54px;" +
499
+ "border-radius:50%;border:none;background:var(--lvc-accent);color:var(--lvc-accent-text);" +
500
+ "cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.25);display:flex;align-items:center;" +
501
+ "justify-content:center}" +
502
+ "#lvc-launcher:hover{filter:brightness(1.08)}" +
503
+ "#lvc-root[dir=rtl] #lvc-launcher{right:auto;left:20px}" +
504
+ "#lvc-badge{position:absolute;top:-4px;right:-4px;min-width:20px;height:20px;padding:0 5px;" +
505
+ "border-radius:999px;background:#dc2626;color:#fff;font-size:12px;font-weight:700;" +
506
+ "line-height:20px;text-align:center}" +
507
+ "#lvc-panel{position:fixed;bottom:86px;right:20px;z-index:" + Z + ";width:360px;max-width:calc(100vw - 24px);" +
508
+ "height:520px;max-height:calc(100dvh - 110px);background:var(--lvc-surface);border:1px solid var(--lvc-border);" +
509
+ "border-radius:14px;box-shadow:0 12px 40px rgba(0,0,0,.28);display:flex;flex-direction:column;overflow:hidden}" +
510
+ "#lvc-panel[hidden]{display:none}" +
511
+ "#lvc-root[dir=rtl] #lvc-panel{right:auto;left:20px}" +
512
+ "@media(max-width:480px){#lvc-panel{right:12px;bottom:80px}}" +
513
+ "#lvc-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;" +
514
+ "padding:14px 16px;background:var(--lvc-accent);color:var(--lvc-accent-text)}" +
515
+ "#lvc-header strong{display:block;font-size:15px}" +
516
+ "#lvc-header span{display:block;font-size:12px;opacity:.85;margin-top:2px}" +
517
+ "#lvc-close{border:none;background:none;color:var(--lvc-accent-text);font-size:22px;" +
518
+ "line-height:1;cursor:pointer;padding:2px 6px;border-radius:6px}" +
519
+ "#lvc-close:hover{background:rgba(255,255,255,.15)}" +
520
+ "#lvc-list{flex:1;overflow-y:auto;padding:14px;background:var(--lvc-bg);display:flex;" +
521
+ "flex-direction:column;gap:4px}" +
522
+ // Class rules carry the #lvc-root prefix so they outrank the
523
+ // id-level `#lvc-root *` reset above.
524
+ "#lvc-root .lvc-greeting{color:var(--lvc-muted);font-size:13px;margin-bottom:8px}" +
525
+ "#lvc-root .lvc-who{font-size:11px;color:var(--lvc-muted);margin:8px 4px 2px}" +
526
+ "#lvc-root .lvc-who-visitor{text-align:right}" +
527
+ "#lvc-root[dir=rtl] .lvc-who-visitor{text-align:left}" +
528
+ "#lvc-root .lvc-msg{max-width:82%;padding:8px 12px;border-radius:14px;white-space:pre-wrap;" +
529
+ "overflow-wrap:anywhere;font-size:14px}" +
530
+ "#lvc-root .lvc-visitor{align-self:flex-end;background:var(--lvc-accent);color:var(--lvc-accent-text);" +
531
+ "border-bottom-right-radius:4px}" +
532
+ "#lvc-root[dir=rtl] .lvc-visitor{align-self:flex-start;border-bottom-right-radius:14px;" +
533
+ "border-bottom-left-radius:4px}" +
534
+ "#lvc-root .lvc-agent{align-self:flex-start;background:var(--lvc-surface);border:1px solid var(--lvc-border);" +
535
+ "border-bottom-left-radius:4px}" +
536
+ "#lvc-root[dir=rtl] .lvc-agent{align-self:flex-end;border-bottom-left-radius:14px;" +
537
+ "border-bottom-right-radius:4px}" +
538
+ "#lvc-root .lvc-system{align-self:center;color:var(--lvc-muted);font-size:12px;margin:8px 0}" +
539
+ "#lvc-error{padding:6px 14px;color:#dc2626;font-size:13px}" +
540
+ "#lvc-email{padding:8px 14px;border-top:1px solid var(--lvc-border);font-size:12px;" +
541
+ "color:var(--lvc-muted)}" +
542
+ "#lvc-email div{display:flex;gap:6px;margin-top:6px}" +
543
+ "#lvc-email input{flex:1;padding:6px 8px;border:1px solid var(--lvc-border);border-radius:8px;" +
544
+ "background:var(--lvc-bg);color:var(--lvc-text);font:inherit}" +
545
+ "#lvc-email button{padding:6px 10px;border:1px solid var(--lvc-border);border-radius:8px;" +
546
+ "background:var(--lvc-surface);color:var(--lvc-text);font:inherit;cursor:pointer}" +
547
+ "#lvc-form{display:flex;gap:8px;padding:10px 12px;border-top:1px solid var(--lvc-border);" +
548
+ "background:var(--lvc-surface)}" +
549
+ "#lvc-form textarea{flex:1;resize:none;border:1px solid var(--lvc-border);border-radius:10px;" +
550
+ "padding:8px 10px;font:inherit;background:var(--lvc-bg);color:var(--lvc-text);max-height:120px}" +
551
+ "#lvc-form textarea:focus{outline:2px solid var(--lvc-accent);outline-offset:-1px}" +
552
+ "#lvc-form button{border:none;border-radius:10px;background:var(--lvc-accent);" +
553
+ "color:var(--lvc-accent-text);width:40px;min-width:40px;height:40px;align-self:flex-end;" +
554
+ "display:flex;align-items:center;justify-content:center;cursor:pointer}" +
555
+ "#lvc-form button:disabled{opacity:.6;cursor:default}" +
556
+ "#lvc-root[dir=rtl] #lvc-form button svg{transform:scaleX(-1)}";
557
+
558
+ var style = document.createElement("style");
559
+ style.id = "lvc-styles";
560
+ style.textContent = css;
561
+ document.head.appendChild(style);
562
+ }
563
+ })();