livechat 0.8.2 → 0.9.1
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.
- checksums.yaml +4 -4
- data/AGENTS.md +1 -0
- data/CHANGELOG.md +43 -0
- data/README.md +3 -13
- data/app/views/livechat/conversations/_thread_panel.html.erb +14 -11
- data/app/views/livechat/conversations/index.html.erb +19 -15
- data/lib/livechat/dashboard.css +300 -125
- data/lib/livechat/dashboard.js +1 -1
- data/lib/livechat/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77c8f578ea3bb8e56ae6e93c3ffc3194bbec65a1952d7e7f3e2152986bc12388
|
|
4
|
+
data.tar.gz: bb5e62c493d8aef5e20a82884ed43213f2671543bee6e06d2aa1a352a55f9d25
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f77110e02430ef730ac85ad64be4a05f4fd588035c384284e14374ebebbb40b3c5537df63e3d6128d20db2f1891836f54c90f223a087f123fa946893b8ea63d1
|
|
7
|
+
data.tar.gz: da4da0a2cfa71838e93434cdf3d0d88fd5a6fc15a24754369977d7e3064d5052b28cf6d29055db044b3132acbaf50d832f97a4eb6ef2a565d58edd37e27bbd84
|
data/AGENTS.md
CHANGED
|
@@ -176,5 +176,6 @@ Conventions this codebase holds to — follow them rather than the first thing t
|
|
|
176
176
|
- **The widget is plain ES5-style JS served by the engine**, no build step, no framework, config read from a JSON block so a Turbo visit re-reads the current page's settings.
|
|
177
177
|
- **Visitor scoping is never by conversation id.** The widget's endpoints resolve the thread from the signed-in id or the guest cookie, so no id in a request can address someone else's conversation. Keep it that way.
|
|
178
178
|
- **The dummy app pins `config.active_job.queue_adapter = :test`.** Do not remove it or let it drift back to the `:async` default. Attaching a file enqueues Active Storage's analysis job, and `:async` runs it on a background thread that checks out its own connection — writes no test transaction covers, landing in the middle of whatever runs next. That is a suite that fails order-dependently in a test which never created a row, and it is miserable to trace back.
|
|
179
|
+
- **`lib/livechat/dashboard.css` is half shared.** Everything above the `GEM-SPECIFIC` banner is the design system all five gems in the family ship — the same tokens, the same `.page-head`/`.tabs`/`.filters`/`.card`/`.badge`/`button`, the same `.dashboard-shell` + `.record-row` + `.detail-panel` two-pane dashboard — identical in every repo apart from the `lvc` prefix. Diff it against a sibling before changing it, and carry the change to the other four. Anything only this gem has goes below the banner. New dashboard markup reuses the shared class names rather than inventing a domain-specific one.
|
|
179
180
|
- Every user-facing change bumps `lib/livechat/version.rb` and adds a `CHANGELOG.md` entry that says what it costs, not only what it adds.
|
|
180
181
|
- Commit messages are prose that explains the tradeoff — read `git log` before writing one.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
|
|
5
|
+
- **The conversation heading reads as one block.** The visitor's email and locale
|
|
6
|
+
moved out of their own row and sit directly under the name, inside a
|
|
7
|
+
`.panel-titles` wrapper, and the open/resolved switch is pinned to the right
|
|
8
|
+
edge of the heading row with `margin-left: auto` rather than relying on the
|
|
9
|
+
row's `space-between` — a long name no longer drags the switch inward. If you
|
|
10
|
+
targeted `.context-line` as a child of `.detail-panel`, it is now a child of
|
|
11
|
+
`.panel-head`.
|
|
12
|
+
|
|
13
|
+
## 0.9.0
|
|
14
|
+
|
|
15
|
+
- **One design system across the family.** The stylesheet now opens with a
|
|
16
|
+
shared core — the colour tokens, `.page-head`, `.tabs`, `.filters`, `.card`,
|
|
17
|
+
`.badge`, buttons and form controls, and the `.dashboard-shell` +
|
|
18
|
+
`.record-row` + `.detail-panel` two-pane dashboard — identical in all five
|
|
19
|
+
gems of the family, apart from the `--lvc-` prefix. The five had drifted:
|
|
20
|
+
sidebars between 380px and 430px, reading columns between 860px and 1020px,
|
|
21
|
+
two different tab styles and three different button styles. The sidebar is
|
|
22
|
+
now 330–430px everywhere, a reading page 1020px, a dashboard 1280px.
|
|
23
|
+
Everything below the `GEM-SPECIFIC` banner is what only this gem has.
|
|
24
|
+
- **The inbox markup uses the shared class names.** `.inbox-shell`,
|
|
25
|
+
`.inbox-sidebar` and `.inbox-thread` are `.dashboard-shell`,
|
|
26
|
+
`.dashboard-sidebar` and `.dashboard-detail`; `.conversation-list`,
|
|
27
|
+
`.conversation-row`, `.conversation-main`, `.conversation-topline`,
|
|
28
|
+
`.conversation-meta`, `.conversation-time` and `.conversation-side` are
|
|
29
|
+
`.record-*`, with `.conversation-name` becoming `.record-name` and
|
|
30
|
+
`.conversation-preview` `.record-copy`; `.conversation-panel` is
|
|
31
|
+
`.detail-panel` and its `.head-row` is `.panel-head`. The body class on the
|
|
32
|
+
inbox is `lvc-index` (was `lvc-inbox`) and the polling element is
|
|
33
|
+
`#lvc-poll` (was `#lvc-index`). A conversation row now reads name, then
|
|
34
|
+
message preview, then ids — the order the sibling gems use. If you styled or
|
|
35
|
+
scripted any of those names in a host app, that is the breaking change — no
|
|
36
|
+
configuration, route or database change is involved.
|
|
37
|
+
- **The narrow-screen rules work again.** The `max-width: 760px` block sat above
|
|
38
|
+
the component rules it means to override, and CSS nesting adds no specificity,
|
|
39
|
+
so the desktop grid won every tie: showing one pane at a time on a phone had
|
|
40
|
+
quietly stopped working. The media query moved to the end of the file.
|
|
41
|
+
- Smaller fixes that came with the shared core: a submit input is styled as a
|
|
42
|
+
button rather than a full-width field, the filter row keeps its search box and
|
|
43
|
+
button on one line, and a `code.key` truncates inside a list row instead of
|
|
44
|
+
wrapping over three lines.
|
|
45
|
+
|
|
3
46
|
## 0.8.0
|
|
4
47
|
|
|
5
48
|
- **`config.agent_layout` now works on its own.** The inbox's stylesheet and
|
data/README.md
CHANGED
|
@@ -316,16 +316,6 @@ bundle exec rake test
|
|
|
316
316
|
bundle exec rubocop
|
|
317
317
|
```
|
|
318
318
|
|
|
319
|
-
## Also by the same author
|
|
320
|
-
|
|
321
|
-
- [testimonials](https://github.com/yshmarov/testimonials) — testimonials,
|
|
322
|
-
reviews and NPS for Rails.
|
|
323
|
-
- [ideasbugs](https://github.com/yshmarov/ideasbugs) — in-app bug reports and
|
|
324
|
-
feature requests.
|
|
325
|
-
- [i18n_proofreading](https://github.com/yshmarov/i18n_proofreading) — in-context
|
|
326
|
-
translation proofreading.
|
|
327
|
-
- [SupeRails](https://superails.com) — Rails screencasts.
|
|
328
|
-
|
|
329
319
|
## One family
|
|
330
320
|
|
|
331
321
|
Five Rails engines built on the same backbone, so adopting a second one is
|
|
@@ -341,9 +331,9 @@ mostly muscle memory:
|
|
|
341
331
|
|
|
342
332
|
They share the install shape (`generate <gem>:install`, mount, one initializer),
|
|
343
333
|
the same host hooks (`base_controller_class` to inherit your admin's controller,
|
|
344
|
-
`agent_layout` for just the shell),
|
|
345
|
-
|
|
346
|
-
`primary_key_type`.
|
|
334
|
+
`agent_layout` for just the shell), one dashboard design system — the same
|
|
335
|
+
two-pane layout, colour tokens and components in all five, scoped so it cannot
|
|
336
|
+
touch your own CSS — and migrations that follow your app's `primary_key_type`.
|
|
347
337
|
|
|
348
338
|
## License
|
|
349
339
|
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
<div class="
|
|
2
|
-
<div class="head
|
|
3
|
-
|
|
1
|
+
<div class="detail-panel">
|
|
2
|
+
<div class="panel-head">
|
|
3
|
+
<%# The name with the visitor's context directly under it, so the status
|
|
4
|
+
toggle on the right of the row clears both. %>
|
|
5
|
+
<div class="panel-titles">
|
|
6
|
+
<h1><%= conversation.display_name %> <span class="case-id">#<%= conversation.id %></span></h1>
|
|
7
|
+
<%# One quiet line of live context: who the visitor is and the language to answer in. %>
|
|
8
|
+
<p class="muted context-line">
|
|
9
|
+
<% context = [] %>
|
|
10
|
+
<% context << conversation.visitor_email if conversation.visitor_email.present? %>
|
|
11
|
+
<% context << conversation.locale if conversation.locale.present? %>
|
|
12
|
+
<%= safe_join(context, ' · ') %>
|
|
13
|
+
</p>
|
|
14
|
+
</div>
|
|
4
15
|
<% if conversation.open? %>
|
|
5
16
|
<%= button_to resolve_conversation_path(conversation), method: :post,
|
|
6
17
|
form_class: 'status-toggle-form',
|
|
@@ -20,14 +31,6 @@
|
|
|
20
31
|
<% end %>
|
|
21
32
|
</div>
|
|
22
33
|
|
|
23
|
-
<%# One quiet line of live context: who the visitor is and the language to answer in. %>
|
|
24
|
-
<p class="muted context-line">
|
|
25
|
-
<% context = [] %>
|
|
26
|
-
<% context << conversation.visitor_email if conversation.visitor_email.present? %>
|
|
27
|
-
<% context << conversation.locale if conversation.locale.present? %>
|
|
28
|
-
<%= safe_join(context, ' · ') %>
|
|
29
|
-
</p>
|
|
30
|
-
|
|
31
34
|
<div class="card">
|
|
32
35
|
<% last = messages.last %>
|
|
33
36
|
<% cable = livechat_cable_data("livechat:conversation:#{conversation.id}") %>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
<% content_for :body_class, 'lvc-
|
|
1
|
+
<% content_for :body_class, 'lvc-index' %>
|
|
2
2
|
|
|
3
3
|
<%= render layout: 'livechat/shared/dashboard' do %>
|
|
4
|
-
<
|
|
4
|
+
<div class="page-head">
|
|
5
|
+
<h1><%= t('livechat.dashboard.title', default: 'LiveChat') %></h1>
|
|
6
|
+
</div>
|
|
5
7
|
|
|
6
|
-
<div class="
|
|
7
|
-
<aside class="
|
|
8
|
+
<div class="dashboard-shell <%= 'has-selected' if @selected_conversation %>">
|
|
9
|
+
<aside class="dashboard-sidebar" aria-label="<%= t('livechat.dashboard.conversations', default: 'Conversations') %>">
|
|
8
10
|
<div class="tabs">
|
|
9
11
|
<% Livechat::Conversation::STATUSES.each do |status| %>
|
|
10
12
|
<%= link_to conversations_path(status: status, q: @query), class: ('active' if @status == status) do %>
|
|
@@ -25,12 +27,12 @@
|
|
|
25
27
|
<%# The list keeps itself fresh (dashboard.js reloads when the token moves) —
|
|
26
28
|
unless the agent is mid-search or mid-reply, whose typing must never be eaten. %>
|
|
27
29
|
<% cable = livechat_cable_data('livechat:inbox') %>
|
|
28
|
-
<div id="lvc-
|
|
30
|
+
<div id="lvc-poll" data-poll-url="<%= poll_conversations_path %>"
|
|
29
31
|
data-token="<%= [Livechat::Message.maximum(:id).to_i, Livechat::Conversation.count,
|
|
30
32
|
@counts.fetch('open', 0)].join('-') %>"
|
|
31
33
|
<%= tag.attributes(data: cable) if cable.any? %>></div>
|
|
32
34
|
|
|
33
|
-
<div class="
|
|
35
|
+
<div class="record-list">
|
|
34
36
|
<% if @conversations.empty? %>
|
|
35
37
|
<p class="empty"><%= t('livechat.dashboard.empty',
|
|
36
38
|
default: 'Nothing here. When a visitor writes, the conversation lands on this page.') %></p>
|
|
@@ -39,27 +41,29 @@
|
|
|
39
41
|
<% unread = @unread.fetch(conversation.id, 0) %>
|
|
40
42
|
<%= link_to conversations_path(status: @status, q: @query, offset: @offset,
|
|
41
43
|
conversation_id: conversation.id),
|
|
42
|
-
class: ['
|
|
44
|
+
class: ['record-row',
|
|
43
45
|
('active' if @selected_conversation&.id == conversation.id),
|
|
44
46
|
('unread' if unread.positive?)].compact do %>
|
|
45
|
-
<span class="
|
|
46
|
-
<span class="
|
|
47
|
-
<span class="
|
|
48
|
-
<span class="muted
|
|
47
|
+
<span class="record-main">
|
|
48
|
+
<span class="record-topline">
|
|
49
|
+
<span class="record-name"><%= conversation.display_name %></span>
|
|
50
|
+
<span class="muted record-time">
|
|
49
51
|
<% if conversation.last_activity_at %>
|
|
50
52
|
<%= time_ago_in_words(conversation.last_activity_at) %>
|
|
51
53
|
<% end %>
|
|
52
54
|
</span>
|
|
53
55
|
</span>
|
|
54
|
-
|
|
56
|
+
<%# Same row shape as the sibling gems: the name and time on top,
|
|
57
|
+
the copy that identifies the record, then the muted ids. %>
|
|
58
|
+
<span class="muted record-copy"><%= conversation.last_message_preview %></span>
|
|
59
|
+
<span class="muted record-meta">
|
|
55
60
|
<span class="case-id">#<%= conversation.id %></span>
|
|
56
61
|
<% if conversation.visitor_email.present? && conversation.visitor_email != conversation.display_name %>
|
|
57
62
|
<span><%= conversation.visitor_email %></span>
|
|
58
63
|
<% end %>
|
|
59
64
|
</span>
|
|
60
|
-
<span class="muted conversation-preview"><%= conversation.last_message_preview %></span>
|
|
61
65
|
</span>
|
|
62
|
-
<span class="
|
|
66
|
+
<span class="record-side">
|
|
63
67
|
<% if unread.positive? %>
|
|
64
68
|
<span class="badge unread-count"><%= unread %></span>
|
|
65
69
|
<% end %>
|
|
@@ -88,7 +92,7 @@
|
|
|
88
92
|
</div>
|
|
89
93
|
</aside>
|
|
90
94
|
|
|
91
|
-
<main class="
|
|
95
|
+
<main class="dashboard-detail" aria-label="<%= t('livechat.dashboard.current_chat', default: 'Current chat') %>">
|
|
92
96
|
<% if @selected_conversation %>
|
|
93
97
|
<p class="mobile-back">
|
|
94
98
|
<%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}",
|
data/lib/livechat/dashboard.css
CHANGED
|
@@ -4,51 +4,56 @@
|
|
|
4
4
|
*
|
|
5
5
|
* :root custom properties, all `--lvc-` prefixed so they can
|
|
6
6
|
* neither overwrite a host's nor be overwritten by them.
|
|
7
|
-
* .lvc-page
|
|
7
|
+
* .lvc-page the page frame (was bare html/body). Only the gem's own
|
|
8
8
|
* layout puts this class on <body>, so inside a host admin
|
|
9
9
|
* these rules simply do not apply.
|
|
10
|
-
* .lvc-dashboard
|
|
10
|
+
* .lvc-dashboard everything else, nested. Names like .container, .card
|
|
11
11
|
* and .tabs are confined to the dashboard's own markup, so
|
|
12
12
|
* loading this file inside a host cannot restyle its chrome.
|
|
13
13
|
*
|
|
14
14
|
* Nothing here styles a bare `body`, `a`, `table` or `*` at the top level.
|
|
15
|
+
*
|
|
16
|
+
* Two sections follow: the design system shared by the whole gem family,
|
|
17
|
+
* then what only this gem has. A rule that belongs in the shared section
|
|
18
|
+
* belongs in all five copies of it.
|
|
19
|
+
*
|
|
20
|
+
* Order inside a section matters — components, then the page frame, then the
|
|
21
|
+
* narrow-screen overrides. Nesting adds no specificity, so the last rule
|
|
22
|
+
* wins the ties, and an override placed before the rule it means to beat
|
|
23
|
+
* silently does nothing.
|
|
15
24
|
*/
|
|
16
25
|
|
|
26
|
+
/* ---------------------------------------------------------------------------
|
|
27
|
+
* SHARED CORE — identical in testimonials, ideasbugs, livechat, product_tours
|
|
28
|
+
* and i18n_proofreading apart from the prefix. Diff it against a sibling gem
|
|
29
|
+
* before you change it, and carry the change to all five.
|
|
30
|
+
* ------------------------------------------------------------------------- */
|
|
17
31
|
|
|
18
32
|
:root {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
color-scheme: light dark;
|
|
34
|
+
--lvc-bg: #f6f7f9;
|
|
35
|
+
--lvc-surface: #fff;
|
|
36
|
+
--lvc-text: #1c2024;
|
|
37
|
+
--lvc-muted: #6b7280;
|
|
38
|
+
--lvc-border: #e5e7eb;
|
|
39
|
+
--lvc-accent: #2563eb;
|
|
40
|
+
--lvc-accent-text: #fff;
|
|
41
|
+
--lvc-code: #f0f1f3;
|
|
42
|
+
/* The four semantic states every gem in the family badges something with. */
|
|
43
|
+
--lvc-success: #16a34a;
|
|
44
|
+
--lvc-warning: #d97706;
|
|
45
|
+
--lvc-danger: #dc2626;
|
|
46
|
+
--lvc-neutral: #6b7280;
|
|
23
47
|
}
|
|
24
48
|
@media (prefers-color-scheme: dark) {
|
|
25
49
|
:root {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.lvc-inbox, .lvc-inbox .container { min-height: 100vh; height: auto; }
|
|
34
|
-
.lvc-inbox .container { padding-bottom: 10px; }
|
|
35
|
-
.lvc-dashboard {
|
|
36
|
-
& .container { padding: 14px 10px 24px; }
|
|
37
|
-
& .inbox-shell { display: block; min-height: calc(100vh - 62px); }
|
|
38
|
-
& .inbox-sidebar,
|
|
39
|
-
& .inbox-thread { min-height: calc(100vh - 62px); }
|
|
40
|
-
& .inbox-shell.has-selected .inbox-sidebar { display: none; }
|
|
41
|
-
& .inbox-shell:not(.has-selected) .inbox-thread { display: none; }
|
|
42
|
-
& .conversation-list { overflow: visible; }
|
|
43
|
-
& .conversation-row { padding: 12px; }
|
|
44
|
-
& .conversation-meta { display: none; }
|
|
45
|
-
& .conversation-panel { min-height: calc(100vh - 64px); }
|
|
46
|
-
& .inbox-thread .conversation-panel { padding: 0; }
|
|
47
|
-
& .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
|
|
48
|
-
& .conversation-panel .head-row h1 { font-size: 18px; }
|
|
49
|
-
& .status-toggle { width: 152px; min-height: 34px; }
|
|
50
|
-
& .status-toggle span { padding: 0 7px; font-size: 12px; }
|
|
51
|
-
& .thread .msg { max-width: 88%; }
|
|
50
|
+
--lvc-bg: #111418;
|
|
51
|
+
--lvc-surface: #1a1f26;
|
|
52
|
+
--lvc-text: #e6e8ea;
|
|
53
|
+
--lvc-muted: #9aa2ab;
|
|
54
|
+
--lvc-border: #2a313a;
|
|
55
|
+
--lvc-accent: #3b82f6;
|
|
56
|
+
--lvc-code: #232a33;
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -61,37 +66,239 @@
|
|
|
61
66
|
& * { box-sizing: border-box; }
|
|
62
67
|
& a { color: var(--lvc-accent); text-decoration: none; }
|
|
63
68
|
& a:hover { text-decoration: underline; }
|
|
64
|
-
&
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
& .
|
|
69
|
-
&
|
|
70
|
-
&
|
|
71
|
-
& .
|
|
72
|
-
& .
|
|
73
|
-
& .
|
|
74
|
-
& .
|
|
69
|
+
& :focus-visible { outline: 2px solid var(--lvc-accent); outline-offset: 2px; }
|
|
70
|
+
|
|
71
|
+
/* Page furniture. `.container` is the reading width; an index page widens it
|
|
72
|
+
to 1280px in the page frame below. */
|
|
73
|
+
& .container { max-width: 1020px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
74
|
+
& h1 { margin: 0 0 16px; font-size: 22px; }
|
|
75
|
+
& h2 { margin: 0 0 12px; font-size: 16px; }
|
|
76
|
+
& .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
77
|
+
& .page-head h1 { margin: 0; }
|
|
78
|
+
& .lede { margin: 4px 0 0; color: var(--lvc-muted); }
|
|
79
|
+
& .breadcrumb { margin: 0 0 12px; font-size: 13px; }
|
|
80
|
+
& .muted { color: var(--lvc-muted); font-size: 13px; }
|
|
81
|
+
& .case-id { color: var(--lvc-muted); font-weight: 400; font-size: 15px; }
|
|
82
|
+
& .spacer { flex: 1; }
|
|
83
|
+
& .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
84
|
+
|
|
85
|
+
/* Section switcher: the pills above the dashboard when a gem has more than
|
|
86
|
+
one section. Lives on the page, so a host `admin_layout` can drop it. */
|
|
87
|
+
& .nav { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
|
88
|
+
& .nav a { padding: 6px 16px; border: 1px solid var(--lvc-border); border-radius: 999px; background: var(--lvc-surface); color: var(--lvc-muted); font-weight: 600; }
|
|
89
|
+
& .nav a:hover { text-decoration: none; color: var(--lvc-text); }
|
|
90
|
+
& .nav a.active { border-color: var(--lvc-accent); background: var(--lvc-accent); color: var(--lvc-accent-text); }
|
|
91
|
+
& .nav a.active:hover { color: var(--lvc-accent-text); }
|
|
92
|
+
|
|
93
|
+
/* Tabs: one system for the whole family — an underlined row of equal-width
|
|
94
|
+
tabs, sized to sit at the top of the sidebar. */
|
|
95
|
+
& .tabs { display: flex; gap: 6px; }
|
|
96
|
+
& .tabs a { position: relative; display: flex; flex: 1 1 0; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px; border-radius: 8px; color: var(--lvc-muted); font-weight: 600; }
|
|
97
|
+
& .tabs a:hover { background: var(--lvc-bg); color: var(--lvc-text); text-decoration: none; }
|
|
98
|
+
& .tabs a.active { color: var(--lvc-text); }
|
|
99
|
+
& .tabs a.active::after { content: ""; position: absolute; right: 20px; bottom: 0; left: 20px; height: 2px; border-radius: 999px; background: var(--lvc-accent); }
|
|
100
|
+
& .count { display: inline-block; min-width: 20px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--lvc-muted) 14%, transparent); font-size: 12px; text-align: center; font-variant-numeric: tabular-nums; }
|
|
101
|
+
& .tabs a.active .count { background: var(--lvc-border); }
|
|
102
|
+
|
|
103
|
+
/* Filter row under the tabs: a full-width select over a search + submit row,
|
|
104
|
+
however many of the three a gem actually has. */
|
|
105
|
+
& .filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 12px; border-bottom: 1px solid var(--lvc-border); }
|
|
106
|
+
& .filters label { flex: 1 1 100%; color: var(--lvc-muted); font-size: 13px; }
|
|
107
|
+
& .filters select { flex: 1 1 100%; }
|
|
108
|
+
/* Basis 0, not auto: the controls below are `width: 100%`, which would
|
|
109
|
+
otherwise push the submit button onto its own row. */
|
|
110
|
+
& .filters input[type="search"] { flex: 1 1 0; min-width: 0; }
|
|
111
|
+
& .filters button,
|
|
112
|
+
& .filters input[type="submit"] { flex: 0 0 auto; }
|
|
113
|
+
|
|
114
|
+
/* Form controls. 16px so iOS does not zoom the page on focus. */
|
|
115
|
+
& input,
|
|
116
|
+
& select,
|
|
117
|
+
& textarea { width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; font-size: 16px; letter-spacing: 0; }
|
|
118
|
+
& textarea { resize: vertical; }
|
|
119
|
+
& input[type="checkbox"],
|
|
120
|
+
& input[type="radio"] { width: auto; min-width: 0; min-height: 0; }
|
|
121
|
+
& input:focus,
|
|
122
|
+
& select:focus,
|
|
123
|
+
& textarea:focus { border-color: var(--lvc-accent); outline: 2px solid color-mix(in srgb, var(--lvc-accent) 22%, transparent); outline-offset: 0; }
|
|
124
|
+
& .field { margin-bottom: 16px; }
|
|
125
|
+
& .field label { display: block; margin-bottom: 5px; font-weight: 600; }
|
|
126
|
+
& .field small { display: block; margin-top: 5px; color: var(--lvc-muted); }
|
|
127
|
+
|
|
128
|
+
/* A submit input is a button, whatever the markup says. */
|
|
129
|
+
& button,
|
|
130
|
+
& .button,
|
|
131
|
+
& input[type="submit"] { display: inline-flex; width: auto; align-items: center; justify-content: center; min-height: 38px; padding: 8px 14px; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; cursor: pointer; }
|
|
132
|
+
& button:hover,
|
|
133
|
+
& .button:hover,
|
|
134
|
+
& input[type="submit"]:hover { border-color: var(--lvc-muted); text-decoration: none; }
|
|
135
|
+
& button.primary,
|
|
136
|
+
& .button.primary,
|
|
137
|
+
& input[type="submit"].primary { border-color: var(--lvc-accent); background: var(--lvc-accent); color: var(--lvc-accent-text); }
|
|
138
|
+
& button.danger,
|
|
139
|
+
& .button.danger { color: var(--lvc-danger); }
|
|
140
|
+
& button.small,
|
|
141
|
+
& .button.small { min-height: 32px; padding: 4px 10px; font-size: 13px; }
|
|
142
|
+
& .actions,
|
|
143
|
+
& .form-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
144
|
+
& .actions form { display: inline; }
|
|
145
|
+
& .form-actions { padding-top: 12px; }
|
|
146
|
+
|
|
147
|
+
& .card { overflow: hidden; border: 1px solid var(--lvc-border); border-radius: 12px; background: var(--lvc-surface); }
|
|
148
|
+
& .card.pad { padding: 16px; overflow: visible; }
|
|
75
149
|
& table { width: 100%; border-collapse: collapse; }
|
|
76
150
|
& th,
|
|
77
151
|
& td { padding: 10px 14px; text-align: left; vertical-align: top; }
|
|
78
152
|
& th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--lvc-muted); border-bottom: 1px solid var(--lvc-border); }
|
|
79
153
|
& tr + tr td { border-top: 1px solid var(--lvc-border); }
|
|
80
|
-
&
|
|
81
|
-
& .
|
|
154
|
+
& td.row-actions { white-space: nowrap; text-align: right; }
|
|
155
|
+
& td.row-actions form { display: inline-block; }
|
|
156
|
+
& td.row-actions form + form { margin-left: 6px; }
|
|
157
|
+
|
|
158
|
+
& .badge { display: inline-flex; padding: 2px 10px; border-radius: 999px; color: #fff; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
159
|
+
& .badge.locale { border: 1px solid var(--lvc-border); background: transparent; color: var(--lvc-muted); }
|
|
160
|
+
& code.key { max-width: 100%; padding: 2px 7px; border-radius: 6px; background: var(--lvc-code); font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
|
|
161
|
+
|
|
162
|
+
& .flash,
|
|
163
|
+
& .errors { margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); }
|
|
164
|
+
& .flash.notice { border-color: var(--lvc-success); }
|
|
165
|
+
& .flash.alert,
|
|
166
|
+
& .errors { border-color: var(--lvc-danger); }
|
|
167
|
+
& .errors ul { margin-bottom: 0; }
|
|
168
|
+
|
|
169
|
+
& dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
|
|
170
|
+
& dt { color: var(--lvc-muted); }
|
|
171
|
+
& dd { margin: 0; overflow-wrap: anywhere; }
|
|
172
|
+
& .message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
173
|
+
|
|
174
|
+
& .empty { padding: 48px 16px; color: var(--lvc-muted); text-align: center; }
|
|
175
|
+
& .empty-state { max-width: 320px; margin: auto; padding: 24px; text-align: center; }
|
|
176
|
+
& .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
|
|
177
|
+
& .empty-state p { margin: 0; }
|
|
178
|
+
|
|
179
|
+
/* The two-pane dashboard: the queue on the left, the selected record on the
|
|
180
|
+
right, each pane owning its own scroll. */
|
|
181
|
+
& .dashboard-shell { display: grid; flex: 1 1 auto; grid-template-columns: minmax(330px, 430px) 1fr; gap: 16px; min-height: 0; }
|
|
182
|
+
& .dashboard-sidebar { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); }
|
|
183
|
+
& .dashboard-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; }
|
|
184
|
+
& .dashboard-sidebar .filters { flex: 0 0 auto; }
|
|
185
|
+
|
|
186
|
+
/* One row shape for every gem: a topline of badges and a timestamp, the copy
|
|
187
|
+
that identifies the record, then a muted line of ids and authors. */
|
|
188
|
+
& .record-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
189
|
+
& .record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; border-bottom: 1px solid var(--lvc-border); color: var(--lvc-text); }
|
|
190
|
+
& .record-row:hover { background: var(--lvc-bg); text-decoration: none; }
|
|
191
|
+
& .record-row.active { background: color-mix(in srgb, var(--lvc-accent) 9%, var(--lvc-surface)); box-shadow: inset 3px 0 0 var(--lvc-accent); }
|
|
192
|
+
& .record-main { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
|
|
193
|
+
& .record-topline,
|
|
194
|
+
& .record-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
195
|
+
& .record-name,
|
|
196
|
+
& .record-copy,
|
|
197
|
+
& .record-meta span,
|
|
198
|
+
/* A key is `overflow-wrap: anywhere` in prose; in a row it truncates. */
|
|
199
|
+
& .record-topline code.key,
|
|
200
|
+
& .record-meta code.key { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
201
|
+
& .record-name { font-weight: 600; }
|
|
202
|
+
& .record-copy { display: block; font-weight: 600; }
|
|
203
|
+
& .record-time { margin-left: auto; white-space: nowrap; }
|
|
204
|
+
& .record-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; padding-top: 1px; }
|
|
205
|
+
& .pager { display: flex; flex: 0 0 auto; justify-content: space-between; gap: 16px; min-height: 42px; margin: 0; padding: 10px 12px; border-top: 1px solid var(--lvc-border); }
|
|
206
|
+
|
|
207
|
+
/* The selected record. `.detail-scroll` is the only part that scrolls, so the
|
|
208
|
+
panel heading and its actions stay put. */
|
|
209
|
+
& .dashboard-detail { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }
|
|
210
|
+
& .dashboard-detail .detail-panel { flex: 1 1 auto; min-height: 0; }
|
|
211
|
+
& .dashboard-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
|
|
212
|
+
& .dashboard-detail .actions { margin-bottom: 0; }
|
|
213
|
+
& .detail-panel { display: flex; min-width: 0; flex-direction: column; }
|
|
214
|
+
/* The panel's own heading row: the title on the left, whatever acts on the
|
|
215
|
+
whole record on the right. */
|
|
216
|
+
& .panel-head { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
217
|
+
& .panel-head h1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0; }
|
|
218
|
+
& .detail-scroll { display: flex; min-height: 0; flex-direction: column; gap: 12px; }
|
|
219
|
+
& .mobile-back { display: none; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Page frame — the gem's own layout only. After the components, so it wins the
|
|
223
|
+
specificity ties against them. */
|
|
224
|
+
.lvc-page { margin: 0; min-height: 100vh; background: var(--lvc-bg); color: var(--lvc-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0; }
|
|
225
|
+
/* An index page is the dashboard: it fills the viewport and never scrolls
|
|
226
|
+
itself, because the queue and the detail pane each scroll on their own. */
|
|
227
|
+
.lvc-index, .lvc-index .container { height: 100vh; height: 100dvh; }
|
|
228
|
+
.lvc-index { overflow: hidden; }
|
|
229
|
+
.lvc-index .container { display: flex; max-width: 1280px; flex-direction: column; padding-bottom: 16px; }
|
|
230
|
+
.lvc-index .nav,
|
|
231
|
+
.lvc-index .page-head { flex: 0 0 auto; }
|
|
232
|
+
/* Standalone show pages keep normal page scrolling. */
|
|
233
|
+
.lvc-show { min-height: 100vh; overflow: auto; }
|
|
234
|
+
.lvc-show .detail-panel { width: 100%; }
|
|
235
|
+
.lvc-show .detail-scroll { overflow: visible; }
|
|
236
|
+
|
|
237
|
+
/* Narrow screens: one pane at a time, the queue until a record is picked.
|
|
238
|
+
Last in the file, so these overrides actually reach the rules above. */
|
|
239
|
+
@media (max-width: 760px) {
|
|
240
|
+
.lvc-page { font-size: 14px; }
|
|
241
|
+
.lvc-index, .lvc-show { overflow-x: hidden; }
|
|
242
|
+
.lvc-index { overflow-y: auto; }
|
|
243
|
+
.lvc-index, .lvc-index .container { min-height: 100vh; height: auto; }
|
|
244
|
+
.lvc-index .container { padding-bottom: 10px; }
|
|
245
|
+
.lvc-dashboard {
|
|
246
|
+
& .container { padding: 14px 10px 24px; }
|
|
247
|
+
& .page-head { margin-bottom: 12px; }
|
|
248
|
+
& .detail-panel,
|
|
249
|
+
& .detail-scroll,
|
|
250
|
+
& .card,
|
|
251
|
+
& dl,
|
|
252
|
+
& dd { min-width: 0; max-width: 100%; }
|
|
253
|
+
& .dashboard-detail .card { width: 100%; }
|
|
254
|
+
& .message,
|
|
255
|
+
& .lede,
|
|
256
|
+
& dd,
|
|
257
|
+
& .muted { overflow-wrap: anywhere; }
|
|
258
|
+
& .dashboard-shell { display: block; min-height: calc(100vh - 62px); }
|
|
259
|
+
& .dashboard-sidebar,
|
|
260
|
+
& .dashboard-detail { min-height: calc(100vh - 62px); }
|
|
261
|
+
& .dashboard-shell.has-selected .dashboard-sidebar { display: none; }
|
|
262
|
+
& .dashboard-shell:not(.has-selected) .dashboard-detail { display: none; }
|
|
263
|
+
& .record-list { overflow: visible; }
|
|
264
|
+
& .record-row { padding: 12px; }
|
|
265
|
+
& .record-meta { display: none; }
|
|
266
|
+
& .dashboard-detail .detail-panel { min-height: calc(100vh - 64px); }
|
|
267
|
+
& .mobile-back { display: block; flex: 0 0 auto; margin: 4px 0 10px; font-size: 13px; }
|
|
268
|
+
& .panel-head h1 { font-size: 18px; }
|
|
269
|
+
& dl { grid-template-columns: 1fr; gap: 3px; }
|
|
270
|
+
& dd { margin-bottom: 8px; }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ---------------------------------------------------------------------------
|
|
275
|
+
* GEM-SPECIFIC — livechat only: the open/resolved toggle, agent avatars, the
|
|
276
|
+
* message thread, and the composer.
|
|
277
|
+
* ------------------------------------------------------------------------- */
|
|
278
|
+
|
|
279
|
+
:root {
|
|
280
|
+
--lvc-open: var(--lvc-success);
|
|
281
|
+
--lvc-resolved: var(--lvc-neutral);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.lvc-dashboard {
|
|
82
285
|
& .badge.status-open { background: var(--lvc-open); }
|
|
83
286
|
& .badge.status-resolved { background: var(--lvc-resolved); }
|
|
84
287
|
& .badge.unread-count { background: var(--lvc-danger); }
|
|
85
|
-
& .
|
|
86
|
-
& .
|
|
87
|
-
& .
|
|
88
|
-
& .
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
& .
|
|
288
|
+
& tr.unread td { font-weight: 600; }
|
|
289
|
+
& .record-row.unread .record-name,
|
|
290
|
+
& .record-row.unread .record-copy { font-weight: 700; }
|
|
291
|
+
& .record-side .avatars { min-height: 26px; }
|
|
292
|
+
|
|
293
|
+
/* The panel heading: the name with the visitor's context directly under it,
|
|
294
|
+
and the switch pushed to the far right of the row whatever the name's
|
|
295
|
+
length. `margin-left: auto` rather than the row's `space-between`, so a
|
|
296
|
+
third element in the row could never pull the switch back in. */
|
|
297
|
+
& .panel-titles { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
|
298
|
+
& .context-line { margin: 0; overflow-wrap: anywhere; }
|
|
299
|
+
|
|
300
|
+
/* One switch reading open|resolved, sliding to the side it is on. */
|
|
301
|
+
& .status-toggle-form { flex: 0 0 auto; margin-left: auto; }
|
|
95
302
|
& .status-toggle { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 0; width: 176px; min-height: 36px; padding: 3px; border-radius: 999px; border: 1px solid var(--lvc-border); background: var(--lvc-bg); color: var(--lvc-muted); overflow: hidden; }
|
|
96
303
|
& .status-toggle::before { content: ""; position: absolute; z-index: 0; top: 3px; bottom: 3px; width: calc(50% - 3px); border-radius: 999px; background: var(--lvc-surface); box-shadow: 0 1px 2px rgba(15, 23, 42, .12); transition: left .16s ease; }
|
|
97
304
|
& .status-toggle.is-open::before { left: 3px; }
|
|
@@ -100,10 +307,7 @@
|
|
|
100
307
|
& .status-toggle.is-open span:first-child { color: var(--lvc-open); }
|
|
101
308
|
& .status-toggle.is-resolved span:last-child { color: var(--lvc-text); }
|
|
102
309
|
& .status-toggle:hover { text-decoration: none; color: var(--lvc-text); }
|
|
103
|
-
|
|
104
|
-
& .button { padding: 8px 14px; border: 1px solid var(--lvc-border); border-radius: 8px; cursor: pointer; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; }
|
|
105
|
-
& button.primary { background: var(--lvc-accent); border-color: var(--lvc-accent); color: var(--lvc-accent-text); }
|
|
106
|
-
& .case-id { color: var(--lvc-muted); font-weight: 400; font-size: 15px; }
|
|
310
|
+
|
|
107
311
|
& .avatars { display: inline-flex; }
|
|
108
312
|
& .avatars .avatar { width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: #fff; border: 2px solid var(--lvc-surface); cursor: default; }
|
|
109
313
|
& .avatars .avatar + .avatar { margin-left: -8px; }
|
|
@@ -113,7 +317,8 @@
|
|
|
113
317
|
& .avatar.color-3 { background: #dc2626; }
|
|
114
318
|
& .avatar.color-4 { background: #7c3aed; }
|
|
115
319
|
& .avatar.color-5 { background: #0891b2; }
|
|
116
|
-
|
|
320
|
+
|
|
321
|
+
/* The thread: the visitor on the left, agents on the right. */
|
|
117
322
|
& .thread { display: flex; flex-direction: column; gap: 4px; padding: 16px; }
|
|
118
323
|
& .thread .who { font-size: 12px; color: var(--lvc-muted); margin: 10px 4px 2px; }
|
|
119
324
|
& .thread .who.visitor { text-align: left; }
|
|
@@ -126,94 +331,64 @@
|
|
|
126
331
|
& .thread .msg .atts { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
|
127
332
|
& .thread .msg .att-img img { max-width: 100%; max-height: 240px; border-radius: 8px; display: block; }
|
|
128
333
|
& .thread .msg .att-audio { display: block; width: 280px; max-width: 100%; height: 36px; }
|
|
129
|
-
& .thread .msg .att-file { display: inline-block; padding: 6px 10px; border-radius: 8px; background: rgba(0,0,0
|
|
130
|
-
& .thread .msg.agent .att-file { background: rgba(255,255,255
|
|
334
|
+
& .thread .msg .att-file { display: inline-block; padding: 6px 10px; border-radius: 8px; background: rgba(0, 0, 0, .06); color: inherit; font-size: 13px; overflow-wrap: anywhere; }
|
|
335
|
+
& .thread .msg.agent .att-file { background: rgba(255, 255, 255, .2); }
|
|
131
336
|
& #new-messages { margin: 12px 0 0; }
|
|
132
337
|
& #new-messages a { font-weight: 600; }
|
|
133
|
-
|
|
338
|
+
|
|
339
|
+
/* Unified composer box (matches the visitor widget): the reply on top, a
|
|
340
|
+
toolbar row below (tools left, send right), all inside one bordered box
|
|
341
|
+
that lights up on focus. */
|
|
134
342
|
& .reply { display: flex; flex-direction: column; gap: 6px; margin: 10px 12px; padding: 8px 10px; border: 1px solid var(--lvc-border); border-radius: 14px; background: var(--lvc-bg); }
|
|
135
343
|
& .reply:focus-within { border-color: var(--lvc-accent); }
|
|
136
|
-
& .reply textarea { -webkit-appearance: none; appearance: none; border: none; background: none; resize: none; outline: none; font: inherit; color: var(--lvc-text);
|
|
137
|
-
& .reply input[type=file].reply-file { font-size: 12px; color: var(--lvc-muted); }
|
|
344
|
+
& .reply textarea { -webkit-appearance: none; appearance: none; min-height: 40px; max-height: 160px; padding: 4px 4px 0; border: none; background: none; resize: none; outline: none; font: inherit; color: var(--lvc-text); overflow-y: auto; }
|
|
345
|
+
& .reply input[type="file"].reply-file { min-height: 0; padding: 0; border: 0; background: none; font-size: 12px; color: var(--lvc-muted); }
|
|
138
346
|
& .reply-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
139
347
|
& .reply-tools { display: flex; align-items: center; gap: 2px; }
|
|
140
|
-
& .reply .attach { width: 32px; height: 32px; padding: 0; border: none; background: none; color: var(--lvc-muted); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
|
348
|
+
& .reply .attach { width: 32px; min-height: 32px; height: 32px; padding: 0; border: none; background: none; color: var(--lvc-muted); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
|
141
349
|
& .reply .attach:hover { background: var(--lvc-border); color: var(--lvc-text); }
|
|
142
350
|
& .reply .attach.recording-on { color: var(--lvc-danger); }
|
|
143
351
|
& .reply.is-recording .attach { display: none; }
|
|
144
352
|
& .reply .chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
145
353
|
& .reply .chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 6px 4px 10px; border: 1px solid var(--lvc-border); border-radius: 999px; background: var(--lvc-surface); font-size: 12px; }
|
|
146
354
|
& .reply .chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
|
147
|
-
& .reply .chip-x { padding: 0 2px; border: none; background: none; color: var(--lvc-muted); font-size: 16px; line-height: 1; cursor: pointer; }
|
|
355
|
+
& .reply .chip-x { min-height: 0; padding: 0 2px; border: none; background: none; color: var(--lvc-muted); font-size: 16px; line-height: 1; cursor: pointer; }
|
|
148
356
|
& .reply .chip-x:hover { color: var(--lvc-text); }
|
|
149
357
|
& .reply .recording { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 4px 0 8px; border: 1px solid var(--lvc-border); border-radius: 999px; background: var(--lvc-surface); color: var(--lvc-muted); font-size: 12px; white-space: nowrap; }
|
|
150
358
|
& .reply .recording[hidden] { display: none; }
|
|
151
359
|
& .reply .recording-status { display: inline-grid; grid-template-columns: 8px 24px 34px; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; }
|
|
152
|
-
& .reply .recording-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lvc-danger); box-shadow: 0 0 0 3px rgba(220,38,38
|
|
360
|
+
& .reply .recording-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lvc-danger); box-shadow: 0 0 0 3px rgba(220, 38, 38, .12); }
|
|
153
361
|
& .reply .recording-rec { font-size: 10px; font-weight: 700; letter-spacing: .04em; color: var(--lvc-danger); }
|
|
154
362
|
& .reply .recording-actions { display: flex; align-items: center; gap: 4px; }
|
|
155
|
-
& .reply .recording-actions button { width: 24px; height: 24px; padding: 0; border: none; border-radius: 50%; background: none; color: var(--lvc-muted); display: flex; align-items: center; justify-content: center; }
|
|
363
|
+
& .reply .recording-actions button { width: 24px; height: 24px; min-height: 24px; padding: 0; border: none; border-radius: 50%; background: none; color: var(--lvc-muted); display: flex; align-items: center; justify-content: center; }
|
|
156
364
|
& .reply .recording-actions button:hover { background: var(--lvc-border); color: var(--lvc-text); }
|
|
157
365
|
& .reply .recording-actions .stop-recording { background: var(--lvc-danger); color: #fff; }
|
|
158
366
|
& .reply .recording-actions .stop-recording:hover { background: #b91c1c; color: #fff; }
|
|
159
|
-
& .reply .send { width: 32px; min-width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%; background: var(--lvc-accent); color: var(--lvc-accent-text); cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
& .
|
|
164
|
-
& .
|
|
165
|
-
& .
|
|
166
|
-
& .
|
|
167
|
-
& .inbox-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--lvc-muted) 14%, transparent); }
|
|
168
|
-
& .inbox-sidebar .tabs a.active .count { background: var(--lvc-border); }
|
|
169
|
-
& .inbox-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--lvc-border); }
|
|
170
|
-
& .inbox-sidebar .filters input[type=search] { max-width: none; }
|
|
171
|
-
& .conversation-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
172
|
-
& .conversation-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; color: var(--lvc-text); border-bottom: 1px solid var(--lvc-border); }
|
|
173
|
-
& .conversation-row:hover { text-decoration: none; background: var(--lvc-bg); }
|
|
174
|
-
& .conversation-row.active { background: color-mix(in srgb, var(--lvc-accent) 9%, var(--lvc-surface)); box-shadow: inset 3px 0 0 var(--lvc-accent); }
|
|
175
|
-
& .conversation-row.unread .conversation-name,
|
|
176
|
-
& .conversation-row.unread .conversation-preview { font-weight: 700; }
|
|
177
|
-
& .conversation-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
178
|
-
& .conversation-topline,
|
|
179
|
-
& .conversation-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
|
180
|
-
& .conversation-name,
|
|
181
|
-
& .conversation-preview,
|
|
182
|
-
& .conversation-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
183
|
-
& .conversation-name { font-weight: 600; }
|
|
184
|
-
& .conversation-time { margin-left: auto; white-space: nowrap; }
|
|
185
|
-
& .conversation-preview { display: block; }
|
|
186
|
-
& .conversation-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; padding-top: 1px; }
|
|
187
|
-
& .conversation-side .avatars { min-height: 26px; }
|
|
188
|
-
& .inbox-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--lvc-border); }
|
|
189
|
-
& .inbox-thread { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
190
|
-
& .inbox-thread .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 0; }
|
|
191
|
-
& .conversation-panel .head-row,
|
|
192
|
-
& .conversation-panel .context-line { flex: 0 0 auto; }
|
|
193
|
-
& .conversation-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-radius: 8px; }
|
|
194
|
-
& .conversation-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
195
|
-
& .conversation-panel .reply { flex: 0 0 auto; }
|
|
196
|
-
& .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
|
|
197
|
-
& .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
|
|
198
|
-
& .empty-state p { margin: 0; }
|
|
199
|
-
& .mobile-back { display: none; }
|
|
367
|
+
& .reply .send { width: 32px; min-width: 32px; height: 32px; min-height: 32px; padding: 0; border: none; border-radius: 50%; background: var(--lvc-accent); color: var(--lvc-accent-text); cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
|
368
|
+
|
|
369
|
+
/* Inside the thread pane only the messages scroll, so a reply never jumps the
|
|
370
|
+
page and the composer stays put. */
|
|
371
|
+
& .detail-panel .panel-head { flex: 0 0 auto; }
|
|
372
|
+
& .detail-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-radius: 8px; }
|
|
373
|
+
& .detail-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
374
|
+
& .detail-panel .reply { flex: 0 0 auto; }
|
|
200
375
|
}
|
|
201
376
|
|
|
202
|
-
/*
|
|
203
|
-
|
|
204
|
-
.lvc-page { margin: 0; background: var(--lvc-bg); color: var(--lvc-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
205
|
-
/* Desktop inbox: a durable conversation list on the left and the active thread on the right, with each pane owning its own scroll. */
|
|
206
|
-
.lvc-inbox, .lvc-inbox .container { height: 100vh; height: 100dvh; }
|
|
207
|
-
.lvc-inbox { overflow: hidden; }
|
|
208
|
-
.lvc-inbox .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
|
|
209
|
-
.lvc-inbox .container h1 { flex: 0 0 auto; margin-bottom: 14px; }
|
|
210
|
-
/* Conversation page: fill the viewport and scroll the THREAD, not the page — so a reply never jumps the page, and the composer stays put. */
|
|
377
|
+
/* The conversation page: same thread, its own page. Fills the viewport and
|
|
378
|
+
scrolls the thread rather than the page. */
|
|
211
379
|
.lvc-convo, .lvc-convo .container { height: 100vh; height: 100dvh; }
|
|
212
380
|
.lvc-convo { overflow: hidden; }
|
|
213
381
|
.lvc-convo .container { display: flex; flex-direction: column; padding-bottom: 16px; }
|
|
214
|
-
.lvc-convo .breadcrumb,
|
|
215
|
-
|
|
216
|
-
.lvc-convo .
|
|
217
|
-
.lvc-convo .
|
|
218
|
-
|
|
219
|
-
|
|
382
|
+
.lvc-convo .breadcrumb,
|
|
383
|
+
.lvc-convo .panel-head,
|
|
384
|
+
.lvc-convo .flash { flex: 0 0 auto; }
|
|
385
|
+
.lvc-convo .detail-panel { flex: 1 1 auto; min-height: 0; }
|
|
386
|
+
|
|
387
|
+
@media (max-width: 760px) {
|
|
388
|
+
.lvc-dashboard {
|
|
389
|
+
& .detail-panel .panel-head h1 { font-size: 18px; }
|
|
390
|
+
& .status-toggle { width: 152px; min-height: 34px; }
|
|
391
|
+
& .status-toggle span { padding: 0 7px; font-size: 12px; }
|
|
392
|
+
& .thread .msg { max-width: 88%; }
|
|
393
|
+
}
|
|
394
|
+
}
|
data/lib/livechat/dashboard.js
CHANGED
|
@@ -295,7 +295,7 @@
|
|
|
295
295
|
// (new message, new thread, resolve/reopen), just reload — unless the
|
|
296
296
|
// agent is mid-search, whose typing must never be eaten.
|
|
297
297
|
function watchIndex() {
|
|
298
|
-
var index = document.getElementById("lvc-
|
|
298
|
+
var index = document.getElementById("lvc-poll");
|
|
299
299
|
if (!index) return;
|
|
300
300
|
|
|
301
301
|
var token = index.getAttribute("data-token");
|
data/lib/livechat/version.rb
CHANGED