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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/MIT-LICENSE +20 -0
- data/README.md +160 -0
- data/Rakefile +11 -0
- data/app/controllers/livechat/application_controller.rb +76 -0
- data/app/controllers/livechat/conversations_controller.rb +57 -0
- data/app/controllers/livechat/messages_controller.rb +21 -0
- data/app/controllers/livechat/visitor_controller.rb +108 -0
- data/app/controllers/livechat/widgets_controller.rb +36 -0
- data/app/helpers/livechat/widget_helper.rb +37 -0
- data/app/mailers/livechat/mailer.rb +51 -0
- data/app/models/livechat/application_record.rb +7 -0
- data/app/models/livechat/conversation.rb +108 -0
- data/app/models/livechat/message.rb +53 -0
- data/app/views/layouts/livechat/application.html.erb +97 -0
- data/app/views/livechat/conversations/index.html.erb +62 -0
- data/app/views/livechat/conversations/show.html.erb +73 -0
- data/app/views/livechat/mailer/new_agent_reply.text.erb +7 -0
- data/app/views/livechat/mailer/new_visitor_message.text.erb +7 -0
- data/config/locales/livechat.ar.yml +44 -0
- data/config/locales/livechat.bg.yml +44 -0
- data/config/locales/livechat.bn.yml +44 -0
- data/config/locales/livechat.de.yml +44 -0
- data/config/locales/livechat.el.yml +44 -0
- data/config/locales/livechat.en.yml +44 -0
- data/config/locales/livechat.es.yml +44 -0
- data/config/locales/livechat.fr.yml +44 -0
- data/config/locales/livechat.hi.yml +44 -0
- data/config/locales/livechat.hr.yml +44 -0
- data/config/locales/livechat.id.yml +44 -0
- data/config/locales/livechat.it.yml +44 -0
- data/config/locales/livechat.ja.yml +44 -0
- data/config/locales/livechat.ko.yml +44 -0
- data/config/locales/livechat.lb.yml +44 -0
- data/config/locales/livechat.nl.yml +44 -0
- data/config/locales/livechat.pl.yml +44 -0
- data/config/locales/livechat.pt.yml +44 -0
- data/config/locales/livechat.ro.yml +44 -0
- data/config/locales/livechat.ru.yml +44 -0
- data/config/locales/livechat.th.yml +44 -0
- data/config/locales/livechat.tr.yml +44 -0
- data/config/locales/livechat.uk.yml +44 -0
- data/config/locales/livechat.ur.yml +44 -0
- data/config/locales/livechat.vi.yml +44 -0
- data/config/locales/livechat.zh-CN.yml +44 -0
- data/config/routes.rb +27 -0
- data/lib/generators/livechat/install/install_generator.rb +42 -0
- data/lib/generators/livechat/install/templates/create_livechat_tables.rb.tt +36 -0
- data/lib/generators/livechat/install/templates/initializer.rb +59 -0
- data/lib/livechat/configuration.rb +103 -0
- data/lib/livechat/dashboard.js +58 -0
- data/lib/livechat/engine.rb +13 -0
- data/lib/livechat/notifications.rb +41 -0
- data/lib/livechat/version.rb +5 -0
- data/lib/livechat/widget.js +563 -0
- data/lib/livechat/widget.rb +115 -0
- data/lib/livechat.rb +56 -0
- metadata +125 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# One thread per visitor — like a phone line, not a ticket queue. A visitor
|
|
5
|
+
# writing into a resolved conversation reopens it; history stays in place.
|
|
6
|
+
# Visitor identity is loose (no foreign key to the host's user table):
|
|
7
|
+
# signed-in visitors are keyed by visitor_id, guests by a cookie token.
|
|
8
|
+
class Conversation < ApplicationRecord
|
|
9
|
+
STATUSES = %w[open resolved].freeze
|
|
10
|
+
|
|
11
|
+
has_many :messages, dependent: :destroy
|
|
12
|
+
|
|
13
|
+
validates :status, inclusion: { in: STATUSES }
|
|
14
|
+
validates :visitor_email, length: { maximum: 254 },
|
|
15
|
+
format: { with: URI::MailTo::EMAIL_REGEXP },
|
|
16
|
+
allow_blank: true
|
|
17
|
+
validate :some_visitor_identity
|
|
18
|
+
|
|
19
|
+
scope :recent_first, -> { order(last_activity_at: :desc, id: :desc) }
|
|
20
|
+
|
|
21
|
+
STATUSES.each do |status|
|
|
22
|
+
define_method(:"#{status}?") { self.status == status }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The visitor's ongoing thread. Signed-in identity wins; guests are found
|
|
26
|
+
# by their cookie token (only unclaimed threads — a token that has been
|
|
27
|
+
# adopted by a user belongs to that user now).
|
|
28
|
+
def self.for_visitor(visitor_id: nil, visitor_token: nil)
|
|
29
|
+
if visitor_id.present?
|
|
30
|
+
where(visitor_id: visitor_id.to_s).order(id: :desc).first
|
|
31
|
+
elsif visitor_token.present?
|
|
32
|
+
where(visitor_token: visitor_token, visitor_id: nil).order(id: :desc).first
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A guest who signs in keeps their conversation: threads started under
|
|
37
|
+
# the cookie token are adopted by the user, once, permanently.
|
|
38
|
+
def self.claim!(visitor_token:, visitor_id:, visitor_label: nil)
|
|
39
|
+
return if visitor_token.blank? || visitor_id.blank?
|
|
40
|
+
|
|
41
|
+
where(visitor_token: visitor_token, visitor_id: nil)
|
|
42
|
+
.update_all(visitor_id: visitor_id.to_s, visitor_label: visitor_label)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def post_visitor_message!(body)
|
|
46
|
+
transaction do
|
|
47
|
+
update!(status: 'open') if resolved?
|
|
48
|
+
messages.create!(author_type: 'visitor', body: body)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def post_agent_message!(body:, agent_id:, agent_label:)
|
|
53
|
+
messages.create!(author_type: 'agent', body: body,
|
|
54
|
+
agent_id: agent_id.to_s, agent_label: agent_label)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_by!(agent_label)
|
|
58
|
+
return if resolved?
|
|
59
|
+
|
|
60
|
+
transaction do
|
|
61
|
+
update!(status: 'resolved')
|
|
62
|
+
messages.create!(author_type: 'system', event: 'resolved', agent_label: agent_label)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reopen_by!(agent_label)
|
|
67
|
+
return if open?
|
|
68
|
+
|
|
69
|
+
transaction do
|
|
70
|
+
update!(status: 'open')
|
|
71
|
+
messages.create!(author_type: 'system', event: 'reopened', agent_label: agent_label)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Denormalized inbox-list fields, refreshed on every message. Written
|
|
76
|
+
# with update_columns: cheap, no callbacks, no updated_at churn.
|
|
77
|
+
def register_message(message)
|
|
78
|
+
updates = { last_activity_at: message.created_at }
|
|
79
|
+
updates[:last_message_preview] = message.body.to_s.truncate(140) unless message.system?
|
|
80
|
+
update_columns(updates)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def unread_from_visitor_count = messages.from_visitor.unread.count
|
|
84
|
+
|
|
85
|
+
def mark_read_for_agent!
|
|
86
|
+
messages.from_visitor.unread.update_all(read_at: Time.current)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def mark_read_for_visitor!
|
|
90
|
+
messages.from_agent.unread.update_all(read_at: Time.current)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def display_name
|
|
94
|
+
visitor_label.presence || visitor_email.presence || "Visitor ##{id}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Every conversation belongs to someone — a signed-in user or at least a
|
|
100
|
+
# guest cookie. An unattributable thread could never be shown to its
|
|
101
|
+
# visitor again.
|
|
102
|
+
def some_visitor_identity
|
|
103
|
+
return if visitor_id.present? || visitor_token.present?
|
|
104
|
+
|
|
105
|
+
errors.add(:base, 'needs a visitor_id or a visitor_token')
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# One chat entry. Three kinds of author: the visitor, a named agent, or the
|
|
5
|
+
# system (resolve/reopen events, so a thread worked by several teammates
|
|
6
|
+
# stays legible). Agent attribution is stored as loose fields — agent_id
|
|
7
|
+
# plus a label resolved at write time — no foreign key to the host's users.
|
|
8
|
+
class Message < ApplicationRecord
|
|
9
|
+
AUTHOR_TYPES = %w[visitor agent system].freeze
|
|
10
|
+
EVENTS = %w[resolved reopened].freeze
|
|
11
|
+
MAX_BODY_LENGTH = 5_000
|
|
12
|
+
|
|
13
|
+
belongs_to :conversation
|
|
14
|
+
|
|
15
|
+
validates :author_type, inclusion: { in: AUTHOR_TYPES }
|
|
16
|
+
validates :body, presence: true, length: { maximum: MAX_BODY_LENGTH }, unless: :system?
|
|
17
|
+
validates :agent_id, presence: true, if: :agent?
|
|
18
|
+
validates :event, presence: true, inclusion: { in: EVENTS }, if: :system?
|
|
19
|
+
validates :event, absence: true, unless: :system?
|
|
20
|
+
|
|
21
|
+
scope :chronological, -> { order(:id) }
|
|
22
|
+
scope :after_id, ->(id) { where(id: (id.to_i + 1)..) }
|
|
23
|
+
scope :from_visitor, -> { where(author_type: 'visitor') }
|
|
24
|
+
scope :from_agent, -> { where(author_type: 'agent') }
|
|
25
|
+
scope :unread, -> { where(read_at: nil) }
|
|
26
|
+
|
|
27
|
+
after_create_commit { conversation.register_message(self) }
|
|
28
|
+
|
|
29
|
+
AUTHOR_TYPES.each do |type|
|
|
30
|
+
define_method(:"#{type}?") { author_type == type }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read? = read_at.present?
|
|
34
|
+
|
|
35
|
+
# What the widget shows as the sender of an agent message — run through
|
|
36
|
+
# config.agent_display_name, so hosts control how much of the team's
|
|
37
|
+
# identity visitors see.
|
|
38
|
+
def public_label
|
|
39
|
+
agent? ? Livechat.display_name_for(agent_label) : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def as_widget_json
|
|
43
|
+
{
|
|
44
|
+
id: id,
|
|
45
|
+
author: author_type,
|
|
46
|
+
label: public_label,
|
|
47
|
+
body: system? ? nil : body,
|
|
48
|
+
event: event,
|
|
49
|
+
at: created_at.iso8601
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= t('livechat.dashboard.title', default: 'Chat') %> — <%= Livechat.app_name %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%# Same-origin script instead of inline handlers, so thread polling and
|
|
8
|
+
keyboard submit work under strict script-src CSPs. %>
|
|
9
|
+
<%= javascript_include_tag "#{dashboard_script_path}?v=#{Livechat::Widget.dashboard_fingerprint}",
|
|
10
|
+
defer: true, nonce: true %>
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
color-scheme: light dark;
|
|
14
|
+
--bg: #f6f7f9; --surface: #fff; --text: #1c2024; --muted: #6b7280;
|
|
15
|
+
--border: #e5e7eb; --accent: #2563eb; --accent-text: #fff;
|
|
16
|
+
--open: #16a34a; --resolved: #6b7280; --danger: #dc2626;
|
|
17
|
+
}
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root {
|
|
20
|
+
--bg: #111418; --surface: #1a1f26; --text: #e6e8ea; --muted: #9aa2ab;
|
|
21
|
+
--border: #2a313a; --accent: #3b82f6;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
body { margin: 0; background: var(--bg); color: var(--text);
|
|
26
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
27
|
+
a { color: var(--accent); text-decoration: none; }
|
|
28
|
+
a:hover { text-decoration: underline; }
|
|
29
|
+
.container { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
30
|
+
h1 { font-size: 22px; margin: 0 0 16px; }
|
|
31
|
+
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
|
|
32
|
+
.tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--muted); }
|
|
33
|
+
.tabs a.active { color: var(--text); font-weight: 600; border: 1px solid var(--border);
|
|
34
|
+
border-bottom: 2px solid var(--surface); background: var(--surface); margin-bottom: -1px; }
|
|
35
|
+
.tabs a:hover { text-decoration: none; color: var(--text); }
|
|
36
|
+
.count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px;
|
|
37
|
+
background: var(--border); font-size: 12px; text-align: center; }
|
|
38
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
|
39
|
+
.card.pad { padding: 16px; }
|
|
40
|
+
table { width: 100%; border-collapse: collapse; }
|
|
41
|
+
th, td { padding: 10px 14px; text-align: left; vertical-align: top; }
|
|
42
|
+
th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
|
|
43
|
+
border-bottom: 1px solid var(--border); }
|
|
44
|
+
tr + tr td { border-top: 1px solid var(--border); }
|
|
45
|
+
tr.unread td { font-weight: 600; }
|
|
46
|
+
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
|
47
|
+
color: #fff; white-space: nowrap; }
|
|
48
|
+
.badge.status-open { background: var(--open); }
|
|
49
|
+
.badge.status-resolved { background: var(--resolved); }
|
|
50
|
+
.badge.unread-count { background: var(--danger); }
|
|
51
|
+
.muted { color: var(--muted); font-size: 13px; }
|
|
52
|
+
.pager { margin-top: 16px; display: flex; gap: 16px; }
|
|
53
|
+
.empty { padding: 48px 16px; text-align: center; color: var(--muted); }
|
|
54
|
+
.breadcrumb { margin-bottom: 12px; font-size: 13px; }
|
|
55
|
+
.flash { margin: 0 0 16px; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--border);
|
|
56
|
+
background: var(--surface); }
|
|
57
|
+
.flash.alert { color: var(--danger); border-color: var(--danger); }
|
|
58
|
+
.head-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
59
|
+
.head-row h1 { margin: 0; flex: 1; }
|
|
60
|
+
.head-row form { display: inline; }
|
|
61
|
+
button, .button { padding: 8px 14px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
|
|
62
|
+
background: var(--surface); color: var(--text); font: inherit; }
|
|
63
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
64
|
+
.case-id { color: var(--muted); font-weight: 400; font-size: 15px; }
|
|
65
|
+
.context-line { margin: -6px 0 16px; overflow-wrap: anywhere; }
|
|
66
|
+
.thread { display: flex; flex-direction: column; gap: 4px; padding: 16px; }
|
|
67
|
+
.thread .who { font-size: 12px; color: var(--muted); margin: 10px 4px 2px; }
|
|
68
|
+
.thread .who.visitor { text-align: left; }
|
|
69
|
+
.thread .who.agent { text-align: right; }
|
|
70
|
+
.thread .msg { max-width: 78%; padding: 8px 12px; border-radius: 14px; white-space: pre-wrap;
|
|
71
|
+
overflow-wrap: anywhere; }
|
|
72
|
+
.thread .msg.visitor { align-self: flex-start; background: var(--bg); border: 1px solid var(--border);
|
|
73
|
+
border-bottom-left-radius: 4px; }
|
|
74
|
+
.thread .msg.agent { align-self: flex-end; background: var(--accent); color: var(--accent-text);
|
|
75
|
+
border-bottom-right-radius: 4px; }
|
|
76
|
+
.thread .system { align-self: center; color: var(--muted); font-size: 12px; margin: 8px 0; }
|
|
77
|
+
#new-messages { margin: 12px 0 0; }
|
|
78
|
+
#new-messages a { font-weight: 600; }
|
|
79
|
+
.reply { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border); }
|
|
80
|
+
.reply textarea { flex: 1; resize: vertical; min-height: 44px; max-height: 200px; padding: 8px 10px;
|
|
81
|
+
border: 1px solid var(--border); border-radius: 10px; font: inherit;
|
|
82
|
+
background: var(--bg); color: var(--text); }
|
|
83
|
+
.reply button.send { align-self: flex-end; width: 42px; min-width: 42px; height: 42px; padding: 0;
|
|
84
|
+
display: flex; align-items: center; justify-content: center;
|
|
85
|
+
background: var(--accent); border-color: var(--accent); color: var(--accent-text);
|
|
86
|
+
border-radius: 10px; }
|
|
87
|
+
</style>
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
<div class="container">
|
|
91
|
+
<% if flash[:alert].present? %>
|
|
92
|
+
<p class="flash alert"><%= flash[:alert] %></p>
|
|
93
|
+
<% end %>
|
|
94
|
+
<%= yield %>
|
|
95
|
+
</div>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<h1><%= t('livechat.dashboard.title', default: 'Chat') %></h1>
|
|
2
|
+
|
|
3
|
+
<div class="tabs">
|
|
4
|
+
<% Livechat::Conversation::STATUSES.each do |status| %>
|
|
5
|
+
<%= link_to conversations_path(status: status), class: ('active' if @status == status) do %>
|
|
6
|
+
<%= t("livechat.statuses.#{status}", default: status.humanize) %>
|
|
7
|
+
<span class="count"><%= @counts.fetch(status, 0) %></span>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="card">
|
|
13
|
+
<% if @conversations.empty? %>
|
|
14
|
+
<p class="empty"><%= t('livechat.dashboard.empty',
|
|
15
|
+
default: 'Nothing here. When a visitor writes, the conversation lands on this page.') %></p>
|
|
16
|
+
<% else %>
|
|
17
|
+
<table>
|
|
18
|
+
<thead>
|
|
19
|
+
<tr>
|
|
20
|
+
<th>#</th>
|
|
21
|
+
<th><%= t('livechat.dashboard.visitor', default: 'Visitor') %></th>
|
|
22
|
+
<th><%= t('livechat.dashboard.last_message', default: 'Last message') %></th>
|
|
23
|
+
<th><%= t('livechat.dashboard.updated', default: 'Updated') %></th>
|
|
24
|
+
</tr>
|
|
25
|
+
</thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<% @conversations.each do |conversation| %>
|
|
28
|
+
<% unread = @unread.fetch(conversation.id, 0) %>
|
|
29
|
+
<tr class="<%= 'unread' if unread.positive? %>">
|
|
30
|
+
<td class="muted"><%= link_to "##{conversation.id}", conversation_path(conversation), class: 'case-id' %></td>
|
|
31
|
+
<td>
|
|
32
|
+
<%= link_to conversation.display_name, conversation_path(conversation) %>
|
|
33
|
+
<% if unread.positive? %>
|
|
34
|
+
<span class="badge unread-count"><%= unread %></span>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% if conversation.visitor_email.present? && conversation.visitor_email != conversation.display_name %>
|
|
37
|
+
<div class="muted"><%= conversation.visitor_email %></div>
|
|
38
|
+
<% end %>
|
|
39
|
+
</td>
|
|
40
|
+
<td class="muted"><%= conversation.last_message_preview %></td>
|
|
41
|
+
<td class="muted">
|
|
42
|
+
<% if conversation.last_activity_at %>
|
|
43
|
+
<%= time_ago_in_words(conversation.last_activity_at) %>
|
|
44
|
+
<% end %>
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
<% end %>
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="pager">
|
|
54
|
+
<% if @offset.positive? %>
|
|
55
|
+
<%= link_to t('livechat.dashboard.newer', default: 'Newer'),
|
|
56
|
+
conversations_path(status: @status, offset: [@offset - Livechat::ConversationsController::PER_PAGE, 0].max) %>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% if @more %>
|
|
59
|
+
<%= link_to t('livechat.dashboard.older', default: 'Older'),
|
|
60
|
+
conversations_path(status: @status, offset: @offset + Livechat::ConversationsController::PER_PAGE) %>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<p class="breadcrumb">
|
|
2
|
+
<%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}", conversations_path %>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<div class="head-row">
|
|
6
|
+
<h1><%= @conversation.display_name %> <span class="case-id">#<%= @conversation.id %></span></h1>
|
|
7
|
+
<span class="badge status-<%= @conversation.status %>">
|
|
8
|
+
<%= t("livechat.statuses.#{@conversation.status}", default: @conversation.status.humanize) %>
|
|
9
|
+
</span>
|
|
10
|
+
<% if @conversation.open? %>
|
|
11
|
+
<%= button_to t('livechat.dashboard.resolve', default: 'Resolve'),
|
|
12
|
+
resolve_conversation_path(@conversation) %>
|
|
13
|
+
<% else %>
|
|
14
|
+
<%= button_to t('livechat.dashboard.reopen', default: 'Reopen'),
|
|
15
|
+
reopen_conversation_path(@conversation) %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<%# One quiet line of live context: who to email, the language to answer in,
|
|
20
|
+
and the page of their LAST message (refreshed on every visitor write). %>
|
|
21
|
+
<p class="muted context-line">
|
|
22
|
+
<% context = [] %>
|
|
23
|
+
<% context << mail_to(@conversation.visitor_email) if @conversation.visitor_email.present? %>
|
|
24
|
+
<% context << @conversation.locale if @conversation.locale.present? %>
|
|
25
|
+
<% if @conversation.page_url.present? %>
|
|
26
|
+
<% context << link_to(@conversation.page_url.sub(%r{\Ahttps?://}, ''), @conversation.page_url,
|
|
27
|
+
target: '_blank', rel: 'noopener') %>
|
|
28
|
+
<% end %>
|
|
29
|
+
<%= safe_join(context, ' · ') %>
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
<div class="card">
|
|
33
|
+
<div class="thread" id="thread"
|
|
34
|
+
data-poll-url="<%= poll_conversation_path(@conversation) %>"
|
|
35
|
+
data-latest="<%= @messages.last&.id.to_i %>">
|
|
36
|
+
<% previous_author = nil %>
|
|
37
|
+
<% @messages.each do |message| %>
|
|
38
|
+
<% if message.system? %>
|
|
39
|
+
<p class="system" id="message-<%= message.id %>">
|
|
40
|
+
<%= t("livechat.events.#{message.event}", agent: message.agent_label,
|
|
41
|
+
default: "%{agent} #{message.event} the conversation") %>
|
|
42
|
+
· <%= message.created_at.to_fs(:short) %>
|
|
43
|
+
</p>
|
|
44
|
+
<% previous_author = nil %>
|
|
45
|
+
<% else %>
|
|
46
|
+
<% author_key = "#{message.author_type}:#{message.agent_label}" %>
|
|
47
|
+
<% if author_key != previous_author %>
|
|
48
|
+
<p class="who <%= message.author_type %>">
|
|
49
|
+
<%= message.agent? ? message.agent_label : @conversation.display_name %>
|
|
50
|
+
· <%= message.created_at.to_fs(:short) %>
|
|
51
|
+
</p>
|
|
52
|
+
<% previous_author = author_key %>
|
|
53
|
+
<% end %>
|
|
54
|
+
<div class="msg <%= message.author_type %>" id="message-<%= message.id %>"><%= message.body %></div>
|
|
55
|
+
<% end %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<p id="new-messages" class="system" hidden>
|
|
60
|
+
<a href=""><%= t('livechat.dashboard.new_messages', default: 'New messages — refresh') %></a>
|
|
61
|
+
</p>
|
|
62
|
+
|
|
63
|
+
<%= form_with url: conversation_messages_path(@conversation), method: :post, class: 'reply' do |form| %>
|
|
64
|
+
<%= form.text_area :body, rows: 2, autofocus: true, required: true,
|
|
65
|
+
placeholder: t('livechat.dashboard.reply_placeholder', default: 'Write your reply…') %>
|
|
66
|
+
<button type="submit" class="primary send"
|
|
67
|
+
aria-label="<%= t('livechat.dashboard.send', default: 'Send') %>"
|
|
68
|
+
title="<%= t('livechat.dashboard.send', default: 'Send') %>">
|
|
69
|
+
<%# Paper airplane (Heroicons, MIT) — same icon as the widget. %>
|
|
70
|
+
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z"/></svg>
|
|
71
|
+
</button>
|
|
72
|
+
<% end %>
|
|
73
|
+
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
ar:
|
|
2
|
+
livechat:
|
|
3
|
+
launcher: "تحدث معنا"
|
|
4
|
+
greeting: "مرحبًا! كيف يمكننا مساعدتك؟"
|
|
5
|
+
reply_time: "نرد عادةً في غضون ساعات قليلة."
|
|
6
|
+
placeholder: "اكتب رسالة…"
|
|
7
|
+
send: "إرسال"
|
|
8
|
+
close: "إغلاق"
|
|
9
|
+
you: "أنت"
|
|
10
|
+
team: "الدعم"
|
|
11
|
+
email_prompt: "احصل على نسخة من ردنا عبر البريد الإلكتروني:"
|
|
12
|
+
email_placeholder: "you@example.com"
|
|
13
|
+
email_save: "حفظ"
|
|
14
|
+
email_saved: "سنرد أيضًا عبر البريد الإلكتروني."
|
|
15
|
+
event_resolved: "تم حل المحادثة"
|
|
16
|
+
event_reopened: "تمت إعادة فتح المحادثة"
|
|
17
|
+
error_send: "تعذر الإرسال. يرجى المحاولة مرة أخرى."
|
|
18
|
+
error_rate_limited: "رسائل كثيرة جدًا. يرجى الانتظار قليلًا والمحاولة مرة أخرى."
|
|
19
|
+
unread_aria: "رسائل غير مقروءة"
|
|
20
|
+
statuses:
|
|
21
|
+
open: "مفتوحة"
|
|
22
|
+
resolved: "محلولة"
|
|
23
|
+
events:
|
|
24
|
+
resolved: "قام %{agent} بحل المحادثة"
|
|
25
|
+
reopened: "قام %{agent} بإعادة فتح المحادثة"
|
|
26
|
+
dashboard:
|
|
27
|
+
title: "الدردشة"
|
|
28
|
+
empty: "لا يوجد شيء هنا. عندما يكتب زائر، تظهر المحادثة في هذه الصفحة."
|
|
29
|
+
visitor: "الزائر"
|
|
30
|
+
last_message: "آخر رسالة"
|
|
31
|
+
updated: "آخر تحديث"
|
|
32
|
+
back: "جميع المحادثات"
|
|
33
|
+
resolve: "حل"
|
|
34
|
+
reopen: "إعادة فتح"
|
|
35
|
+
reply_placeholder: "اكتب ردك…"
|
|
36
|
+
send: "إرسال"
|
|
37
|
+
new_messages: "رسائل جديدة — تحديث"
|
|
38
|
+
newer: "الأحدث"
|
|
39
|
+
older: "الأقدم"
|
|
40
|
+
mail:
|
|
41
|
+
visitor_subject: "كتب لك %{name} على %{app}"
|
|
42
|
+
reply_subject: "رد عليك %{name} على %{app}"
|
|
43
|
+
answer: "الرد"
|
|
44
|
+
continue: "متابعة المحادثة"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
bg:
|
|
2
|
+
livechat:
|
|
3
|
+
launcher: "Пишете ни"
|
|
4
|
+
greeting: "Здравейте! Как можем да помогнем?"
|
|
5
|
+
reply_time: "Обикновено отговаряме в рамките на няколко часа."
|
|
6
|
+
placeholder: "Напишете съобщение…"
|
|
7
|
+
send: "Изпрати"
|
|
8
|
+
close: "Затвори"
|
|
9
|
+
you: "Вие"
|
|
10
|
+
team: "Поддръжка"
|
|
11
|
+
email_prompt: "Получете копие от нашия отговор по имейл:"
|
|
12
|
+
email_placeholder: "you@example.com"
|
|
13
|
+
email_save: "Запази"
|
|
14
|
+
email_saved: "Ще отговорим и по имейл."
|
|
15
|
+
event_resolved: "Разговорът е разрешен"
|
|
16
|
+
event_reopened: "Разговорът е отворен отново"
|
|
17
|
+
error_send: "Изпращането не бе успешно. Моля, опитайте отново."
|
|
18
|
+
error_rate_limited: "Твърде много съобщения. Моля, изчакайте малко и опитайте отново."
|
|
19
|
+
unread_aria: "непрочетени съобщения"
|
|
20
|
+
statuses:
|
|
21
|
+
open: "Отворен"
|
|
22
|
+
resolved: "Разрешен"
|
|
23
|
+
events:
|
|
24
|
+
resolved: "%{agent} разреши разговора"
|
|
25
|
+
reopened: "%{agent} отвори отново разговора"
|
|
26
|
+
dashboard:
|
|
27
|
+
title: "Чат"
|
|
28
|
+
empty: "Тук няма нищо. Когато посетител напише съобщение, разговорът се появява на тази страница."
|
|
29
|
+
visitor: "Посетител"
|
|
30
|
+
last_message: "Последно съобщение"
|
|
31
|
+
updated: "Обновено"
|
|
32
|
+
back: "Всички разговори"
|
|
33
|
+
resolve: "Разреши"
|
|
34
|
+
reopen: "Отвори отново"
|
|
35
|
+
reply_placeholder: "Напишете отговора си…"
|
|
36
|
+
send: "Изпрати"
|
|
37
|
+
new_messages: "Нови съобщения — обновете"
|
|
38
|
+
newer: "По-нови"
|
|
39
|
+
older: "По-стари"
|
|
40
|
+
mail:
|
|
41
|
+
visitor_subject: "%{name} ви писа в %{app}"
|
|
42
|
+
reply_subject: "%{name} ви отговори в %{app}"
|
|
43
|
+
answer: "Отговори"
|
|
44
|
+
continue: "Продължете разговора"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
bn:
|
|
2
|
+
livechat:
|
|
3
|
+
launcher: "আমাদের সাথে চ্যাট করুন"
|
|
4
|
+
greeting: "হ্যালো! আমরা কীভাবে সাহায্য করতে পারি?"
|
|
5
|
+
reply_time: "আমরা সাধারণত কয়েক ঘণ্টার মধ্যে উত্তর দিই।"
|
|
6
|
+
placeholder: "একটি বার্তা লিখুন…"
|
|
7
|
+
send: "পাঠান"
|
|
8
|
+
close: "বন্ধ করুন"
|
|
9
|
+
you: "আপনি"
|
|
10
|
+
team: "সাপোর্ট"
|
|
11
|
+
email_prompt: "আমাদের উত্তরের একটি কপি ইমেইলে পান:"
|
|
12
|
+
email_placeholder: "you@example.com"
|
|
13
|
+
email_save: "সংরক্ষণ করুন"
|
|
14
|
+
email_saved: "আমরা ইমেইলেও উত্তর দেব।"
|
|
15
|
+
event_resolved: "কথোপকথন সমাধান হয়েছে"
|
|
16
|
+
event_reopened: "কথোপকথন আবার খোলা হয়েছে"
|
|
17
|
+
error_send: "পাঠানো যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।"
|
|
18
|
+
error_rate_limited: "অনেক বেশি বার্তা। একটু অপেক্ষা করে আবার চেষ্টা করুন।"
|
|
19
|
+
unread_aria: "অপঠিত বার্তা"
|
|
20
|
+
statuses:
|
|
21
|
+
open: "খোলা"
|
|
22
|
+
resolved: "সমাধান হয়েছে"
|
|
23
|
+
events:
|
|
24
|
+
resolved: "%{agent} কথোপকথনটি সমাধান করেছেন"
|
|
25
|
+
reopened: "%{agent} কথোপকথনটি আবার খুলেছেন"
|
|
26
|
+
dashboard:
|
|
27
|
+
title: "চ্যাট"
|
|
28
|
+
empty: "এখানে এখনও কিছু নেই। কোনো দর্শনার্থী লিখলে কথোপকথনটি এই পৃষ্ঠায় দেখা যাবে।"
|
|
29
|
+
visitor: "দর্শনার্থী"
|
|
30
|
+
last_message: "শেষ বার্তা"
|
|
31
|
+
updated: "আপডেট হয়েছে"
|
|
32
|
+
back: "সব কথোপকথন"
|
|
33
|
+
resolve: "সমাধান করুন"
|
|
34
|
+
reopen: "আবার খুলুন"
|
|
35
|
+
reply_placeholder: "আপনার উত্তর লিখুন…"
|
|
36
|
+
send: "পাঠান"
|
|
37
|
+
new_messages: "নতুন বার্তা — রিফ্রেশ করুন"
|
|
38
|
+
newer: "নতুনতর"
|
|
39
|
+
older: "পুরনো"
|
|
40
|
+
mail:
|
|
41
|
+
visitor_subject: "%{name} আপনাকে %{app}-এ লিখেছেন"
|
|
42
|
+
reply_subject: "%{name} আপনাকে %{app}-এ উত্তর দিয়েছেন"
|
|
43
|
+
answer: "উত্তর দিন"
|
|
44
|
+
continue: "কথোপকথন চালিয়ে যান"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
de:
|
|
2
|
+
livechat:
|
|
3
|
+
launcher: "Chatten Sie mit uns"
|
|
4
|
+
greeting: "Hallo! Wie können wir helfen?"
|
|
5
|
+
reply_time: "Wir antworten in der Regel innerhalb weniger Stunden."
|
|
6
|
+
placeholder: "Nachricht schreiben…"
|
|
7
|
+
send: "Senden"
|
|
8
|
+
close: "Schließen"
|
|
9
|
+
you: "Sie"
|
|
10
|
+
team: "Support"
|
|
11
|
+
email_prompt: "Erhalten Sie eine Kopie unserer Antwort per E-Mail:"
|
|
12
|
+
email_placeholder: "sie@beispiel.de"
|
|
13
|
+
email_save: "Speichern"
|
|
14
|
+
email_saved: "Wir antworten auch per E-Mail."
|
|
15
|
+
event_resolved: "Unterhaltung gelöst"
|
|
16
|
+
event_reopened: "Unterhaltung wieder geöffnet"
|
|
17
|
+
error_send: "Senden fehlgeschlagen. Bitte versuchen Sie es erneut."
|
|
18
|
+
error_rate_limited: "Zu viele Nachrichten. Bitte warten Sie einen Moment und versuchen Sie es erneut."
|
|
19
|
+
unread_aria: "ungelesene Nachrichten"
|
|
20
|
+
statuses:
|
|
21
|
+
open: "Offen"
|
|
22
|
+
resolved: "Gelöst"
|
|
23
|
+
events:
|
|
24
|
+
resolved: "%{agent} hat die Unterhaltung gelöst"
|
|
25
|
+
reopened: "%{agent} hat die Unterhaltung wieder geöffnet"
|
|
26
|
+
dashboard:
|
|
27
|
+
title: "Chat"
|
|
28
|
+
empty: "Hier ist noch nichts. Wenn ein Besucher schreibt, erscheint die Unterhaltung auf dieser Seite."
|
|
29
|
+
visitor: "Besucher"
|
|
30
|
+
last_message: "Letzte Nachricht"
|
|
31
|
+
updated: "Aktualisiert"
|
|
32
|
+
back: "Alle Unterhaltungen"
|
|
33
|
+
resolve: "Lösen"
|
|
34
|
+
reopen: "Wieder öffnen"
|
|
35
|
+
reply_placeholder: "Antwort schreiben…"
|
|
36
|
+
send: "Senden"
|
|
37
|
+
new_messages: "Neue Nachrichten — aktualisieren"
|
|
38
|
+
newer: "Neuere"
|
|
39
|
+
older: "Ältere"
|
|
40
|
+
mail:
|
|
41
|
+
visitor_subject: "%{name} hat Ihnen auf %{app} geschrieben"
|
|
42
|
+
reply_subject: "%{name} hat Ihnen auf %{app} geantwortet"
|
|
43
|
+
answer: "Antworten"
|
|
44
|
+
continue: "Unterhaltung fortsetzen"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
el:
|
|
2
|
+
livechat:
|
|
3
|
+
launcher: "Συνομιλήστε μαζί μας"
|
|
4
|
+
greeting: "Γεια σας! Πώς μπορούμε να βοηθήσουμε;"
|
|
5
|
+
reply_time: "Συνήθως απαντάμε μέσα σε λίγες ώρες."
|
|
6
|
+
placeholder: "Γράψτε ένα μήνυμα…"
|
|
7
|
+
send: "Αποστολή"
|
|
8
|
+
close: "Κλείσιμο"
|
|
9
|
+
you: "Εσείς"
|
|
10
|
+
team: "Υποστήριξη"
|
|
11
|
+
email_prompt: "Λάβετε αντίγραφο της απάντησής μας μέσω email:"
|
|
12
|
+
email_placeholder: "you@example.com"
|
|
13
|
+
email_save: "Αποθήκευση"
|
|
14
|
+
email_saved: "Θα απαντήσουμε επίσης μέσω email."
|
|
15
|
+
event_resolved: "Η συνομιλία επιλύθηκε"
|
|
16
|
+
event_reopened: "Η συνομιλία άνοιξε ξανά"
|
|
17
|
+
error_send: "Δεν ήταν δυνατή η αποστολή. Δοκιμάστε ξανά."
|
|
18
|
+
error_rate_limited: "Πάρα πολλά μηνύματα. Περιμένετε λίγο και δοκιμάστε ξανά."
|
|
19
|
+
unread_aria: "μη αναγνωσμένα μηνύματα"
|
|
20
|
+
statuses:
|
|
21
|
+
open: "Ανοιχτή"
|
|
22
|
+
resolved: "Επιλυμένη"
|
|
23
|
+
events:
|
|
24
|
+
resolved: "%{agent} επέλυσε τη συνομιλία"
|
|
25
|
+
reopened: "%{agent} άνοιξε ξανά τη συνομιλία"
|
|
26
|
+
dashboard:
|
|
27
|
+
title: "Συνομιλία"
|
|
28
|
+
empty: "Δεν υπάρχει τίποτα εδώ ακόμα. Όταν ένας επισκέπτης γράψει, η συνομιλία εμφανίζεται σε αυτήν τη σελίδα."
|
|
29
|
+
visitor: "Επισκέπτης"
|
|
30
|
+
last_message: "Τελευταίο μήνυμα"
|
|
31
|
+
updated: "Ενημερώθηκε"
|
|
32
|
+
back: "Όλες οι συνομιλίες"
|
|
33
|
+
resolve: "Επίλυση"
|
|
34
|
+
reopen: "Άνοιγμα ξανά"
|
|
35
|
+
reply_placeholder: "Γράψτε την απάντησή σας…"
|
|
36
|
+
send: "Αποστολή"
|
|
37
|
+
new_messages: "Νέα μηνύματα — ανανέωση"
|
|
38
|
+
newer: "Νεότερα"
|
|
39
|
+
older: "Παλαιότερα"
|
|
40
|
+
mail:
|
|
41
|
+
visitor_subject: "Ο/Η %{name} σας έγραψε στο %{app}"
|
|
42
|
+
reply_subject: "Ο/Η %{name} σας απάντησε στο %{app}"
|
|
43
|
+
answer: "Απάντηση"
|
|
44
|
+
continue: "Συνεχίστε τη συνομιλία"
|