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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f20ef59f51139224d4dc822bc4d96eced0d5517e6b308399f707d43eef11d229
|
|
4
|
+
data.tar.gz: fde3321433991bb1c7aa89a944e57a5fa4cd7cf19da1ba3444f8a81289b962ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0fcd4009a005176029c7f2c07dc5f4a4dd7c6bee5a46428025d9ccc56d30f9b2a76170713d9362a3f5aab658f34c0f9c0ad151d048969fc7b7a0ea3b641ec724
|
|
7
|
+
data.tar.gz: ae545aeec1cac66db40a3df1777fd2c7747dda0103c99d7424b55d06cbbd6dd67cb9cb8b1685e642d97f82153afc2ea149257967957da63b2e1524a7c437bf52
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,140 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-10-19
|
|
8
|
+
|
|
9
|
+
Intended as **0.5.0**: "Need help?" in the corner. **Requires chats >= 0.7.1**
|
|
10
|
+
(its tray and its `:unread_changed` event). No migration; nothing changes
|
|
11
|
+
until you render the launcher.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **The launcher.** `support_launcher` puts a "Need help?" tab in the
|
|
16
|
+
bottom-right corner: in chats' messaging dock's tray, beside its bar
|
|
17
|
+
(`chats_dock { support_launcher }`), or in a corner of its own when there is
|
|
18
|
+
no dock. Its panel is this engine's own screens in a frame — their open
|
|
19
|
+
cases, a new case (the wizard, with the promise under the form), a case's
|
|
20
|
+
thread — so nobody leaves the page they are on. chats draws and runs the
|
|
21
|
+
chrome (one menu open at a time, the windows giving it room, Escape,
|
|
22
|
+
memory, the phone's round button and full-screen sheet); this engine ships
|
|
23
|
+
no JavaScript for it. `show_for: :everyone` shows it to visitors who are
|
|
24
|
+
signed out too, whose panel says how to reach the desk (sign in through
|
|
25
|
+
`/support`, the desk's `config.email`, the host's `help_url:`).
|
|
26
|
+
- **The panel is the pages.** A request from the panel's frame
|
|
27
|
+
(`Turbo-Frame: support_desk_panel`, `SupportDesk::Launcher.panel_request?`)
|
|
28
|
+
is answered with the screen's panel template (`support_desk/panel/home`,
|
|
29
|
+
`new`, `case`, and the limit wall) inside `layouts/support_desk/panel`;
|
|
30
|
+
every other request renders exactly as before. In the panel a new case
|
|
31
|
+
opens in the panel, and so does any case, whatever `thread_page` says. A
|
|
32
|
+
case there is chats' compact thread body (`window: true`), which reads
|
|
33
|
+
itself once the panel is open (the server never reads a panel render: a
|
|
34
|
+
frame loading behind a closed panel must not put the badge out). The limit
|
|
35
|
+
wall has its own panel screen (`support_desk/panel/rate_limited`, the
|
|
36
|
+
page's content from `tickets/_limit_wall`).
|
|
37
|
+
- **A live badge.** `unread_support_count` on the requester's
|
|
38
|
+
`[requester, :support_desk]` stream, replaced wherever chats says an unread
|
|
39
|
+
count in a case may have moved (`Chats.on(:unread_changed)`), the case's own
|
|
40
|
+
notices included, and asked again from `GET /support/tickets/badge` when the
|
|
41
|
+
stream missed one. The Messages badge never counts it. One small job per
|
|
42
|
+
message in a case, counting as it renders, whether or not the host renders
|
|
43
|
+
a launcher: a job cannot tell whether anybody listens, and a switch would
|
|
44
|
+
let a launcher's badge silently never move.
|
|
45
|
+
- `support_panel?`, `support_desk_top_frame` (the frame a link that leaves a
|
|
46
|
+
step targets: `"_top"` on a page, the panel in the panel), and
|
|
47
|
+
`support_desk_slot` with two slots, `panel_top` and `panel_footer`.
|
|
48
|
+
- `support_desk.launcher.*` in en and es; `rails g support_desk:views` ejects
|
|
49
|
+
the launcher and its panel's screens too (`--views launcher`).
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- The requester partials that targeted `_top` (`_pick_topic`, `_pick_thing`,
|
|
54
|
+
`_write`, `_wizard_header`) use `support_desk_top_frame`, and
|
|
55
|
+
`support_thread_path` leads to the ticket page in the panel. **If you
|
|
56
|
+
ejected them and use the launcher**, make the same change in your copies,
|
|
57
|
+
or their links leave the panel for the page.
|
|
58
|
+
- The dummy app loads Turbo and Stimulus for real (its importmap pinned
|
|
59
|
+
neither, so the browser tests ran on plain HTML), and two tests that leaned
|
|
60
|
+
on that were corrected: the console's refused send is rendered in place by
|
|
61
|
+
Turbo, and the thread page under chats 0.6's two panes shows the inbox's
|
|
62
|
+
badged desk row beside the thread.
|
|
63
|
+
|
|
64
|
+
## [0.4.0] - 2026-10-18
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- **Support apart from the inbox.** `config.thread_page = :support` reads a
|
|
69
|
+
case on the engine's own page, `/support/tickets/T-AB12CD`, which renders
|
|
70
|
+
chats' thread template (same bubbles, composer and realtime) with its way
|
|
71
|
+
back leading to the support list. `support_thread_path`, the list rows and
|
|
72
|
+
the redirect after opening a case follow the setting. The default,
|
|
73
|
+
`:chats`, is unchanged. A third Hotwire Native rule pushes the ticket page
|
|
74
|
+
under `:support`.
|
|
75
|
+
- **A requester's own case always opens through chats.** chats finds every
|
|
76
|
+
thread and composer request through `viewer.chats`, which applies the
|
|
77
|
+
host's `inbox_scope`; a host keeping support out of its inbox would 404 its
|
|
78
|
+
requesters inside their own cases. The engine adds the viewer's own cases
|
|
79
|
+
to chats' `conversation_lookup_scopes` (`SupportDesk::ConversationAccess`,
|
|
80
|
+
`SupportDesk::Ticket.conversations_of`): by participation and blocks,
|
|
81
|
+
without the listing scope, each a direct conversation whose subject is that
|
|
82
|
+
very case. Nothing else the scope hides becomes reachable.
|
|
83
|
+
- **`acknowledgement`**, a desk setting: the system line posted right under a
|
|
84
|
+
requester's first message, in the same transaction, and read for them as
|
|
85
|
+
it is posted (a receipt for their own words is never news). Same values and
|
|
86
|
+
interpolations as `opening_line`; nil by default, and the gem ships
|
|
87
|
+
`support_desk.thread.acknowledgement` to opt into.
|
|
88
|
+
- An I18n key given as a line reads its `<key>_with_promise` variant when the
|
|
89
|
+
desk promises a time and the variant exists (the assistant lines already
|
|
90
|
+
did): the shipped acknowledgement is "Thanks, we got it." on a desk with
|
|
91
|
+
`reply_within = nil`, and adds the promise otherwise.
|
|
92
|
+
- `SupportDesk.humanize_promise` and `DeskConfiguration#reply_promise`: the
|
|
93
|
+
promise in the unit the desk wrote it in, from the new
|
|
94
|
+
`support_desk.durations.*` keys.
|
|
95
|
+
- `support_desk.thread.closed_notice` can interpolate `%{reply_within}`,
|
|
96
|
+
`%{desk}` and `%{label}`.
|
|
97
|
+
|
|
98
|
+
### Changed
|
|
99
|
+
|
|
100
|
+
- The answer promise (`support_reply_promise`, the wizard's line, and
|
|
101
|
+
`%{reply_within}` in every line) is now said in the unit `reply_within`
|
|
102
|
+
was configured in: `24.hours` reads "24 hours" where it read "1 day", and
|
|
103
|
+
`8.hours` reads "8 hours" where it read "about 8 hours". A duration of more
|
|
104
|
+
than one unit, or a fractional one, reads as before. Waits in the console
|
|
105
|
+
are unchanged.
|
|
106
|
+
|
|
107
|
+
### Fixed
|
|
108
|
+
|
|
109
|
+
- `SupportDesk::ConversationAccess` adds a requester's own cases as a lookup
|
|
110
|
+
scope (`conversation_lookup_scopes`, chats 0.5.1) instead of rescuing chats'
|
|
111
|
+
404: no exception is inspected, and a host's own `find_conversation` that
|
|
112
|
+
refuses a case is final. It only ever adds a scope beside the host's: a host
|
|
113
|
+
that returns no scopes for a viewer (a suspended account) opens nothing for
|
|
114
|
+
them, their cases included.
|
|
115
|
+
- The ticket page resolves its conversation through chats' own
|
|
116
|
+
`find_conversation` (a chats controller bound to the request), so whatever
|
|
117
|
+
refuses a case at the chats URL refuses it on the ticket page too.
|
|
118
|
+
- The access fallback and the ticket page require the case and its
|
|
119
|
+
conversation to point at each other (a direct conversation whose subject is
|
|
120
|
+
that case): a ticket mislinked to a group or to another conversation opens
|
|
121
|
+
nothing.
|
|
122
|
+
- The ticket page resolves its conversation through chats' participation and
|
|
123
|
+
block rules: owning the case is no longer enough once the seat is gone.
|
|
124
|
+
- The support list's "showing the N most recent" line under a long closed
|
|
125
|
+
section (`support_desk.tickets.closed_capped`) had no copy in English or
|
|
126
|
+
Spanish, so a requester with more than 20 closed cases read "translation
|
|
127
|
+
missing".
|
|
128
|
+
|
|
129
|
+
### Compatibility
|
|
130
|
+
|
|
131
|
+
- Requires chats >= 0.5.1; take both from GitHub by tag until they are on
|
|
132
|
+
rubygems. (The old `~> 0.3, >= 0.3.2` already admitted 0.4 and 0.5: `~> 0.3`
|
|
133
|
+
is `>= 0.3, < 1.0`.) 0.5.1 brings the lookup seam the access to a
|
|
134
|
+
requester's own case is built on. The whole suite runs on chats 0.5.1, the
|
|
135
|
+
reviewed package in `vendor/cache` built from tag v0.5.1 (6159ea1). The
|
|
136
|
+
chats 0.4.2 CI leg is gone: the constraint no longer admits it. Every CI job
|
|
137
|
+
asserts the chats it resolved (`CHATS_VERSION_UNDER_TEST`), so a later chats
|
|
138
|
+
release never silently becomes what CI tests. The dummy app's chats
|
|
139
|
+
migration is chats' current install template.
|
|
140
|
+
|
|
7
141
|
## [0.3.2] - Unreleased
|
|
8
142
|
|
|
9
143
|
### Fixed
|
data/README.md
CHANGED
|
@@ -153,6 +153,7 @@ configuration mistake is a boot failure, never a 3 a.m. `NoMethodError`.
|
|
|
153
153
|
| `current_agent_method` | `:current_user` | how the console finds the person answering (or define `current_agent` in your controller) |
|
|
154
154
|
| `authenticate_method` | `:authenticate_user!` | your own filter, run before every requester screen |
|
|
155
155
|
| `visible_desks_for` | `nil` (every desk) | `->(agent) { … }` returning the desks this agent may work; scopes the whole console |
|
|
156
|
+
| `thread_page` | `:chats` | where a requester reads a case: `:chats` (its thread in the chats inbox) or `:support` (this engine's own ticket page, for support kept apart from the inbox; see [Support apart from the inbox](#support-apart-from-the-inbox)) |
|
|
156
157
|
| `authorize_console` | `nil` (allow) | `->(agent, ticket, action) { … }`; asked before every console action, `ticket` is nil for `index`, `new`, `next` and `open_conversation`; a hook that raises **denies** |
|
|
157
158
|
| `assistant(key) { … }` | — | declare an assistant (see [Assistants](#-assistants)); `config.assistants`, `config.assistant?(key)` read them back |
|
|
158
159
|
| `default_assistant` | the only one declared | which assistant the `:default` desk gets when more than one exists |
|
|
@@ -185,7 +186,7 @@ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
|
|
|
185
186
|
| `reply_policy` | `:anyone` | `:anyone` (a drop-in posts, signed; an unheld case is taken by whoever answers) · `:take_over` (replying reassigns) · `:assignee_only` (raises `NotAllowed`) |
|
|
186
187
|
| `announce_assignments` | `:first_only` | `:first_only` ("Lucía is taking care of your request" once) · `:always` (hand-offs too) · `:never` |
|
|
187
188
|
| `closed_tickets` | `:reopen_on_reply` | `:reopen_on_reply` (a requester writing reopens the case) · `:locked` (composer replaced by a notice) |
|
|
188
|
-
| `reply_within` | `24.hours` | the SLA breach threshold **and** the promise the requester reads |
|
|
189
|
+
| `reply_within` | `24.hours` | the SLA breach threshold **and** the promise the requester reads, said in the unit you write it in (`72.hours` reads "72 hours") |
|
|
189
190
|
| `at_risk_after` | `4.hours` | when a waiting case starts showing as at risk |
|
|
190
191
|
| `open_rate_limit` | `{ to: 5, within: 1.hour }` | how often one requester may **ask**; `nil` disables. Counts only cases the requester opened |
|
|
191
192
|
| `max_open_tickets` | `5` | how many cases one requester may have open; `nil` disables. Same rule |
|
|
@@ -195,6 +196,7 @@ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
|
|
|
195
196
|
| `auto_close_after` | `nil` | reserved for the sweep job: a duration after which an answered case closes itself |
|
|
196
197
|
| `opening_line` | `nil` | the system line a requester-opened thread starts with (see [Writing first](#writing-first)) |
|
|
197
198
|
| `opening_line_from_support` | the gem's I18n copy | the same line for a case the desk opened |
|
|
199
|
+
| `acknowledgement` | `nil` | the system line posted right under a requester's first message ("Thanks, we got it…"; see [Writing first](#the-line-a-thread-opens-with)) |
|
|
198
200
|
| `find_requester { \|query\| … }` | `nil` | how a console turns what an agent typed into a requester record |
|
|
199
201
|
|
|
200
202
|
`SupportDesk.doctor.print` checks all of it against a running app (see
|
|
@@ -410,7 +412,7 @@ mount SupportDesk::Engine => "/support" # CarHey mounts it at "/messages/suppo
|
|
|
410
412
|
| `GET /support` | their cases — open ones as chats rows, closed ones folded away, and the door into a new one |
|
|
411
413
|
| `GET /support/new` | the wizard: pick a topic, pick the thing, write. One URL, three Turbo frames |
|
|
412
414
|
| `POST /support/tickets` | `ask_support!`, then straight into the conversation |
|
|
413
|
-
| `GET /support/tickets/:id` | a stable URL for a case (`/support/tickets/T-AB12CD` works too)
|
|
415
|
+
| `GET /support/tickets/:id` | a stable URL for a case (`/support/tickets/T-AB12CD` works too): a redirect to its thread, or the page it is read on under `thread_page = :support` |
|
|
414
416
|
|
|
415
417
|
The screens inherit `config.parent_controller`, so your layout, your
|
|
416
418
|
authentication (`config.authenticate_method`) and your locale switching all
|
|
@@ -440,6 +442,54 @@ door in its place (`config.inbox_entry = :always | :when_tickets | :never`) —
|
|
|
440
442
|
a support entry that only exists once you already have a ticket is
|
|
441
443
|
undiscoverable.
|
|
442
444
|
|
|
445
|
+
### Support apart from the inbox
|
|
446
|
+
|
|
447
|
+
Some products want support to be a place of its own: a Support button that
|
|
448
|
+
leads to the person's cases, rather than an official row among their
|
|
449
|
+
conversations. Three settings say so:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
# config/initializers/support_desk.rb
|
|
453
|
+
config.thread_page = :support # a case is read at /support/tickets/T-AB12CD
|
|
454
|
+
config.inbox_entry = :never # no door in the chats inbox
|
|
455
|
+
|
|
456
|
+
# config/initializers/chats.rb: support conversations out of the inbox, the unread badge and everything else that lists
|
|
457
|
+
config.inbox_scope = ->(relation, _viewer) {
|
|
458
|
+
relation.where(subject_type: nil).or(relation.where.not(subject_type: "SupportDesk::Ticket"))
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
(`subject_type: nil` keeps the conversations that are about nothing, which is
|
|
463
|
+
most of them: `where.not` alone would hide those too.)
|
|
464
|
+
|
|
465
|
+
The ticket page **is chats' thread**: the engine renders chats' own template,
|
|
466
|
+
so the bubbles, the composer, read state, typing and realtime are the ones
|
|
467
|
+
every other conversation has, with the desk's verified name in the header.
|
|
468
|
+
Only the way back differs: it leads to the support list. Older pages, the
|
|
469
|
+
composer and the live updates still go to chats' endpoints.
|
|
470
|
+
|
|
471
|
+
Those endpoints find a conversation through the relations chats'
|
|
472
|
+
`conversation_lookup_scopes` returns (chats >= 0.5.1), by default the viewer's
|
|
473
|
+
inbox with the host's `inbox_scope` applied, so a scope that keeps support out
|
|
474
|
+
of the inbox would also keep a requester out of their own case. The engine
|
|
475
|
+
adds one relation after chats' own (`SupportDesk::ConversationAccess`): the
|
|
476
|
+
viewer's **own** cases (`SupportDesk::Ticket.conversations_of`), through the
|
|
477
|
+
same participation and block rules (`Chats::Conversation.inbox_for`) without
|
|
478
|
+
the listing scope, each a direct conversation whose subject is that very
|
|
479
|
+
case. Nobody gains a conversation they are not seated in, anything else the
|
|
480
|
+
scope hides stays a 404, and a host's own `find_conversation` that refuses a
|
|
481
|
+
case still refuses it. support_desk only ever adds a scope beside the host's:
|
|
482
|
+
a host that must refuse a viewer entirely returns no scopes, and then nothing
|
|
483
|
+
opens for them, their cases included. The ticket page asks chats the same
|
|
484
|
+
question (chats' own `find_conversation`), so it never shows what the chats
|
|
485
|
+
URL would refuse.
|
|
486
|
+
|
|
487
|
+
Links follow the setting: `support_thread_path`, the list's rows and the
|
|
488
|
+
redirect after a case is opened all lead to the ticket page, by reference.
|
|
489
|
+
Telling the requester an answer arrived is then yours, since the chats
|
|
490
|
+
badge no longer counts it: subscribe to `agent_replied` (see [Events](#events))
|
|
491
|
+
and link the notification to `support_desk.ticket_path(ticket.reference)`.
|
|
492
|
+
|
|
443
493
|
Hotwire Native hosts merge the engine's path rules into their own, AFTER any
|
|
444
494
|
rule that could swallow them:
|
|
445
495
|
|
|
@@ -455,14 +505,90 @@ Both surfaces are pushed screens, never modals: every wizard step is a real
|
|
|
455
505
|
URL, so the back gesture and cold-boot deep links work.
|
|
456
506
|
|
|
457
507
|
|
|
508
|
+
### "Need help?" in the corner: the launcher
|
|
509
|
+
|
|
510
|
+
The support pages are where help lives; the launcher is the door that is
|
|
511
|
+
always there. One line puts a **Need help?** tab in the bottom-right corner,
|
|
512
|
+
and its panel is the same screens in a frame: their open cases, a new case
|
|
513
|
+
(the wizard, with the promise under the form), a case's thread. Nobody leaves
|
|
514
|
+
the page they were on.
|
|
515
|
+
|
|
516
|
+
```erb
|
|
517
|
+
<%# A host with chats' messaging dock: the launcher sits in the dock's tray, beside its bar %>
|
|
518
|
+
<%= chats_dock do %>
|
|
519
|
+
<%= support_launcher %>
|
|
520
|
+
<% end %>
|
|
521
|
+
|
|
522
|
+
<%# A host without the dock: the launcher brings the corner with it %>
|
|
523
|
+
<%= support_launcher %>
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+

|
|
527
|
+
|
|
528
|
+
| Their cases | A case, in the panel | A new one |
|
|
529
|
+
| --- | --- | --- |
|
|
530
|
+
|  |  |  |
|
|
531
|
+
|
|
532
|
+
It is an item of chats' **tray** (chats >= 0.7.1), so chats draws the tab,
|
|
533
|
+
the panel's chrome and the phone's sheet, and runs them: one menu open at a
|
|
534
|
+
time with the Messages panel, the windows giving it room rather than being
|
|
535
|
+
covered, Escape, the open item remembered on a desk, a 48px round button and a
|
|
536
|
+
full-screen sheet on a phone (`chats_dock launcher: false` takes the buttons
|
|
537
|
+
away). The look is the dock's, themed with the same `--chats-dock-*`
|
|
538
|
+
variables. This engine ships no JavaScript of its own for it.
|
|
539
|
+
|
|
540
|
+
- **The panel is the pages.** A request from the panel's frame
|
|
541
|
+
(`Turbo-Frame: support_desk_panel`) is answered with the screen's panel
|
|
542
|
+
template (`support_desk/panel/home`, `new`, `case`) inside
|
|
543
|
+
`layouts/support_desk/panel`; every other request renders exactly as before.
|
|
544
|
+
Links that leave a step target the panel instead of the page
|
|
545
|
+
(`support_desk_top_frame`), a case opens in the panel whatever
|
|
546
|
+
`thread_page` says (`support_thread_path`), and a new case opens there
|
|
547
|
+
after it is sent. ⤢ opens the case's page; "All your conversations" the
|
|
548
|
+
list, closed ones included.
|
|
549
|
+
- **A case in the panel is chats' thread**, the compact body a dock window
|
|
550
|
+
renders: live, the composer at the bottom, the desk's signature, the way to
|
|
551
|
+
a person. It is read only while the panel is open (the tray item is the
|
|
552
|
+
thread's read gate), and when the page itself shows the same case (its
|
|
553
|
+
page, or a dock window), the panel goes back to the list rather than show
|
|
554
|
+
it twice.
|
|
555
|
+
- **The badge** is `unread_support_count`, unread messages across their own
|
|
556
|
+
cases (the number `support_unread_badge` shows), live on the requester's
|
|
557
|
+
`[requester, :support_desk]` stream: it moves wherever chats says an unread
|
|
558
|
+
count may have moved (`:unread_changed`), the case's own notices included,
|
|
559
|
+
and catches up from `GET /support/tickets/badge` when the stream missed one.
|
|
560
|
+
It never touches the Messages badge, which counts the inbox (your
|
|
561
|
+
`inbox_scope` applied): a host that keeps support out of the inbox gets two
|
|
562
|
+
separate numbers. The open list reloads in place when it moves.
|
|
563
|
+
- **Signed out**, `support_launcher show_for: :everyone` shows the same tab,
|
|
564
|
+
and its panel says how to reach you: sign in (through `/support`, so your
|
|
565
|
+
own authentication asks and brings them back), the desk's `config.email`
|
|
566
|
+
for those who can't, and your help centre (`help_url:`).
|
|
567
|
+
|
|
568
|
+
<img src="https://raw.githubusercontent.com/rameerez/support_desk/main/screenshots/launcher/phone-sheet.png" alt="A phone: the panel is a full-screen sheet" width="240"> <img src="https://raw.githubusercontent.com/rameerez/support_desk/main/screenshots/launcher/signed-out.png" alt="Signed out: how to reach the desk" width="400">
|
|
569
|
+
|
|
570
|
+
```erb
|
|
571
|
+
<%= support_launcher show_for: :everyone, help_url: "https://example.com/help" %>
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
The words are `support_desk.launcher.*` (override them in your own locale
|
|
575
|
+
files); the icon is `support_desk/launcher/_icon` (eject it to draw your own).
|
|
576
|
+
Two slots put your own content in the panel, above and below it:
|
|
577
|
+
`app/views/support_desk/slots/_panel_top.html.erb` and `_panel_footer.html.erb`
|
|
578
|
+
(both get `requester:`, nil when signed out). The `/support` pages stay the
|
|
579
|
+
place support lives; keep your Support links to them.
|
|
580
|
+
|
|
458
581
|
### Every helper the requester views can use
|
|
459
582
|
|
|
460
583
|
| helper | what it renders |
|
|
461
584
|
|---|---|
|
|
462
585
|
| `link_to_support(about:, text:, **html)` | the door into the wizard (nothing when there is nobody to ask, or nothing they may ask about) |
|
|
586
|
+
| `support_launcher(show_for: :signed_in, help_url: nil)` | the ["Need help?" launcher](#need-help-in-the-corner-the-launcher), in chats' tray |
|
|
463
587
|
| `support_unread_badge` | unread support messages, counted against the chats read horizon |
|
|
588
|
+
| `support_panel?` / `support_desk_top_frame` | whether this render answers the launcher's panel; the frame a link that leaves a step targets (`"_top"`, or the panel) |
|
|
589
|
+
| `support_desk_slot(name, **locals)` | the launcher panel's `panel_top` / `panel_footer` slots |
|
|
464
590
|
| `support_desk_styles` | the bundled stylesheet tag for your `<head>` |
|
|
465
|
-
| `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` |
|
|
591
|
+
| `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` (also `SupportDesk.config.desk(key).reply_promise`) |
|
|
466
592
|
| `support_ticket_state(ticket)` | "We're on it" · "We replied" · "We wrote to you" · "Closed" |
|
|
467
593
|
| `support_inbox_door?(viewer)` | whether the inbox should show the door instead of a row (`inbox_entry`) |
|
|
468
594
|
| `support_desk_record(key)`, `support_desk_avatar`, `support_desk_badge` | the desk as a counterpart: record, avatar (initials fallback), verified badge |
|
|
@@ -482,8 +608,9 @@ the screens yours:
|
|
|
482
608
|
rails generate support_desk:views
|
|
483
609
|
```
|
|
484
610
|
|
|
485
|
-
That copies `app/views/support_desk/tickets
|
|
486
|
-
|
|
611
|
+
That copies `app/views/support_desk/tickets/**`, the launcher and its panel's
|
|
612
|
+
screens (`support_desk/launcher/**`, `support_desk/panel/**`) and the two rows
|
|
613
|
+
this engine contributes to chats' screens (`app/views/chats/slots/**`) into your app,
|
|
487
614
|
where they shadow the gem's copies — the Devise move. Delete your copy and
|
|
488
615
|
the default comes back; upgrade the gem and your copy is untouched. Every
|
|
489
616
|
view helper the templates use stays available afterwards, so an ejected copy
|
|
@@ -682,6 +809,33 @@ existing threads open exactly as they do today; `opening_line_from_support`
|
|
|
682
809
|
has the gem's own copy as its default, because a message from a desk
|
|
683
810
|
somebody never wrote to has to explain itself.
|
|
684
811
|
|
|
812
|
+
And one line goes **under** the first message, for a case the requester
|
|
813
|
+
opened: the answer to "did that go through?", posted in the same
|
|
814
|
+
transaction, before anybody on the desk has read it.
|
|
815
|
+
|
|
816
|
+
```ruby
|
|
817
|
+
config.acknowledgement = :"support_desk.thread.acknowledgement"
|
|
818
|
+
# => "Thanks, we got it. We usually reply in under 72 hours."
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
An I18n key reads its `<key>_with_promise` variant when the desk promises a
|
|
822
|
+
time and the variant exists (the shipped key has one), so a desk with
|
|
823
|
+
`reply_within = nil` says "Thanks, we got it." and nothing it cannot keep.
|
|
824
|
+
The requester's read horizon moves through the acknowledgement as it is
|
|
825
|
+
posted: it is a receipt for what they just wrote, never news to them.
|
|
826
|
+
|
|
827
|
+
Same values and interpolations as the opening lines, nil by default. It
|
|
828
|
+
never moves the clocks (it is a system message), it is pinned one database
|
|
829
|
+
tick after the message when the clock did not move between the two inserts,
|
|
830
|
+
and it is then the conversation's last message, which is what a list row
|
|
831
|
+
previews. A reply into a case already open is not acknowledged again.
|
|
832
|
+
|
|
833
|
+
`%{reply_within}` is the promise in the unit it was configured in
|
|
834
|
+
(`SupportDesk.humanize_promise`: `72.hours` is "72 hours", never "3 days"),
|
|
835
|
+
from `support_desk.durations.*`. The closed notice
|
|
836
|
+
(`support_desk.thread.closed_notice`) takes the same three interpolations,
|
|
837
|
+
so a locked case can say how soon a new one will be answered.
|
|
838
|
+
|
|
685
839
|
### From the console
|
|
686
840
|
|
|
687
841
|
Add `new` to your routes and the queue grows a "Write to someone" button:
|
|
@@ -1603,7 +1757,7 @@ that is exactly when a seat gets stranded.
|
|
|
1603
1757
|
|
|
1604
1758
|
## Compatibility
|
|
1605
1759
|
|
|
1606
|
-
Rails 7.2, 8.0 and 8.1; Ruby >= 3.2; PostgreSQL, SQLite and MySQL; bigint or UUID primary keys (the migration follows your app's `primary_key_type`).
|
|
1760
|
+
Rails 7.2, 8.0 and 8.1; Ruby >= 3.2; PostgreSQL, SQLite and MySQL; bigint or UUID primary keys (the migration follows your app's `primary_key_type`). chats >= 0.7.1 (0.5.0 and later: the launcher sits in chats' tray and its badge listens to chats' `:unread_changed`).
|
|
1607
1761
|
|
|
1608
1762
|
PostgreSQL and SQLite enforce unique new-case creation, one open assignment per ticket and
|
|
1609
1763
|
one pending proposal per ticket with **partial unique indexes**. New submissions reuse an existing open case. Reopening
|
|
@@ -1674,7 +1828,8 @@ SupportDesk.find_topic("billing/invoice")
|
|
|
1674
1828
|
SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
|
|
1675
1829
|
SupportDesk.doctor
|
|
1676
1830
|
SupportDesk.native_path_rules(mount:, title:) # Hotwire Native path rules for the requester screens
|
|
1677
|
-
SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language
|
|
1831
|
+
SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language (a wait: rounded)
|
|
1832
|
+
SupportDesk.humanize_promise(72.hours) # "72 hours" (a promise: in the unit it was made in)
|
|
1678
1833
|
SupportDesk.actor_key(record) # a stable key for an actor (GlobalID param); what event payloads store
|
|
1679
1834
|
SupportDesk.requester_class?(klass) .supportable_class?(klass) .agent_class?(klass)
|
|
1680
1835
|
SupportDesk.eligible?(record, condition) # how both macros read their `if:`
|
|
@@ -1686,7 +1841,7 @@ SupportDesk::VERSION
|
|
|
1686
1841
|
|
|
1687
1842
|
After checking out the repo, run `bundle install`, then `bundle exec rake ci`. The dummy app lives in `test/dummy` and mounts all three surfaces the way a real host does: the requester engine at `/messages/support`, `chats` at `/messages`, and the turnkey console at `/admin/support` — plus the same console again inside a host-owned `madmin` namespace, because "the console uses only the public API" is a claim that needs a second implementation to be worth anything.
|
|
1688
1843
|
|
|
1689
|
-
`chats` is the kernel this gem is a product on and the two are developed in lockstep,
|
|
1844
|
+
`chats` is the kernel this gem is a product on and the two are developed in lockstep. support_desk requires `chats >= 0.7.1, < 1.0`. Until that is on rubygems, the bundle takes the reviewed chats package from `vendor/cache` (provenance, checksum and the step that adds it in `vendor/cache/README.md`), so `bundle exec rake ci` needs no network and no sibling checkout.
|
|
1690
1845
|
|
|
1691
1846
|
## Contributing
|
|
1692
1847
|
|
|
@@ -250,3 +250,80 @@
|
|
|
250
250
|
only the error notice carries colours of its own. */
|
|
251
251
|
.support-desk-error { background: #3f1d1d; color: #fecaca; }
|
|
252
252
|
}
|
|
253
|
+
|
|
254
|
+
/* --- The launcher's panel (0.5.0) ------------------------------------------- *
|
|
255
|
+
*
|
|
256
|
+
* `support_launcher` is an item of chats' tray: chats draws its tab, the
|
|
257
|
+
* panel's chrome and the phone's sheet. This is the content: a frame that
|
|
258
|
+
* fills the panel and scrolls inside it. Rows, the empty state, "All your
|
|
259
|
+
* conversations" and a case's header wear the dock's own classes
|
|
260
|
+
* (.chats-dock__list, .chats-dock__see-all, .chats-window__header), so a
|
|
261
|
+
* host's dock theme reaches them too.
|
|
262
|
+
*/
|
|
263
|
+
.support-desk-launcher__name { overflow: hidden; text-overflow: ellipsis; }
|
|
264
|
+
|
|
265
|
+
.support-desk-panel {
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
268
|
+
flex: 1;
|
|
269
|
+
min-height: 0;
|
|
270
|
+
font-size: 0.875rem;
|
|
271
|
+
}
|
|
272
|
+
.support-desk-panel__screen {
|
|
273
|
+
display: flex;
|
|
274
|
+
flex-direction: column;
|
|
275
|
+
flex: 1;
|
|
276
|
+
min-height: 0;
|
|
277
|
+
overflow-y: auto;
|
|
278
|
+
overscroll-behavior: contain;
|
|
279
|
+
}
|
|
280
|
+
.support-desk-panel__screen > .support-desk-door { margin: 0.75rem; }
|
|
281
|
+
.support-desk-panel__all { margin-top: auto; gap: 0.4rem; }
|
|
282
|
+
/* inline-block: the link's underline (a host's, on hover) stops at the count. */
|
|
283
|
+
.support-desk-panel__count { display: inline-block; font-weight: 400; color: var(--support-desk-text-muted); }
|
|
284
|
+
.support-desk-panel__loading { margin: auto; padding: 1.5rem; text-align: center; }
|
|
285
|
+
|
|
286
|
+
/* The wizard, compacted to the panel's width. */
|
|
287
|
+
.support-desk-panel__wizard .support-desk-wizard__header { padding: 0.85rem 0.85rem 0.15rem; }
|
|
288
|
+
.support-desk-panel__wizard .support-desk-wizard__ask { font-size: 1.05rem; }
|
|
289
|
+
.support-desk-panel__wizard :is(.support-desk-choice, .support-desk-card, .support-desk-error, .support-desk-notice) {
|
|
290
|
+
margin-inline: 0.75rem;
|
|
291
|
+
}
|
|
292
|
+
.support-desk-panel__wizard .support-desk-choice { padding: 0.7rem 0.85rem; }
|
|
293
|
+
.support-desk-panel__wizard .support-desk-form { padding: 0 0.75rem 1rem; }
|
|
294
|
+
.support-desk-panel__wizard .support-desk-form__input { padding: 0.65rem 0.8rem; }
|
|
295
|
+
|
|
296
|
+
/* A case: the dock window's header, and the way to a person under it. */
|
|
297
|
+
.support-desk-panel__nav { padding: 0 4px; gap: 2px; }
|
|
298
|
+
.support-desk-panel__heading { padding: 0 6px; }
|
|
299
|
+
.support-desk-panel__human {
|
|
300
|
+
flex: none;
|
|
301
|
+
padding: 0.5rem 0.75rem;
|
|
302
|
+
border-bottom: 1px solid var(--chats-dock-edge, var(--support-desk-border));
|
|
303
|
+
}
|
|
304
|
+
.support-desk-panel__human .support-desk-notice__title { margin: 0; font-size: 0.8125rem; }
|
|
305
|
+
|
|
306
|
+
/* Signed out: how to reach the desk. */
|
|
307
|
+
.support-desk-panel__signed-out { gap: 0.75rem; padding: 1rem; }
|
|
308
|
+
.support-desk-panel__lead,
|
|
309
|
+
.support-desk-panel__promise,
|
|
310
|
+
.support-desk-panel__note { margin: 0; }
|
|
311
|
+
.support-desk-panel__lead { font-size: 0.9375rem; font-weight: 600; }
|
|
312
|
+
.support-desk-panel__promise,
|
|
313
|
+
.support-desk-panel__note { color: var(--support-desk-text-muted); }
|
|
314
|
+
.support-desk-panel__note a,
|
|
315
|
+
.support-desk-panel__link { color: inherit; font-weight: 600; }
|
|
316
|
+
|
|
317
|
+
/* A phone: the sheet fills the screen; fields at 16px, or iOS zooms in. */
|
|
318
|
+
@media (max-width: 699.98px), (max-height: 499.98px) {
|
|
319
|
+
.support-desk-panel { font-size: 1rem; }
|
|
320
|
+
.support-desk-panel__wizard .support-desk-form__input { font-size: 16px; }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* The wall in the panel: its title at the panel's size, room around it. */
|
|
324
|
+
.support-desk-panel__wall { padding-bottom: 1rem; }
|
|
325
|
+
.support-desk-panel__wall .chats-inbox__header { padding: 0.85rem 0.85rem 0.25rem; }
|
|
326
|
+
.support-desk-panel__wall .chats-inbox__title { font-size: 1.05rem; margin: 0; }
|
|
327
|
+
.support-desk-panel__wall :is(.support-desk-notice__title, .support-desk-form) { margin-inline: 0.75rem; padding-inline: 0; }
|
|
328
|
+
.support-desk-panel__wall > .support-desk-button,
|
|
329
|
+
.support-desk-panel__wall .support-desk-button { margin: 0.5rem 0.75rem 0; }
|
|
@@ -81,10 +81,29 @@ module SupportDesk
|
|
|
81
81
|
respond_to?(:chats) ? chats : Chats::Engine.routes.url_helpers
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
84
|
+
# Whether this request is the launcher's panel (SupportDesk::Launcher):
|
|
85
|
+
# then the screens answer with the panel's templates.
|
|
86
|
+
def support_panel?
|
|
87
|
+
SupportDesk::Launcher.panel_request?(request)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A screen: +page+ on the page, or, in the launcher's panel, +panel+
|
|
91
|
+
# inside the panel's frame (layouts/support_desk/panel) and nothing else
|
|
92
|
+
# of the layout.
|
|
93
|
+
def render_screen(page, panel: page, **options)
|
|
94
|
+
return render(page, **options) unless support_panel?
|
|
95
|
+
|
|
96
|
+
render panel, layout: "support_desk/panel", **options
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Where a case is read and answered: its chats conversation, in the
|
|
100
|
+
# chats inbox (`thread_page = :chats`) or on this engine's ticket page
|
|
101
|
+
# (`:support`). Never a second thread UI of our own: that page renders
|
|
102
|
+
# chats' thread. In the launcher's panel, always the ticket page, which
|
|
103
|
+
# answers the panel with the case inside it.
|
|
86
104
|
def conversation_path_for(ticket)
|
|
87
105
|
return root_path if ticket.conversation.nil?
|
|
106
|
+
return ticket_path(ticket.reference) if support_panel? || SupportDesk.config.support_thread_page?
|
|
88
107
|
|
|
89
108
|
chats_routes.conversation_path(ticket.conversation)
|
|
90
109
|
end
|