support_desk 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -0
  3. data/README.md +163 -8
  4. data/app/assets/stylesheets/support_desk.css +77 -0
  5. data/app/controllers/support_desk/application_controller.rb +21 -2
  6. data/app/controllers/support_desk/tickets_controller.rb +98 -9
  7. data/app/helpers/support_desk/engine_helper.rb +75 -10
  8. data/app/helpers/support_desk/thread_helper.rb +29 -0
  9. data/app/views/layouts/support_desk/panel.html.erb +6 -0
  10. data/app/views/support_desk/launcher/_badge.html.erb +6 -0
  11. data/app/views/support_desk/launcher/_icon.html.erb +6 -0
  12. data/app/views/support_desk/launcher/_launcher.html.erb +40 -0
  13. data/app/views/support_desk/launcher/_signed_out.html.erb +25 -0
  14. data/app/views/support_desk/panel/case.html.erb +36 -0
  15. data/app/views/support_desk/panel/home.html.erb +36 -0
  16. data/app/views/support_desk/panel/new.html.erb +8 -0
  17. data/app/views/support_desk/panel/rate_limited.html.erb +5 -0
  18. data/app/views/support_desk/tickets/_limit_wall.html.erb +33 -0
  19. data/app/views/support_desk/tickets/_pick_thing.html.erb +1 -1
  20. data/app/views/support_desk/tickets/_pick_topic.html.erb +1 -1
  21. data/app/views/support_desk/tickets/_wizard_header.html.erb +1 -1
  22. data/app/views/support_desk/tickets/_write.html.erb +4 -3
  23. data/app/views/support_desk/tickets/rate_limited.html.erb +1 -31
  24. data/app/views/support_desk/tickets/show.html.erb +8 -0
  25. data/config/locales/support_desk.en.yml +38 -0
  26. data/config/locales/support_desk.es.yml +38 -0
  27. data/config/routes.rb +4 -0
  28. data/lib/generators/support_desk/templates/initializer.rb +21 -1
  29. data/lib/generators/support_desk/views_generator.rb +9 -3
  30. data/lib/support_desk/brief.rb +1 -1
  31. data/lib/support_desk/configuration.rb +100 -19
  32. data/lib/support_desk/conversation_access.rb +42 -0
  33. data/lib/support_desk/engine.rb +7 -0
  34. data/lib/support_desk/launcher.rb +64 -0
  35. data/lib/support_desk/models/concerns/requester.rb +6 -2
  36. data/lib/support_desk/models/ticket.rb +55 -1
  37. data/lib/support_desk/version.rb +1 -1
  38. data/lib/support_desk/wizard.rb +1 -5
  39. data/lib/support_desk.rb +41 -6
  40. metadata +28 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66ba1a32f86acca1b4068bbca4091fcb16c4e6840a9d57ae5dc44bea25572ea2
4
- data.tar.gz: 2433e492636e42eeffde1652a2c7d21915267c6f0a9180bea8bb010d902cdfad
3
+ metadata.gz: f20ef59f51139224d4dc822bc4d96eced0d5517e6b308399f707d43eef11d229
4
+ data.tar.gz: fde3321433991bb1c7aa89a944e57a5fa4cd7cf19da1ba3444f8a81289b962ab
5
5
  SHA512:
6
- metadata.gz: ac1246120f4c2aa2763f81866006f054e0dfd14901d42120ddc25d754b08dad350b1627552faaa9e4fd62d37a50d785eeae9abe6dab1c5dc9adbb9f9bb44acd1
7
- data.tar.gz: 66fca7c4ddad8c9b41358448ec24beda2a3dcd9bade8bd373fe7433a565700d500c212f863c1755048faa7dd803c79c1fa92d3fb0b029a072322d1eff6672994
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), redirecting to its thread |
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
+ ![The launcher beside the messaging dock's bar](https://raw.githubusercontent.com/rameerez/support_desk/main/screenshots/launcher/bar.png)
527
+
528
+ | Their cases | A case, in the panel | A new one |
529
+ | --- | --- | --- |
530
+ | ![The panel: the way to a new case with its promise, and the open cases](https://raw.githubusercontent.com/rameerez/support_desk/main/screenshots/launcher/panel.png) | ![A case's thread in the panel, an answer signed by the agent](https://raw.githubusercontent.com/rameerez/support_desk/main/screenshots/launcher/case.png) | ![The wizard's last step in the panel, the promise under the form](https://raw.githubusercontent.com/rameerez/support_desk/main/screenshots/launcher/new.png) |
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/**` and the two rows this engine
486
- contributes to chats' screens (`app/views/chats/slots/**`) into your app,
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, so the Gemfile points at a sibling checkout (`../chats`) whenever this gem needs a `chats` that is not on rubygems yet. It currently requires `chats` ~> 0.3 (the `verified:` messager option).
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
- # Where a case is read and answered: its chats conversation, never a
85
- # second thread UI of our own.
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