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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -0
  3. data/README.md +163 -8
  4. data/app/assets/stylesheets/support_desk.css +77 -0
  5. data/app/controllers/support_desk/application_controller.rb +21 -2
  6. data/app/controllers/support_desk/tickets_controller.rb +98 -9
  7. data/app/helpers/support_desk/engine_helper.rb +75 -10
  8. data/app/helpers/support_desk/thread_helper.rb +29 -0
  9. data/app/views/layouts/support_desk/panel.html.erb +6 -0
  10. data/app/views/support_desk/launcher/_badge.html.erb +6 -0
  11. data/app/views/support_desk/launcher/_icon.html.erb +6 -0
  12. data/app/views/support_desk/launcher/_launcher.html.erb +40 -0
  13. data/app/views/support_desk/launcher/_signed_out.html.erb +25 -0
  14. data/app/views/support_desk/panel/case.html.erb +36 -0
  15. data/app/views/support_desk/panel/home.html.erb +36 -0
  16. data/app/views/support_desk/panel/new.html.erb +8 -0
  17. data/app/views/support_desk/panel/rate_limited.html.erb +5 -0
  18. data/app/views/support_desk/tickets/_limit_wall.html.erb +33 -0
  19. data/app/views/support_desk/tickets/_pick_thing.html.erb +1 -1
  20. data/app/views/support_desk/tickets/_pick_topic.html.erb +1 -1
  21. data/app/views/support_desk/tickets/_wizard_header.html.erb +1 -1
  22. data/app/views/support_desk/tickets/_write.html.erb +4 -3
  23. data/app/views/support_desk/tickets/rate_limited.html.erb +1 -31
  24. data/app/views/support_desk/tickets/show.html.erb +8 -0
  25. data/config/locales/support_desk.en.yml +38 -0
  26. data/config/locales/support_desk.es.yml +38 -0
  27. data/config/routes.rb +4 -0
  28. data/lib/generators/support_desk/templates/initializer.rb +21 -1
  29. data/lib/generators/support_desk/views_generator.rb +9 -3
  30. data/lib/support_desk/brief.rb +1 -1
  31. data/lib/support_desk/configuration.rb +100 -19
  32. data/lib/support_desk/conversation_access.rb +42 -0
  33. data/lib/support_desk/engine.rb +7 -0
  34. data/lib/support_desk/launcher.rb +64 -0
  35. data/lib/support_desk/models/concerns/requester.rb +6 -2
  36. data/lib/support_desk/models/ticket.rb +55 -1
  37. data/lib/support_desk/version.rb +1 -1
  38. data/lib/support_desk/wizard.rb +1 -5
  39. data/lib/support_desk.rb +41 -6
  40. 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
- render :new
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 render :new, status: :unprocessable_entity
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
- render :new, status: :unprocessable_entity
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
- render :new, status: :unprocessable_entity
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. The case is READ in its conversation, so this is a redirect and
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
- redirect_to conversation_path_for(find_ticket)
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
- render :rate_limited, status: :too_many_requests
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 h" for a desk, or nil when the
88
- # desk promises nothing. The same `config.reply_within` the SLA breaches
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
- within = SupportDesk.config.desk(desk_key).reply_within
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. Falls back
182
- # to the engine's own ticket URL (which redirects to the same place) for
183
- # the one case that has no conversation yet, so a link is never dead.
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,5 @@
1
+ <%# The wall in the launcher's panel: the same content as the page's, in a screen that scrolls
2
+ inside the panel. %>
3
+ <div class="support-desk-panel__screen support-desk-panel__wall">
4
+ <%= render "support_desk/tickets/limit_wall" %>
5
+ </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: "_top" } do %>
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: "_top" } %>
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: "_top" } %>
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: "_top" } %>
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: "_top" } do %>
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
- <header class="chats-inbox__header">
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" %>