support_desk 0.4.0 → 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 +57 -0
- data/README.md +81 -4
- data/app/assets/stylesheets/support_desk.css +77 -0
- data/app/controllers/support_desk/application_controller.rb +18 -2
- data/app/controllers/support_desk/tickets_controller.rb +35 -11
- data/app/helpers/support_desk/engine_helper.rb +69 -4
- 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/config/locales/support_desk.en.yml +10 -0
- data/config/locales/support_desk.es.yml +10 -0
- data/config/routes.rb +4 -0
- data/lib/generators/support_desk/views_generator.rb +9 -3
- data/lib/support_desk/launcher.rb +64 -0
- data/lib/support_desk/models/concerns/requester.rb +6 -2
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +9 -2
- metadata +15 -4
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,63 @@ 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
|
+
|
|
7
64
|
## [0.4.0] - 2026-10-18
|
|
8
65
|
|
|
9
66
|
### Added
|
data/README.md
CHANGED
|
@@ -505,12 +505,88 @@ Both surfaces are pushed screens, never modals: every wizard step is a real
|
|
|
505
505
|
URL, so the back gesture and cold-boot deep links work.
|
|
506
506
|
|
|
507
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
|
+
|
|
508
581
|
### Every helper the requester views can use
|
|
509
582
|
|
|
510
583
|
| helper | what it renders |
|
|
511
584
|
|---|---|
|
|
512
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 |
|
|
513
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 |
|
|
514
590
|
| `support_desk_styles` | the bundled stylesheet tag for your `<head>` |
|
|
515
591
|
| `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` (also `SupportDesk.config.desk(key).reply_promise`) |
|
|
516
592
|
| `support_ticket_state(ticket)` | "We're on it" · "We replied" · "We wrote to you" · "Closed" |
|
|
@@ -532,8 +608,9 @@ the screens yours:
|
|
|
532
608
|
rails generate support_desk:views
|
|
533
609
|
```
|
|
534
610
|
|
|
535
|
-
That copies `app/views/support_desk/tickets
|
|
536
|
-
|
|
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,
|
|
537
614
|
where they shadow the gem's copies — the Devise move. Delete your copy and
|
|
538
615
|
the default comes back; upgrade the gem and your copy is untouched. Every
|
|
539
616
|
view helper the templates use stays available afterwards, so an ejected copy
|
|
@@ -1680,7 +1757,7 @@ that is exactly when a seat gets stranded.
|
|
|
1680
1757
|
|
|
1681
1758
|
## Compatibility
|
|
1682
1759
|
|
|
1683
|
-
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`).
|
|
1684
1761
|
|
|
1685
1762
|
PostgreSQL and SQLite enforce unique new-case creation, one open assignment per ticket and
|
|
1686
1763
|
one pending proposal per ticket with **partial unique indexes**. New submissions reuse an existing open case. Reopening
|
|
@@ -1764,7 +1841,7 @@ SupportDesk::VERSION
|
|
|
1764
1841
|
|
|
1765
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.
|
|
1766
1843
|
|
|
1767
|
-
`chats` is the kernel this gem is a product on and the two are developed in lockstep. support_desk requires `chats >= 0.
|
|
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.
|
|
1768
1845
|
|
|
1769
1846
|
## Contributing
|
|
1770
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,13 +81,29 @@ module SupportDesk
|
|
|
81
81
|
respond_to?(:chats) ? chats : Chats::Engine.routes.url_helpers
|
|
82
82
|
end
|
|
83
83
|
|
|
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
|
+
|
|
84
99
|
# Where a case is read and answered: its chats conversation, in the
|
|
85
100
|
# chats inbox (`thread_page = :chats`) or on this engine's ticket page
|
|
86
101
|
# (`:support`). Never a second thread UI of our own: that page renders
|
|
87
|
-
# chats' thread.
|
|
102
|
+
# chats' thread. In the launcher's panel, always the ticket page, which
|
|
103
|
+
# answers the panel with the case inside it.
|
|
88
104
|
def conversation_path_for(ticket)
|
|
89
105
|
return root_path if ticket.conversation.nil?
|
|
90
|
-
return ticket_path(ticket.reference) if SupportDesk.config.support_thread_page?
|
|
106
|
+
return ticket_path(ticket.reference) if support_panel? || SupportDesk.config.support_thread_page?
|
|
91
107
|
|
|
92
108
|
chats_routes.conversation_path(ticket.conversation)
|
|
93
109
|
end
|
|
@@ -32,12 +32,22 @@ module SupportDesk
|
|
|
32
32
|
@closed_tickets = tickets.closed.newest_first.limit(CLOSED_TICKETS_SHOWN).to_a
|
|
33
33
|
@closed_count = tickets.closed.count
|
|
34
34
|
@unread_counts = unread_counts_for(@open_tickets + @closed_tickets)
|
|
35
|
+
render_screen :index, panel: "support_desk/panel/home"
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
# The wizard. One URL, three frames — which one renders is the wizard's
|
|
38
|
-
# 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.
|
|
39
41
|
def new
|
|
40
|
-
|
|
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 })
|
|
41
51
|
end
|
|
42
52
|
|
|
43
53
|
# Open the case and hand the requester straight to the conversation.
|
|
@@ -49,20 +59,20 @@ module SupportDesk
|
|
|
49
59
|
def create
|
|
50
60
|
if message.blank? && files.empty?
|
|
51
61
|
@error = t("support_desk.wizard.message_required")
|
|
52
|
-
return
|
|
62
|
+
return render_wizard(status: :unprocessable_entity)
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
ticket = @wizard.open!(message, files: files)
|
|
56
66
|
redirect_to conversation_path_for(ticket)
|
|
57
67
|
rescue ActiveRecord::RecordInvalid => e
|
|
58
68
|
@error = e.record.errors.full_messages.to_sentence
|
|
59
|
-
|
|
69
|
+
render_wizard(status: :unprocessable_entity)
|
|
60
70
|
rescue SupportDesk::RateLimited, SupportDesk::TooManyOpenTickets => e
|
|
61
71
|
render_limit_wall(e)
|
|
62
72
|
rescue SupportDesk::InvalidTransition
|
|
63
73
|
# The form was submitted from a step that isn't the composer (a stale
|
|
64
74
|
# tab, a hand-rolled POST): re-render the step they are actually on.
|
|
65
|
-
|
|
75
|
+
render_wizard(status: :unprocessable_entity)
|
|
66
76
|
rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable, SupportDesk::NotARequester
|
|
67
77
|
# The subject passed the wizard's check and failed the model's, or this
|
|
68
78
|
# account stopped being able to write between the form and the submit.
|
|
@@ -75,12 +85,14 @@ module SupportDesk
|
|
|
75
85
|
# to. Under `thread_page = :chats` the case is read in the inbox's thread,
|
|
76
86
|
# so this is a redirect and nothing else. Under `:support` it is the page
|
|
77
87
|
# the case is read on: chats' own thread, with its back link leading
|
|
78
|
-
# here rather than to an inbox support is not part of.
|
|
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.
|
|
79
91
|
def show
|
|
80
92
|
ticket = find_ticket
|
|
81
|
-
return
|
|
93
|
+
return render_thread(ticket) if ticket.conversation && (support_panel? || SupportDesk.config.support_thread_page?)
|
|
82
94
|
|
|
83
|
-
|
|
95
|
+
redirect_to conversation_path_for(ticket)
|
|
84
96
|
end
|
|
85
97
|
|
|
86
98
|
# "Prefiero hablar con una persona." The one thing a requester can
|
|
@@ -130,9 +142,21 @@ module SupportDesk
|
|
|
130
142
|
# reading the case; the visit that follows reads it. Read through the
|
|
131
143
|
# last message on the page, not merely "now": an acknowledgement pinned
|
|
132
144
|
# one tick after the message it answers may sit just ahead of the clock.
|
|
133
|
-
|
|
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
|
|
134
156
|
|
|
135
|
-
|
|
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
|
|
136
160
|
end
|
|
137
161
|
|
|
138
162
|
# The case's conversation, as chats' own endpoints would find it: chats'
|
|
@@ -199,7 +223,7 @@ module SupportDesk
|
|
|
199
223
|
.includes(:desk, :subject, conversation: { last_message: :sender })
|
|
200
224
|
.recent_activity_first.to_a
|
|
201
225
|
@unread_counts = unread_counts_for(@open_tickets)
|
|
202
|
-
|
|
226
|
+
render_screen :rate_limited, panel: "support_desk/panel/rate_limited", status: :too_many_requests
|
|
203
227
|
end
|
|
204
228
|
|
|
205
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
|
|
@@ -177,11 +241,12 @@ module SupportDesk
|
|
|
177
241
|
|
|
178
242
|
# Where a case is read and answered: its chats conversation, or the
|
|
179
243
|
# engine's own ticket page under `thread_page = :support` (by reference:
|
|
180
|
-
# the URL people read back from a screen)
|
|
181
|
-
#
|
|
182
|
-
#
|
|
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.
|
|
183
248
|
def support_thread_path(ticket)
|
|
184
|
-
return support_desk_routes.ticket_path(ticket.reference) if SupportDesk.config.support_thread_page?
|
|
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,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>
|
|
@@ -93,6 +93,16 @@ en:
|
|
|
93
93
|
about: "Need help with %{subject}?"
|
|
94
94
|
existing: "See your support conversation"
|
|
95
95
|
new_conversation: "Start a new conversation"
|
|
96
|
+
launcher:
|
|
97
|
+
label: "Need help?"
|
|
98
|
+
open: "Open support"
|
|
99
|
+
all: "All your conversations"
|
|
100
|
+
open_page: "Open in a page"
|
|
101
|
+
signed_out:
|
|
102
|
+
lead: "Sign in and write to us: your conversations with us are kept in your account."
|
|
103
|
+
sign_in: "Sign in to write to us"
|
|
104
|
+
email_html: "Can't sign in? Write to %{email}."
|
|
105
|
+
help: "Visit the help centre"
|
|
96
106
|
inbox:
|
|
97
107
|
empty_hint: "Need help? Write to us"
|
|
98
108
|
limits:
|
|
@@ -109,6 +109,16 @@ es:
|
|
|
109
109
|
about: "¿Necesitas ayuda con %{subject}?"
|
|
110
110
|
existing: "Ver tu conversación de soporte"
|
|
111
111
|
new_conversation: "Abrir una nueva conversación"
|
|
112
|
+
launcher:
|
|
113
|
+
label: "¿Necesitas ayuda?"
|
|
114
|
+
open: "Abrir soporte"
|
|
115
|
+
all: "Todas tus conversaciones"
|
|
116
|
+
open_page: "Abrir en una página"
|
|
117
|
+
signed_out:
|
|
118
|
+
lead: "Inicia sesión y escríbenos: tus conversaciones con nosotros se guardan en tu cuenta."
|
|
119
|
+
sign_in: "Inicia sesión para escribirnos"
|
|
120
|
+
email_html: "¿No puedes iniciar sesión? Escribe a %{email}."
|
|
121
|
+
help: "Visita el centro de ayuda"
|
|
112
122
|
inbox:
|
|
113
123
|
empty_hint: "¿Necesitas ayuda? Escríbenos"
|
|
114
124
|
limits:
|
data/config/routes.rb
CHANGED
|
@@ -19,6 +19,10 @@ SupportDesk::Engine.routes.draw do
|
|
|
19
19
|
# for emails and notifications, that never has to know where chats is
|
|
20
20
|
# mounted.
|
|
21
21
|
resources :tickets, only: %i[create show] do
|
|
22
|
+
# The launcher's badge, as a Turbo Stream: what its stream missed
|
|
23
|
+
# (a reconnect, a return to the tab), asked again by chats' tray.
|
|
24
|
+
get :badge, on: :collection
|
|
25
|
+
|
|
22
26
|
# The door out of a machine. It is a POST on the CASE rather than
|
|
23
27
|
# anything to do with chats, because it is a fact about the case: from
|
|
24
28
|
# here on a person is expected, whatever the assistant would have done
|
|
@@ -23,15 +23,21 @@ module SupportDesk
|
|
|
23
23
|
|
|
24
24
|
# Which groups to eject. `tickets` is every requester screen (the list,
|
|
25
25
|
# the wizard's three frames, the rows and the doors); `slots` is the
|
|
26
|
-
# row this engine contributes to chats' inbox
|
|
26
|
+
# row this engine contributes to chats' inbox; `launcher` is the
|
|
27
|
+
# "Need help?" launcher (its tab, badge, icon and signed-out panel) and
|
|
28
|
+
# the screens its panel shows.
|
|
27
29
|
class_option :views,
|
|
28
30
|
type: :array,
|
|
29
|
-
default: %w[tickets slots],
|
|
30
|
-
desc: "Which view groups to copy (tickets, slots)"
|
|
31
|
+
default: %w[tickets slots launcher],
|
|
32
|
+
desc: "Which view groups to copy (tickets, slots, launcher)"
|
|
31
33
|
|
|
32
34
|
def copy_views
|
|
33
35
|
directory "support_desk/tickets", "app/views/support_desk/tickets" if include?("tickets")
|
|
34
36
|
directory "chats/slots", "app/views/chats/slots" if include?("slots")
|
|
37
|
+
return unless include?("launcher")
|
|
38
|
+
|
|
39
|
+
directory "support_desk/launcher", "app/views/support_desk/launcher"
|
|
40
|
+
directory "support_desk/panel", "app/views/support_desk/panel"
|
|
35
41
|
end
|
|
36
42
|
|
|
37
43
|
def show_styling_tip
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The "Need help?" launcher (`support_launcher`): an item of chats' tray
|
|
5
|
+
# (chats >= 0.7.0), beside the messaging dock's bar or in a corner of its
|
|
6
|
+
# own, whose panel is this engine's own pages in a frame.
|
|
7
|
+
#
|
|
8
|
+
# This module is the launcher's two names and its live badge:
|
|
9
|
+
#
|
|
10
|
+
# FRAME the panel's turbo frame. A request that carries it
|
|
11
|
+
# (`Turbo-Frame: support_desk_panel`) is answered with the
|
|
12
|
+
# panel's templates (SupportDesk::TicketsController).
|
|
13
|
+
# BADGE_ID the badge the requester's `[requester, :support_desk]`
|
|
14
|
+
# stream replaces.
|
|
15
|
+
#
|
|
16
|
+
# The badge is `requester.unread_support_count`: unread messages across
|
|
17
|
+
# their own cases, against chats' read horizon, with no listing scope. It
|
|
18
|
+
# never touches the Messages badge, which counts `messager.chats` (the
|
|
19
|
+
# host's inbox_scope applied). It moves wherever chats says an unread count
|
|
20
|
+
# may have moved (`Chats.on(:unread_changed)`: every message, the case's
|
|
21
|
+
# own notices included, and a read), for a conversation that is a case's.
|
|
22
|
+
module Launcher
|
|
23
|
+
FRAME = "support_desk_panel"
|
|
24
|
+
BADGE_ID = "support_desk_launcher_badge"
|
|
25
|
+
BADGE_PARTIAL = "support_desk/launcher/badge"
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Whether +request+ is the panel's: its frame asked for it. Then the
|
|
29
|
+
# requester's screens answer with the panel's templates, every link
|
|
30
|
+
# stays inside it, and a new case opens in it.
|
|
31
|
+
def panel_request?(request)
|
|
32
|
+
request&.headers&.[]("Turbo-Frame") == FRAME
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# chats' `:unread_changed`, filtered to support: a case's conversation
|
|
36
|
+
# (its subject is the ticket, no query needed) and a seat that asks
|
|
37
|
+
# for help (a requester; a desk or an agent's console has no launcher).
|
|
38
|
+
def unread_changed(messager, conversation)
|
|
39
|
+
return unless conversation&.subject_type == SupportDesk::Ticket.polymorphic_name
|
|
40
|
+
return unless messager.respond_to?(:unread_support_count)
|
|
41
|
+
|
|
42
|
+
broadcast_badge_of(messager)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Replace +requester+'s launcher badge, wherever it is on screen: one
|
|
46
|
+
# job, which counts when it renders (nothing is counted in the request
|
|
47
|
+
# that wrote the message). A missed one heals on the launcher's
|
|
48
|
+
# catch-up (GET tickets/badge).
|
|
49
|
+
#
|
|
50
|
+
# It runs whether or not this host renders a launcher: there is no
|
|
51
|
+
# telling, from a job, whether anybody is listening, and a switch for it
|
|
52
|
+
# would let a launcher's badge silently never move. Support is
|
|
53
|
+
# human-paced, so this is one small job per message in a case.
|
|
54
|
+
def broadcast_badge_of(requester)
|
|
55
|
+
Turbo::StreamsChannel.broadcast_replace_later_to(
|
|
56
|
+
requester, :support_desk,
|
|
57
|
+
target: BADGE_ID,
|
|
58
|
+
partial: BADGE_PARTIAL,
|
|
59
|
+
locals: { requester: requester }
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -93,9 +93,13 @@ module SupportDesk
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
# Unread messages across every support conversation this requester has,
|
|
96
|
-
# counted against the chats read horizon — the number for a nav badge
|
|
96
|
+
# counted against the chats read horizon — the number for a nav badge
|
|
97
|
+
# and the launcher's. Counted through the cases they can open
|
|
98
|
+
# (Ticket.conversations_of: their seat still there, the same scope the
|
|
99
|
+
# case page and chats' lookup use), so the badge never counts a case that
|
|
100
|
+
# would 404.
|
|
97
101
|
def unread_support_count
|
|
98
|
-
conversation_ids =
|
|
102
|
+
conversation_ids = SupportDesk::Ticket.conversations_of(self).reorder(nil).distinct.pluck(:id)
|
|
99
103
|
return 0 if conversation_ids.empty?
|
|
100
104
|
|
|
101
105
|
Chats::Conversation.unread_counts_for(self, conversation_ids).values.sum
|
data/lib/support_desk/version.rb
CHANGED
data/lib/support_desk.rb
CHANGED
|
@@ -330,9 +330,11 @@ module SupportDesk
|
|
|
330
330
|
|
|
331
331
|
# --- The chats seam -------------------------------------------------------------
|
|
332
332
|
|
|
333
|
-
# Subscribe the gem's own `:message_created
|
|
333
|
+
# Subscribe the gem's own chats listeners: `:message_created`, which is what
|
|
334
334
|
# keeps `awaiting`, the SLA clocks and reopen-on-reply true without
|
|
335
|
-
# anybody remembering to call anything
|
|
335
|
+
# anybody remembering to call anything, and `:unread_changed`, which
|
|
336
|
+
# keeps the launcher's badge live.
|
|
337
|
+
|
|
336
338
|
#
|
|
337
339
|
# Safe to call as often as you like: chats replaces a subscriber
|
|
338
340
|
# registered under the same `key:` rather than stacking another one.
|
|
@@ -346,6 +348,11 @@ module SupportDesk
|
|
|
346
348
|
# the Ticket class is a new object after each reload.
|
|
347
349
|
SupportDesk::Ticket.for_conversation(message.conversation)&.register!(message)
|
|
348
350
|
end
|
|
351
|
+
# The launcher's badge (chats >= 0.7.0): wherever an unread count in a
|
|
352
|
+
# case's conversation may have moved, the case's own notices included.
|
|
353
|
+
Chats.on(:unread_changed, key: :support_desk_launcher) do |messager:, conversation:|
|
|
354
|
+
SupportDesk::Launcher.unread_changed(messager, conversation)
|
|
355
|
+
end
|
|
349
356
|
self
|
|
350
357
|
end
|
|
351
358
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: support_desk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -56,7 +56,7 @@ dependencies:
|
|
|
56
56
|
requirements:
|
|
57
57
|
- - ">="
|
|
58
58
|
- !ruby/object:Gem::Version
|
|
59
|
-
version: 0.
|
|
59
|
+
version: 0.7.1
|
|
60
60
|
- - "<"
|
|
61
61
|
- !ruby/object:Gem::Version
|
|
62
62
|
version: '1.0'
|
|
@@ -66,7 +66,7 @@ dependencies:
|
|
|
66
66
|
requirements:
|
|
67
67
|
- - ">="
|
|
68
68
|
- !ruby/object:Gem::Version
|
|
69
|
-
version: 0.
|
|
69
|
+
version: 0.7.1
|
|
70
70
|
- - "<"
|
|
71
71
|
- !ruby/object:Gem::Version
|
|
72
72
|
version: '1.0'
|
|
@@ -147,6 +147,7 @@ files:
|
|
|
147
147
|
- app/helpers/support_desk/thread_helper.rb
|
|
148
148
|
- app/views/chats/slots/_inbox_top.html.erb
|
|
149
149
|
- app/views/chats/slots/_locked_composer.html.erb
|
|
150
|
+
- app/views/layouts/support_desk/panel.html.erb
|
|
150
151
|
- app/views/support_desk/console/tickets/_actions.html.erb
|
|
151
152
|
- app/views/support_desk/console/tickets/_assignment.html.erb
|
|
152
153
|
- app/views/support_desk/console/tickets/_composer.html.erb
|
|
@@ -162,9 +163,18 @@ files:
|
|
|
162
163
|
- app/views/support_desk/console/tickets/index.html.erb
|
|
163
164
|
- app/views/support_desk/console/tickets/new.html.erb
|
|
164
165
|
- app/views/support_desk/console/tickets/show.html.erb
|
|
166
|
+
- app/views/support_desk/launcher/_badge.html.erb
|
|
167
|
+
- app/views/support_desk/launcher/_icon.html.erb
|
|
168
|
+
- app/views/support_desk/launcher/_launcher.html.erb
|
|
169
|
+
- app/views/support_desk/launcher/_signed_out.html.erb
|
|
170
|
+
- app/views/support_desk/panel/case.html.erb
|
|
171
|
+
- app/views/support_desk/panel/home.html.erb
|
|
172
|
+
- app/views/support_desk/panel/new.html.erb
|
|
173
|
+
- app/views/support_desk/panel/rate_limited.html.erb
|
|
165
174
|
- app/views/support_desk/tickets/_context_card.html.erb
|
|
166
175
|
- app/views/support_desk/tickets/_door.html.erb
|
|
167
176
|
- app/views/support_desk/tickets/_human_door.html.erb
|
|
177
|
+
- app/views/support_desk/tickets/_limit_wall.html.erb
|
|
168
178
|
- app/views/support_desk/tickets/_pick_thing.html.erb
|
|
169
179
|
- app/views/support_desk/tickets/_pick_topic.html.erb
|
|
170
180
|
- app/views/support_desk/tickets/_ticket_row.html.erb
|
|
@@ -213,6 +223,7 @@ files:
|
|
|
213
223
|
- lib/support_desk/engine.rb
|
|
214
224
|
- lib/support_desk/errors.rb
|
|
215
225
|
- lib/support_desk/events.rb
|
|
226
|
+
- lib/support_desk/launcher.rb
|
|
216
227
|
- lib/support_desk/macros.rb
|
|
217
228
|
- lib/support_desk/message_signatures.rb
|
|
218
229
|
- lib/support_desk/models/application_record.rb
|