support_desk 0.3.2 → 0.5.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 +4 -4
- data/CHANGELOG.md +134 -0
- data/README.md +163 -8
- data/app/assets/stylesheets/support_desk.css +77 -0
- data/app/controllers/support_desk/application_controller.rb +21 -2
- data/app/controllers/support_desk/tickets_controller.rb +98 -9
- data/app/helpers/support_desk/engine_helper.rb +75 -10
- data/app/helpers/support_desk/thread_helper.rb +29 -0
- data/app/views/layouts/support_desk/panel.html.erb +6 -0
- data/app/views/support_desk/launcher/_badge.html.erb +6 -0
- data/app/views/support_desk/launcher/_icon.html.erb +6 -0
- data/app/views/support_desk/launcher/_launcher.html.erb +40 -0
- data/app/views/support_desk/launcher/_signed_out.html.erb +25 -0
- data/app/views/support_desk/panel/case.html.erb +36 -0
- data/app/views/support_desk/panel/home.html.erb +36 -0
- data/app/views/support_desk/panel/new.html.erb +8 -0
- data/app/views/support_desk/panel/rate_limited.html.erb +5 -0
- data/app/views/support_desk/tickets/_limit_wall.html.erb +33 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +1 -1
- data/app/views/support_desk/tickets/_pick_topic.html.erb +1 -1
- data/app/views/support_desk/tickets/_wizard_header.html.erb +1 -1
- data/app/views/support_desk/tickets/_write.html.erb +4 -3
- data/app/views/support_desk/tickets/rate_limited.html.erb +1 -31
- data/app/views/support_desk/tickets/show.html.erb +8 -0
- data/config/locales/support_desk.en.yml +38 -0
- data/config/locales/support_desk.es.yml +38 -0
- data/config/routes.rb +4 -0
- data/lib/generators/support_desk/templates/initializer.rb +21 -1
- data/lib/generators/support_desk/views_generator.rb +9 -3
- data/lib/support_desk/brief.rb +1 -1
- data/lib/support_desk/configuration.rb +100 -19
- data/lib/support_desk/conversation_access.rb +42 -0
- data/lib/support_desk/engine.rb +7 -0
- data/lib/support_desk/launcher.rb +64 -0
- data/lib/support_desk/models/concerns/requester.rb +6 -2
- data/lib/support_desk/models/ticket.rb +55 -1
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +1 -5
- data/lib/support_desk.rb +41 -6
- metadata +28 -11
|
@@ -16,6 +16,11 @@ module SupportDesk
|
|
|
16
16
|
before_action :set_wizard, only: %i[new create]
|
|
17
17
|
include Chats::SendRateLimited
|
|
18
18
|
|
|
19
|
+
# The ticket page renders chats' thread template, which names chats'
|
|
20
|
+
# routes bare (SupportDesk::ThreadHelper) and asks for these two.
|
|
21
|
+
helper SupportDesk::ThreadHelper
|
|
22
|
+
helper_method :chats_current_messager, :chats_counterpart
|
|
23
|
+
|
|
19
24
|
# Their cases: open ones as rows, closed ones folded away, and the door
|
|
20
25
|
# to open another.
|
|
21
26
|
def index
|
|
@@ -27,12 +32,22 @@ module SupportDesk
|
|
|
27
32
|
@closed_tickets = tickets.closed.newest_first.limit(CLOSED_TICKETS_SHOWN).to_a
|
|
28
33
|
@closed_count = tickets.closed.count
|
|
29
34
|
@unread_counts = unread_counts_for(@open_tickets + @closed_tickets)
|
|
35
|
+
render_screen :index, panel: "support_desk/panel/home"
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
# The wizard. One URL, three frames — which one renders is the wizard's
|
|
33
|
-
# answer, never a param we trust.
|
|
39
|
+
# answer, never a param we trust. In the launcher's panel, the same step
|
|
40
|
+
# in the panel's one frame.
|
|
34
41
|
def new
|
|
35
|
-
|
|
42
|
+
render_wizard
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The launcher's badge, as the Turbo Stream its own stream would have
|
|
46
|
+
# sent: chats' tray asks for it when that stream may have missed one.
|
|
47
|
+
def badge
|
|
48
|
+
render turbo_stream: turbo_stream.replace(SupportDesk::Launcher::BADGE_ID,
|
|
49
|
+
partial: SupportDesk::Launcher::BADGE_PARTIAL,
|
|
50
|
+
locals: { count: current_requester.unread_support_count })
|
|
36
51
|
end
|
|
37
52
|
|
|
38
53
|
# Open the case and hand the requester straight to the conversation.
|
|
@@ -44,20 +59,20 @@ module SupportDesk
|
|
|
44
59
|
def create
|
|
45
60
|
if message.blank? && files.empty?
|
|
46
61
|
@error = t("support_desk.wizard.message_required")
|
|
47
|
-
return
|
|
62
|
+
return render_wizard(status: :unprocessable_entity)
|
|
48
63
|
end
|
|
49
64
|
|
|
50
65
|
ticket = @wizard.open!(message, files: files)
|
|
51
66
|
redirect_to conversation_path_for(ticket)
|
|
52
67
|
rescue ActiveRecord::RecordInvalid => e
|
|
53
68
|
@error = e.record.errors.full_messages.to_sentence
|
|
54
|
-
|
|
69
|
+
render_wizard(status: :unprocessable_entity)
|
|
55
70
|
rescue SupportDesk::RateLimited, SupportDesk::TooManyOpenTickets => e
|
|
56
71
|
render_limit_wall(e)
|
|
57
72
|
rescue SupportDesk::InvalidTransition
|
|
58
73
|
# The form was submitted from a step that isn't the composer (a stale
|
|
59
74
|
# tab, a hand-rolled POST): re-render the step they are actually on.
|
|
60
|
-
|
|
75
|
+
render_wizard(status: :unprocessable_entity)
|
|
61
76
|
rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable, SupportDesk::NotARequester
|
|
62
77
|
# The subject passed the wizard's check and failed the model's, or this
|
|
63
78
|
# account stopped being able to write between the form and the submit.
|
|
@@ -67,10 +82,17 @@ module SupportDesk
|
|
|
67
82
|
end
|
|
68
83
|
|
|
69
84
|
# A stable URL for a case — what an email or a push notification links
|
|
70
|
-
# to.
|
|
71
|
-
# nothing else.
|
|
85
|
+
# to. Under `thread_page = :chats` the case is read in the inbox's thread,
|
|
86
|
+
# so this is a redirect and nothing else. Under `:support` it is the page
|
|
87
|
+
# the case is read on: chats' own thread, with its back link leading
|
|
88
|
+
# here rather than to an inbox support is not part of. In the launcher's
|
|
89
|
+
# panel it is always the case, inside the panel, whatever `thread_page`
|
|
90
|
+
# says.
|
|
72
91
|
def show
|
|
73
|
-
|
|
92
|
+
ticket = find_ticket
|
|
93
|
+
return render_thread(ticket) if ticket.conversation && (support_panel? || SupportDesk.config.support_thread_page?)
|
|
94
|
+
|
|
95
|
+
redirect_to conversation_path_for(ticket)
|
|
74
96
|
end
|
|
75
97
|
|
|
76
98
|
# "Prefiero hablar con una persona." The one thing a requester can
|
|
@@ -96,6 +118,73 @@ module SupportDesk
|
|
|
96
118
|
|
|
97
119
|
def chat_rate_limit_messager = current_requester
|
|
98
120
|
|
|
121
|
+
# chats' thread page, rendered here: the same latest page of messages,
|
|
122
|
+
# the same «new messages» line and the same read on open as
|
|
123
|
+
# Chats::ConversationsController#show, for chats' own template. Older
|
|
124
|
+
# pages, the composer and the live updates are chats' endpoints, which
|
|
125
|
+
# reach this conversation through SupportDesk::ConversationAccess.
|
|
126
|
+
def render_thread(ticket)
|
|
127
|
+
@ticket = ticket
|
|
128
|
+
@conversation = chats_conversation(ticket)
|
|
129
|
+
@participant = @conversation.participant_for(current_requester)
|
|
130
|
+
|
|
131
|
+
scope = @conversation.messages.includes(:sender, :reactions)
|
|
132
|
+
scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
|
|
133
|
+
page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
134
|
+
@more_messages = page.size > Chats.config.messages_per_page
|
|
135
|
+
@messages = page.first(Chats.config.messages_per_page).reverse
|
|
136
|
+
|
|
137
|
+
if @participant&.unread?
|
|
138
|
+
@first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
|
|
139
|
+
@messages.first&.id
|
|
140
|
+
end
|
|
141
|
+
# A pointer resting on a link (Turbo's hover prefetch) is not somebody
|
|
142
|
+
# reading the case; the visit that follows reads it. Read through the
|
|
143
|
+
# last message on the page, not merely "now": an acknowledgement pinned
|
|
144
|
+
# one tick after the message it answers may sit just ahead of the clock.
|
|
145
|
+
#
|
|
146
|
+
# Not in the launcher's panel: there the thread is the compact one a
|
|
147
|
+
# dock window renders, and it reads itself once the panel is open and
|
|
148
|
+
# the requester is with it (chats' read gate, which the tray keeps). A
|
|
149
|
+
# frame that loads behind a closed panel must not put the badge out.
|
|
150
|
+
unless prefetch_request? || support_panel?
|
|
151
|
+
@participant&.read!(at: [ Time.current, @messages.last&.created_at ].compact.max)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
render_screen :show, panel: "support_desk/panel/case"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The wizard's step: the page's three frames, or the panel's one.
|
|
158
|
+
def render_wizard(**options)
|
|
159
|
+
render_screen :new, panel: "support_desk/panel/new", **options
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The case's conversation, as chats' own endpoints would find it: chats'
|
|
163
|
+
# `find_conversation` on a chats controller bound to this request, so
|
|
164
|
+
# every rule the host or a product gem added there (its lookup scopes, its
|
|
165
|
+
# own `find_conversation`) decides here exactly as at the chats URL. Then
|
|
166
|
+
# it must be this very case's conversation: direct, about this case (the
|
|
167
|
+
# rule Ticket.conversations_of holds the chats lookup to). A 404
|
|
168
|
+
# otherwise, never a hint.
|
|
169
|
+
def chats_conversation(ticket)
|
|
170
|
+
lookup = Chats::ApplicationController.new
|
|
171
|
+
lookup.set_request!(request)
|
|
172
|
+
conversation = lookup.send(:find_conversation, ticket.conversation_id)
|
|
173
|
+
raise ActiveRecord::RecordNotFound unless conversation.direct? &&
|
|
174
|
+
conversation.subject_type == ticket.class.polymorphic_name &&
|
|
175
|
+
conversation.subject_id.to_s == ticket.id.to_s
|
|
176
|
+
|
|
177
|
+
conversation
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def prefetch_request?
|
|
181
|
+
request.headers["X-Sec-Purpose"] == "prefetch" || request.headers["Sec-Purpose"].to_s.include?("prefetch")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# What chats' thread template asks its controller for.
|
|
185
|
+
def chats_current_messager = current_requester
|
|
186
|
+
def chats_counterpart = @conversation&.counterpart_for(current_requester)
|
|
187
|
+
|
|
99
188
|
def set_wizard
|
|
100
189
|
@wizard = SupportDesk::Wizard.new(current_requester, params)
|
|
101
190
|
# A token that is forged, expired, or points at somebody else's record
|
|
@@ -134,7 +223,7 @@ module SupportDesk
|
|
|
134
223
|
.includes(:desk, :subject, conversation: { last_message: :sender })
|
|
135
224
|
.recent_activity_first.to_a
|
|
136
225
|
@unread_counts = unread_counts_for(@open_tickets)
|
|
137
|
-
|
|
226
|
+
render_screen :rate_limited, panel: "support_desk/panel/rate_limited", status: :too_many_requests
|
|
138
227
|
end
|
|
139
228
|
|
|
140
229
|
# One grouped query for every row's badge (see
|
|
@@ -16,6 +16,14 @@ module SupportDesk
|
|
|
16
16
|
# Which frame the wizard renders on each of its three steps.
|
|
17
17
|
WIZARD_PARTIALS = { topic: "pick_topic", subject: "pick_thing", compose: "write" }.freeze
|
|
18
18
|
|
|
19
|
+
# Who sees the launcher.
|
|
20
|
+
LAUNCHER_AUDIENCES = %i[signed_in everyone].freeze
|
|
21
|
+
|
|
22
|
+
# The launcher panel's slots: a host drops
|
|
23
|
+
# `app/views/support_desk/slots/_<name>.html.erb` in and it renders there
|
|
24
|
+
# (gets `requester:`, nil for a visitor who is signed out).
|
|
25
|
+
SLOTS = %w[panel_top panel_footer].freeze
|
|
26
|
+
|
|
19
27
|
# The door: "¿Necesitas ayuda con esto?" next to a record, or "Contactar
|
|
20
28
|
# con soporte" on its own.
|
|
21
29
|
#
|
|
@@ -70,6 +78,62 @@ module SupportDesk
|
|
|
70
78
|
render partial: "support_desk/tickets/human_door", locals: { ticket: ticket }
|
|
71
79
|
end
|
|
72
80
|
|
|
81
|
+
# The "Need help?" launcher: an item of chats' tray (chats >= 0.7.0),
|
|
82
|
+
# bottom-right, whose panel is this engine's own screens — their cases,
|
|
83
|
+
# a new one (the wizard, with the promise), a case's thread — in a frame.
|
|
84
|
+
#
|
|
85
|
+
# <%= chats_dock do %> <%# beside the messaging dock's bar %>
|
|
86
|
+
# <%= support_launcher %>
|
|
87
|
+
# <% end %>
|
|
88
|
+
#
|
|
89
|
+
# <%= support_launcher %> <%# no dock: a corner of its own %>
|
|
90
|
+
#
|
|
91
|
+
# show_for: :signed_in (default) shows it to requesters only;
|
|
92
|
+
# :everyone also to visitors who are signed out, whose panel
|
|
93
|
+
# says how to reach the desk (sign in, its address, help_url)
|
|
94
|
+
# help_url: the host's public help centre, linked from the panel
|
|
95
|
+
#
|
|
96
|
+
# Renders nothing for a visitor who is signed out under :signed_in. The
|
|
97
|
+
# /support pages stay what they are; the panel is a window onto them.
|
|
98
|
+
def support_launcher(show_for: :signed_in, help_url: nil)
|
|
99
|
+
unless LAUNCHER_AUDIENCES.include?(show_for)
|
|
100
|
+
raise ArgumentError, "support_launcher(show_for:) is one of #{LAUNCHER_AUDIENCES.inspect}, not #{show_for.inspect}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
requester = support_desk_requester
|
|
104
|
+
return if requester.nil? && show_for == :signed_in
|
|
105
|
+
|
|
106
|
+
chats_tray { render("support_desk/launcher/launcher", requester: requester, help_url: help_url) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Whether this render answers the launcher's panel (its frame asked):
|
|
110
|
+
# the screens then render their panel templates, and links stay inside.
|
|
111
|
+
def support_panel?
|
|
112
|
+
SupportDesk::Launcher.panel_request?(respond_to?(:request) ? request : nil)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The frame a link that leaves the current screen targets: the whole page
|
|
116
|
+
# ("_top"), or, in the launcher's panel, the panel itself (nil: the
|
|
117
|
+
# nearest frame).
|
|
118
|
+
#
|
|
119
|
+
# link_to "Back", root_path, data: { turbo_frame: support_desk_top_frame }
|
|
120
|
+
def support_desk_top_frame
|
|
121
|
+
support_panel? ? nil : "_top"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Render a host's slot partial when it exists (see SLOTS). Absent slots
|
|
125
|
+
# cost one memoized lookup and render nothing; a name outside SLOTS is
|
|
126
|
+
# never looked up.
|
|
127
|
+
def support_desk_slot(name, **locals)
|
|
128
|
+
key = name.to_s
|
|
129
|
+
return unless SLOTS.include?(key)
|
|
130
|
+
|
|
131
|
+
@support_desk_slots ||= {}
|
|
132
|
+
@support_desk_slots[key] = lookup_context.exists?("support_desk/slots/#{key}", [], true) unless
|
|
133
|
+
@support_desk_slots.key?(key)
|
|
134
|
+
render(partial: "support_desk/slots/#{key}", locals: locals) if @support_desk_slots[key]
|
|
135
|
+
end
|
|
136
|
+
|
|
73
137
|
# --- What the bundled views render with -----------------------------------
|
|
74
138
|
|
|
75
139
|
# The gem's bundled stylesheet, into the host layout's <head> — where a
|
|
@@ -84,14 +148,11 @@ module SupportDesk
|
|
|
84
148
|
content_for(:head) { stylesheet_link_tag "support_desk", "data-turbo-track": "reload" }
|
|
85
149
|
end
|
|
86
150
|
|
|
87
|
-
# "Normalmente respondemos en menos de 24
|
|
88
|
-
# desk promises nothing. The same `config.reply_within` the SLA
|
|
89
|
-
# on — one setting, one truth.
|
|
151
|
+
# "Normalmente respondemos en menos de 24 horas" for a desk, or nil when
|
|
152
|
+
# the desk promises nothing. The same `config.reply_within` the SLA
|
|
153
|
+
# breaches on — one setting, one truth.
|
|
90
154
|
def support_reply_promise(desk_key = :default)
|
|
91
|
-
|
|
92
|
-
return nil if within.nil?
|
|
93
|
-
|
|
94
|
-
t("support_desk.thread.promise", time: SupportDesk.humanize_duration(within))
|
|
155
|
+
SupportDesk.config.desk(desk_key).reply_promise
|
|
95
156
|
end
|
|
96
157
|
|
|
97
158
|
# The status line under a case in the requester's list: who owes the next
|
|
@@ -178,10 +239,14 @@ module SupportDesk
|
|
|
178
239
|
chats_verified_badge(support_desk_record(desk_key))
|
|
179
240
|
end
|
|
180
241
|
|
|
181
|
-
# Where a case is read and answered: its chats conversation
|
|
182
|
-
#
|
|
183
|
-
# the
|
|
242
|
+
# Where a case is read and answered: its chats conversation, or the
|
|
243
|
+
# engine's own ticket page under `thread_page = :support` (by reference:
|
|
244
|
+
# the URL people read back from a screen) and in the launcher's panel,
|
|
245
|
+
# whose ticket page is the case inside the panel. Falls back to the
|
|
246
|
+
# ticket URL (which redirects to the same place) for the one case that
|
|
247
|
+
# has no conversation yet, so a link is never dead.
|
|
184
248
|
def support_thread_path(ticket)
|
|
249
|
+
return support_desk_routes.ticket_path(ticket.reference) if support_panel? || SupportDesk.config.support_thread_page?
|
|
185
250
|
return support_desk_routes.ticket_path(ticket) if ticket.conversation.nil? || !respond_to?(:chats_routes)
|
|
186
251
|
|
|
187
252
|
chats_routes.conversation_path(ticket.conversation)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The ticket page (`config.thread_page = :support`) renders chats' own
|
|
5
|
+
# thread template, and that template names chats' routes bare:
|
|
6
|
+
# `conversation_path`, `read_conversation_path`,
|
|
7
|
+
# `conversation_messages_path`… Inside chats' engine those are its own;
|
|
8
|
+
# rendered from this engine they would be looked up here and not exist.
|
|
9
|
+
#
|
|
10
|
+
# So on that page any route helper this engine does not have is chats',
|
|
11
|
+
# through its mounted proxy (`chats_routes`), and the one name both
|
|
12
|
+
# engines draw stays ours: `root_path` is the support list, which is
|
|
13
|
+
# exactly where the thread's way back should lead.
|
|
14
|
+
module ThreadHelper
|
|
15
|
+
def method_missing(name, ...)
|
|
16
|
+
chats_route?(name) ? chats_routes.public_send(name, ...) : super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def respond_to_missing?(name, include_private = false)
|
|
20
|
+
chats_route?(name) || super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def chats_route?(name)
|
|
26
|
+
name.end_with?("_path", "_url") && chats_routes.respond_to?(name)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# The launcher's panel (SupportDesk::Launcher): a screen answering the
|
|
2
|
+
panel's frame is the screen inside that frame, and nothing else of the
|
|
3
|
+
host's layout. Turbo takes the frame's content into the panel. %>
|
|
4
|
+
<%= turbo_frame_tag SupportDesk::Launcher::FRAME do %>
|
|
5
|
+
<%= yield %>
|
|
6
|
+
<% end %>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# The launcher's badge: unread messages across the requester's cases
|
|
2
|
+
(`unread_support_count`), hidden at zero. What their
|
|
3
|
+
`[requester, :support_desk]` stream replaces (SupportDesk::Launcher, which
|
|
4
|
+
hands it the requester and lets it count as it renders) and GET
|
|
5
|
+
tickets/badge answers. Locals: count, or requester. %>
|
|
6
|
+
<%= chats_tray_badge local_assigns.fetch(:count) { requester.unread_support_count }, id: SupportDesk::Launcher::BADGE_ID %>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# The launcher's icon (a question mark in a circle). Eject this partial to
|
|
2
|
+
draw your own (an icon set's lifebuoy, your logo). %>
|
|
3
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
|
|
4
|
+
stroke-linejoin="round" aria-hidden="true" focusable="false">
|
|
5
|
+
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%# The "Need help?" launcher (`support_launcher`): one item of chats' tray,
|
|
2
|
+
beside the messaging dock's bar or in a corner of its own. chats draws the
|
|
3
|
+
tab, the panel's chrome and the phone's sheet, and runs them (one open at
|
|
4
|
+
a time, Escape, memory, the badge's catch-up); this is what is ours: the
|
|
5
|
+
words, the icon, the live badge and the panel's content.
|
|
6
|
+
|
|
7
|
+
Signed in, the panel is a lazy frame onto this engine's own screens (the
|
|
8
|
+
list, the wizard, a case), answered with their panel templates
|
|
9
|
+
(SupportDesk::TicketsController). `data-chats-tray-home`: if the page
|
|
10
|
+
ever shows the case the panel shows, the panel goes back to the list.
|
|
11
|
+
`refresh: :morph`: the list reloads in place when the badge moves.
|
|
12
|
+
Signed out, a short panel says how to reach the desk.
|
|
13
|
+
|
|
14
|
+
Locals: requester (nil when signed out), help_url. %>
|
|
15
|
+
<% desk_key = requester ? requester.class.support_desk_key : :default %>
|
|
16
|
+
<% desk = SupportDesk.config.desk(desk_key) %>
|
|
17
|
+
<%= stylesheet_link_tag "support_desk" %>
|
|
18
|
+
<%= chats_tray_item :support,
|
|
19
|
+
label: t("support_desk.launcher.label"),
|
|
20
|
+
title: safe_join([ tag.span(desk.name, class: "support-desk-launcher__name"), support_desk_badge(desk_key) ]),
|
|
21
|
+
icon: render("support_desk/launcher/icon"),
|
|
22
|
+
badge: (safe_join([ turbo_stream_from(requester, :support_desk),
|
|
23
|
+
render("support_desk/launcher/badge", count: requester.unread_support_count) ]) if requester),
|
|
24
|
+
badge_url: (support_desk_routes.badge_tickets_path if requester) do %>
|
|
25
|
+
<% if requester %>
|
|
26
|
+
<%= turbo_frame_tag SupportDesk::Launcher::FRAME,
|
|
27
|
+
src: support_desk_routes.root_path,
|
|
28
|
+
loading: :lazy,
|
|
29
|
+
refresh: :morph,
|
|
30
|
+
class: "support-desk support-desk-panel",
|
|
31
|
+
data: { chats_tray_home: support_desk_routes.root_path } do %>
|
|
32
|
+
<%# Until the frame loads, and for good without JavaScript. %>
|
|
33
|
+
<p class="support-desk-panel__loading">
|
|
34
|
+
<%= link_to t("support_desk.launcher.open"), support_desk_routes.root_path, data: { turbo_frame: "_top" } %>
|
|
35
|
+
</p>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% else %>
|
|
38
|
+
<%= render "support_desk/launcher/signed_out", desk: desk, help_url: help_url %>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%# The launcher's panel for a visitor who is signed out
|
|
2
|
+
(`support_launcher show_for: :everyone`): how to reach the desk. Signing
|
|
3
|
+
in goes through /support, so the host's own authentication asks for it
|
|
4
|
+
and brings them back here; the desk's address is for those who can't
|
|
5
|
+
sign in (often why they need help); the help centre when the host has
|
|
6
|
+
one. Locals: desk (its configuration), help_url. %>
|
|
7
|
+
<div class="support-desk support-desk-panel support-desk-panel__screen support-desk-panel__signed-out">
|
|
8
|
+
<%= support_desk_slot :panel_top, requester: nil %>
|
|
9
|
+
|
|
10
|
+
<p class="support-desk-panel__lead"><%= t(".lead") %></p>
|
|
11
|
+
<% if (promise = desk.reply_promise) %>
|
|
12
|
+
<p class="support-desk-panel__promise"><%= promise %></p>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<%= link_to t(".sign_in"), support_desk_routes.root_path, class: "support-desk-button", data: { turbo_frame: "_top" } %>
|
|
16
|
+
|
|
17
|
+
<% if desk.email.present? %>
|
|
18
|
+
<p class="support-desk-panel__note"><%= t(".email_html", email: mail_to(desk.email)) %></p>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% if help_url.present? %>
|
|
21
|
+
<%= link_to t(".help"), help_url, class: "support-desk-panel__link" %>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<%= support_desk_slot :panel_footer, requester: nil %>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%# A case in the launcher's panel (GET /tickets/:id from the panel's
|
|
2
|
+
frame): chats' own thread body, the compact one a dock window renders
|
|
3
|
+
(`window: true`: live, read only while the panel is open, the composer at
|
|
4
|
+
the bottom, the desk's signature on its answers), under the panel's way
|
|
5
|
+
back to the list and out to the case's page, in a dock window's header
|
|
6
|
+
classes so the two look alike wherever a host themes them. %>
|
|
7
|
+
<%= render layout: "chats/conversations/thread",
|
|
8
|
+
locals: {
|
|
9
|
+
conversation: @conversation,
|
|
10
|
+
messages: @messages,
|
|
11
|
+
more: @more_messages,
|
|
12
|
+
first_unread_id: @first_unread_id,
|
|
13
|
+
counterpart: chats_counterpart,
|
|
14
|
+
window: true
|
|
15
|
+
} do %>
|
|
16
|
+
<header class="chats-window__header support-desk-panel__nav">
|
|
17
|
+
<%= link_to support_desk_routes.root_path, class: "chats-window__icon",
|
|
18
|
+
title: t("support_desk.wizard.back"), "aria-label": t("support_desk.wizard.back") do %>
|
|
19
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
20
|
+
stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M19 12H5m6-6-6 6 6 6"/></svg>
|
|
21
|
+
<% end %>
|
|
22
|
+
<span class="chats-window__identity support-desk-panel__heading">
|
|
23
|
+
<span class="chats-window__name"><%= @ticket.label %></span>
|
|
24
|
+
<span class="chats-window__subject"><%= support_ticket_state(@ticket) %></span>
|
|
25
|
+
</span>
|
|
26
|
+
<%= link_to support_desk_routes.ticket_path(@ticket.reference), class: "chats-window__icon",
|
|
27
|
+
title: t("support_desk.launcher.open_page"), "aria-label": t("support_desk.launcher.open_page"),
|
|
28
|
+
data: { turbo_frame: "_top" } do %>
|
|
29
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
30
|
+
stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
|
|
31
|
+
<% end %>
|
|
32
|
+
</header>
|
|
33
|
+
<% if (door = support_human_door(@ticket)).present? %>
|
|
34
|
+
<div class="support-desk-panel__human"><%= door %></div>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%# The launcher's panel, first screen (GET / from the panel's frame): the
|
|
2
|
+
way to a new case with the promise on it, their open cases as the same
|
|
3
|
+
rows the page has (each opens in the panel), and the way to the page,
|
|
4
|
+
where the closed ones are. `data-chats-tray-refresh`: chats' tray reloads
|
|
5
|
+
this screen (morphed in place) when the launcher's badge moves. %>
|
|
6
|
+
<div class="support-desk-panel__screen" data-chats-tray-refresh>
|
|
7
|
+
<%= support_desk_slot :panel_top, requester: current_requester %>
|
|
8
|
+
|
|
9
|
+
<%= render "support_desk/tickets/door" %>
|
|
10
|
+
|
|
11
|
+
<% if @open_tickets.any? %>
|
|
12
|
+
<%# The dock's own list classes: the same compact rows as its recent list. %>
|
|
13
|
+
<ul class="chats-inbox__list chats-dock__list">
|
|
14
|
+
<%= render partial: "support_desk/tickets/ticket_row", collection: @open_tickets, as: :ticket,
|
|
15
|
+
locals: { unread_counts: @unread_counts } %>
|
|
16
|
+
</ul>
|
|
17
|
+
<% else %>
|
|
18
|
+
<div class="chats-empty">
|
|
19
|
+
<p class="chats-empty__title"><%= t("support_desk.tickets.empty_title") %></p>
|
|
20
|
+
<p class="chats-empty__hint"><%= t("support_desk.tickets.empty_hint") %></p>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<% if @open_tickets.any? || @closed_count.positive? %>
|
|
25
|
+
<%# The dock's "See all messages", for support. %>
|
|
26
|
+
<%= link_to support_desk_routes.root_path, class: "chats-dock__see-all support-desk-panel__all",
|
|
27
|
+
data: { turbo_frame: "_top" } do %>
|
|
28
|
+
<%= t("support_desk.launcher.all") %>
|
|
29
|
+
<% if @closed_count.positive? %>
|
|
30
|
+
<span class="support-desk-panel__count"><%= t("support_desk.tickets.closed_section", count: @closed_count) %></span>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<%= support_desk_slot :panel_footer, requester: current_requester %>
|
|
36
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# The wizard in the launcher's panel (GET /new from the panel's frame):
|
|
2
|
+
the same step partials as the page, in the panel's one frame instead of
|
|
3
|
+
the page's `support_wizard` (and never `advance`: the address bar is the
|
|
4
|
+
host's page). Every step's links navigate the panel; the form posts from
|
|
5
|
+
it and the new case opens in it. %>
|
|
6
|
+
<div class="support-desk-panel__screen support-desk-panel__wizard">
|
|
7
|
+
<%= render support_wizard_partial(@wizard), wizard: @wizard, error: @error %>
|
|
8
|
+
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<%# The wall's content (rate_limited.html.erb on a page, support_desk/panel/rate_limited in the
|
|
2
|
+
launcher's panel): why, the cases they already have, and what they wrote. %>
|
|
3
|
+
<header class="chats-inbox__header">
|
|
4
|
+
<h1 class="chats-inbox__title"><%= t("support_desk.limits.#{@limit_reason}_title") %></h1>
|
|
5
|
+
</header>
|
|
6
|
+
|
|
7
|
+
<p class="support-desk-notice__title">
|
|
8
|
+
<%= t("support_desk.limits.#{@limit_reason}", count: @open_tickets.size) %>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<% if @open_tickets.any? %>
|
|
12
|
+
<ul class="chats-inbox__list">
|
|
13
|
+
<%= render partial: "support_desk/tickets/ticket_row",
|
|
14
|
+
collection: @open_tickets,
|
|
15
|
+
as: :ticket,
|
|
16
|
+
locals: { unread_counts: @unread_counts } %>
|
|
17
|
+
</ul>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<%# Whatever they had typed comes with them. The wall sends them into a
|
|
21
|
+
conversation they already have, and the words they just wrote are not
|
|
22
|
+
ours to throw away — kept on the page to copy rather than pushed
|
|
23
|
+
through a URL, where it would land in logs and browser history. %>
|
|
24
|
+
<% if params[:message].present? %>
|
|
25
|
+
<div class="support-desk-form">
|
|
26
|
+
<p class="support-desk-form__promise"><%= t("support_desk.limits.your_message") %></p>
|
|
27
|
+
<%= text_area_tag :message, params[:message], rows: 6, readonly: true,
|
|
28
|
+
class: "support-desk-form__input", id: "support_desk_kept_message" %>
|
|
29
|
+
</div>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
32
|
+
<%= link_to t("support_desk.wizard.back_to_list"), support_desk_routes.root_path,
|
|
33
|
+
class: "support-desk-button" %>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<% if open_ticket %>
|
|
15
15
|
<%= link_to support_thread_path(open_ticket),
|
|
16
16
|
class: "support-desk-choice support-desk-choice--taken",
|
|
17
|
-
data: { turbo_frame:
|
|
17
|
+
data: { turbo_frame: support_desk_top_frame } do %>
|
|
18
18
|
<span class="support-desk-choice__label"><%= record.support_label %></span>
|
|
19
19
|
<span class="support-desk-choice__hint"><%= t("support_desk.wizard.already_open") %> ›</span>
|
|
20
20
|
<% end %>
|
|
@@ -25,6 +25,6 @@
|
|
|
25
25
|
<div class="chats-empty">
|
|
26
26
|
<p class="chats-empty__title"><%= t("support_desk.wizard.no_topics") %></p>
|
|
27
27
|
<%= link_to t("support_desk.wizard.back_to_list"), support_desk_routes.root_path,
|
|
28
|
-
class: "support-desk-button", data: { turbo_frame:
|
|
28
|
+
class: "support-desk-button", data: { turbo_frame: support_desk_top_frame } %>
|
|
29
29
|
</div>
|
|
30
30
|
<% end %>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<%= link_to t("support_desk.wizard.back"),
|
|
11
11
|
support_desk_routes.root_path,
|
|
12
12
|
class: "support-desk-wizard__back",
|
|
13
|
-
data: { turbo_frame:
|
|
13
|
+
data: { turbo_frame: support_desk_top_frame } %>
|
|
14
14
|
<% end %>
|
|
15
15
|
|
|
16
16
|
<% if (ask = local_assigns[:ask] || wizard.ask) %>
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
The form targets `_top`: it POSTs to /tickets and the answer is a
|
|
5
5
|
redirect into the conversation, which must replace the page, not land
|
|
6
|
-
inside the wizard frame.
|
|
6
|
+
inside the wizard frame. In the launcher's panel it targets the panel
|
|
7
|
+
(support_desk_top_frame), and the new case opens there. %>
|
|
7
8
|
<%= render "support_desk/tickets/wizard_header", wizard: wizard, error: error,
|
|
8
9
|
ask: t("support_desk.wizard.write") %>
|
|
9
10
|
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
<div class="support-desk-notice">
|
|
15
16
|
<p class="support-desk-notice__title"><%= t("support_desk.wizard.already_open") %></p>
|
|
16
17
|
<%= link_to t("support_desk.doors.existing"), support_thread_path(existing),
|
|
17
|
-
class: "support-desk-button", data: { turbo_frame:
|
|
18
|
+
class: "support-desk-button", data: { turbo_frame: support_desk_top_frame } %>
|
|
18
19
|
</div>
|
|
19
20
|
<% else %>
|
|
20
21
|
<%= render "support_desk/tickets/context_card", subject: wizard.subject if wizard.subject %>
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
multipart: true,
|
|
24
25
|
method: :post,
|
|
25
26
|
class: "support-desk-form",
|
|
26
|
-
data: { turbo_frame:
|
|
27
|
+
data: { turbo_frame: support_desk_top_frame } do %>
|
|
27
28
|
<% wizard.state_params.each do |name, value| %>
|
|
28
29
|
<%= hidden_field_tag name, value %>
|
|
29
30
|
<% end %>
|
|
@@ -6,35 +6,5 @@
|
|
|
6
6
|
<%= support_desk_styles %>
|
|
7
7
|
|
|
8
8
|
<div class="chats chats-inbox support-desk">
|
|
9
|
-
|
|
10
|
-
<h1 class="chats-inbox__title"><%= t("support_desk.limits.#{@limit_reason}_title") %></h1>
|
|
11
|
-
</header>
|
|
12
|
-
|
|
13
|
-
<p class="support-desk-notice__title">
|
|
14
|
-
<%= t("support_desk.limits.#{@limit_reason}", count: @open_tickets.size) %>
|
|
15
|
-
</p>
|
|
16
|
-
|
|
17
|
-
<% if @open_tickets.any? %>
|
|
18
|
-
<ul class="chats-inbox__list">
|
|
19
|
-
<%= render partial: "support_desk/tickets/ticket_row",
|
|
20
|
-
collection: @open_tickets,
|
|
21
|
-
as: :ticket,
|
|
22
|
-
locals: { unread_counts: @unread_counts } %>
|
|
23
|
-
</ul>
|
|
24
|
-
<% end %>
|
|
25
|
-
|
|
26
|
-
<%# Whatever they had typed comes with them. The wall sends them into a
|
|
27
|
-
conversation they already have, and the words they just wrote are not
|
|
28
|
-
ours to throw away — kept on the page to copy rather than pushed
|
|
29
|
-
through a URL, where it would land in logs and browser history. %>
|
|
30
|
-
<% if params[:message].present? %>
|
|
31
|
-
<div class="support-desk-form">
|
|
32
|
-
<p class="support-desk-form__promise"><%= t("support_desk.limits.your_message") %></p>
|
|
33
|
-
<%= text_area_tag :message, params[:message], rows: 6, readonly: true,
|
|
34
|
-
class: "support-desk-form__input", id: "support_desk_kept_message" %>
|
|
35
|
-
</div>
|
|
36
|
-
<% end %>
|
|
37
|
-
|
|
38
|
-
<%= link_to t("support_desk.wizard.back_to_list"), support_desk_routes.root_path,
|
|
39
|
-
class: "support-desk-button" %>
|
|
9
|
+
<%= render "support_desk/tickets/limit_wall" %>
|
|
40
10
|
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# One case, read where support lives (`config.thread_page = :support`).
|
|
2
|
+
|
|
3
|
+
It is chats' thread: the same template, so the same bubbles, composer,
|
|
4
|
+
read state and live updates as every conversation in the app, with the
|
|
5
|
+
desk's verified name in its header. Only the way back differs: its
|
|
6
|
+
`root_path` is this engine's, the support list (SupportDesk::ThreadHelper).
|
|
7
|
+
Eject this file to put anything of your own around it. %>
|
|
8
|
+
<%= render template: "chats/conversations/show" %>
|