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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/README.md +81 -4
  4. data/app/assets/stylesheets/support_desk.css +77 -0
  5. data/app/controllers/support_desk/application_controller.rb +18 -2
  6. data/app/controllers/support_desk/tickets_controller.rb +35 -11
  7. data/app/helpers/support_desk/engine_helper.rb +69 -4
  8. data/app/views/layouts/support_desk/panel.html.erb +6 -0
  9. data/app/views/support_desk/launcher/_badge.html.erb +6 -0
  10. data/app/views/support_desk/launcher/_icon.html.erb +6 -0
  11. data/app/views/support_desk/launcher/_launcher.html.erb +40 -0
  12. data/app/views/support_desk/launcher/_signed_out.html.erb +25 -0
  13. data/app/views/support_desk/panel/case.html.erb +36 -0
  14. data/app/views/support_desk/panel/home.html.erb +36 -0
  15. data/app/views/support_desk/panel/new.html.erb +8 -0
  16. data/app/views/support_desk/panel/rate_limited.html.erb +5 -0
  17. data/app/views/support_desk/tickets/_limit_wall.html.erb +33 -0
  18. data/app/views/support_desk/tickets/_pick_thing.html.erb +1 -1
  19. data/app/views/support_desk/tickets/_pick_topic.html.erb +1 -1
  20. data/app/views/support_desk/tickets/_wizard_header.html.erb +1 -1
  21. data/app/views/support_desk/tickets/_write.html.erb +4 -3
  22. data/app/views/support_desk/tickets/rate_limited.html.erb +1 -31
  23. data/config/locales/support_desk.en.yml +10 -0
  24. data/config/locales/support_desk.es.yml +10 -0
  25. data/config/routes.rb +4 -0
  26. data/lib/generators/support_desk/views_generator.rb +9 -3
  27. data/lib/support_desk/launcher.rb +64 -0
  28. data/lib/support_desk/models/concerns/requester.rb +6 -2
  29. data/lib/support_desk/version.rb +1 -1
  30. data/lib/support_desk.rb +9 -2
  31. metadata +15 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: afefc82399ac7abf6a331cc44fceb5fd160694fea8c83025af5e5888399a18d2
4
- data.tar.gz: c209e7f207e89de6e4e0b48ecdd8b2dfd4e7011b352cf2eaf52c82dd9b13260f
3
+ metadata.gz: f20ef59f51139224d4dc822bc4d96eced0d5517e6b308399f707d43eef11d229
4
+ data.tar.gz: fde3321433991bb1c7aa89a944e57a5fa4cd7cf19da1ba3444f8a81289b962ab
5
5
  SHA512:
6
- metadata.gz: 773a60b0710143ef412597a9789b2c37e5a41d99cbb965e5908d6035c11dcd5d97b0353c4f0777e5fd1e7644414ad01afa7c19379402a83e3cb9ef81353d0896
7
- data.tar.gz: 120b9347b36642f5248ba4c6e95b1898886f666913729cedc28d02b0177e3a97c5136e12ab8052501921a2b812488d55359ed326ac361dd5cb9c5af76b1ff55f
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
+ ![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
+
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/**` and the two rows this engine
536
- 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,
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.5.1, < 1.0`. Until that is on rubygems, the bundle takes the reviewed chats package from `vendor/cache` (provenance and checksum in `vendor/cache/README.md`), so `bundle exec rake ci` needs no network and no sibling checkout.
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
- render :new
42
+ render_wizard
43
+ end
44
+
45
+ # The launcher's badge, as the Turbo Stream its own stream would have
46
+ # sent: chats' tray asks for it when that stream may have missed one.
47
+ def badge
48
+ render turbo_stream: turbo_stream.replace(SupportDesk::Launcher::BADGE_ID,
49
+ partial: SupportDesk::Launcher::BADGE_PARTIAL,
50
+ locals: { count: current_requester.unread_support_count })
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 render :new, status: :unprocessable_entity
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
- render :new, status: :unprocessable_entity
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
- render :new, status: :unprocessable_entity
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 redirect_to conversation_path_for(ticket) unless SupportDesk.config.support_thread_page? && ticket.conversation
93
+ return render_thread(ticket) if ticket.conversation && (support_panel? || SupportDesk.config.support_thread_page?)
82
94
 
83
- render_thread(ticket)
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
- @participant&.read!(at: [ Time.current, @messages.last&.created_at ].compact.max) unless prefetch_request?
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
- render :show
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
- render :rate_limited, status: :too_many_requests
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). Falls back to the ticket URL
181
- # (which redirects to the same place) for the one case that has no
182
- # conversation yet, so a link is never dead.
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,5 @@
1
+ <%# The wall in the launcher's panel: the same content as the page's, in a screen that scrolls
2
+ inside the panel. %>
3
+ <div class="support-desk-panel__screen support-desk-panel__wall">
4
+ <%= render "support_desk/tickets/limit_wall" %>
5
+ </div>
@@ -0,0 +1,33 @@
1
+ <%# The wall's content (rate_limited.html.erb on a page, support_desk/panel/rate_limited in the
2
+ launcher's panel): why, the cases they already have, and what they wrote. %>
3
+ <header class="chats-inbox__header">
4
+ <h1 class="chats-inbox__title"><%= t("support_desk.limits.#{@limit_reason}_title") %></h1>
5
+ </header>
6
+
7
+ <p class="support-desk-notice__title">
8
+ <%= t("support_desk.limits.#{@limit_reason}", count: @open_tickets.size) %>
9
+ </p>
10
+
11
+ <% if @open_tickets.any? %>
12
+ <ul class="chats-inbox__list">
13
+ <%= render partial: "support_desk/tickets/ticket_row",
14
+ collection: @open_tickets,
15
+ as: :ticket,
16
+ locals: { unread_counts: @unread_counts } %>
17
+ </ul>
18
+ <% end %>
19
+
20
+ <%# Whatever they had typed comes with them. The wall sends them into a
21
+ conversation they already have, and the words they just wrote are not
22
+ ours to throw away — kept on the page to copy rather than pushed
23
+ through a URL, where it would land in logs and browser history. %>
24
+ <% if params[:message].present? %>
25
+ <div class="support-desk-form">
26
+ <p class="support-desk-form__promise"><%= t("support_desk.limits.your_message") %></p>
27
+ <%= text_area_tag :message, params[:message], rows: 6, readonly: true,
28
+ class: "support-desk-form__input", id: "support_desk_kept_message" %>
29
+ </div>
30
+ <% end %>
31
+
32
+ <%= link_to t("support_desk.wizard.back_to_list"), support_desk_routes.root_path,
33
+ class: "support-desk-button" %>
@@ -14,7 +14,7 @@
14
14
  <% if open_ticket %>
15
15
  <%= link_to support_thread_path(open_ticket),
16
16
  class: "support-desk-choice support-desk-choice--taken",
17
- data: { turbo_frame: "_top" } do %>
17
+ data: { turbo_frame: support_desk_top_frame } do %>
18
18
  <span class="support-desk-choice__label"><%= record.support_label %></span>
19
19
  <span class="support-desk-choice__hint"><%= t("support_desk.wizard.already_open") %> ›</span>
20
20
  <% end %>
@@ -25,6 +25,6 @@
25
25
  <div class="chats-empty">
26
26
  <p class="chats-empty__title"><%= t("support_desk.wizard.no_topics") %></p>
27
27
  <%= link_to t("support_desk.wizard.back_to_list"), support_desk_routes.root_path,
28
- class: "support-desk-button", data: { turbo_frame: "_top" } %>
28
+ class: "support-desk-button", data: { turbo_frame: support_desk_top_frame } %>
29
29
  </div>
30
30
  <% end %>
@@ -10,7 +10,7 @@
10
10
  <%= link_to t("support_desk.wizard.back"),
11
11
  support_desk_routes.root_path,
12
12
  class: "support-desk-wizard__back",
13
- data: { turbo_frame: "_top" } %>
13
+ data: { turbo_frame: support_desk_top_frame } %>
14
14
  <% end %>
15
15
 
16
16
  <% if (ask = local_assigns[:ask] || wizard.ask) %>
@@ -3,7 +3,8 @@
3
3
 
4
4
  The form targets `_top`: it POSTs to /tickets and the answer is a
5
5
  redirect into the conversation, which must replace the page, not land
6
- inside the wizard frame. %>
6
+ inside the wizard frame. In the launcher's panel it targets the panel
7
+ (support_desk_top_frame), and the new case opens there. %>
7
8
  <%= render "support_desk/tickets/wizard_header", wizard: wizard, error: error,
8
9
  ask: t("support_desk.wizard.write") %>
9
10
 
@@ -14,7 +15,7 @@
14
15
  <div class="support-desk-notice">
15
16
  <p class="support-desk-notice__title"><%= t("support_desk.wizard.already_open") %></p>
16
17
  <%= link_to t("support_desk.doors.existing"), support_thread_path(existing),
17
- class: "support-desk-button", data: { turbo_frame: "_top" } %>
18
+ class: "support-desk-button", data: { turbo_frame: support_desk_top_frame } %>
18
19
  </div>
19
20
  <% else %>
20
21
  <%= render "support_desk/tickets/context_card", subject: wizard.subject if wizard.subject %>
@@ -23,7 +24,7 @@
23
24
  multipart: true,
24
25
  method: :post,
25
26
  class: "support-desk-form",
26
- data: { turbo_frame: "_top" } do %>
27
+ data: { turbo_frame: support_desk_top_frame } do %>
27
28
  <% wizard.state_params.each do |name, value| %>
28
29
  <%= hidden_field_tag name, value %>
29
30
  <% end %>
@@ -6,35 +6,5 @@
6
6
  <%= support_desk_styles %>
7
7
 
8
8
  <div class="chats chats-inbox support-desk">
9
- <header class="chats-inbox__header">
10
- <h1 class="chats-inbox__title"><%= t("support_desk.limits.#{@limit_reason}_title") %></h1>
11
- </header>
12
-
13
- <p class="support-desk-notice__title">
14
- <%= t("support_desk.limits.#{@limit_reason}", count: @open_tickets.size) %>
15
- </p>
16
-
17
- <% if @open_tickets.any? %>
18
- <ul class="chats-inbox__list">
19
- <%= render partial: "support_desk/tickets/ticket_row",
20
- collection: @open_tickets,
21
- as: :ticket,
22
- locals: { unread_counts: @unread_counts } %>
23
- </ul>
24
- <% end %>
25
-
26
- <%# Whatever they had typed comes with them. The wall sends them into a
27
- conversation they already have, and the words they just wrote are not
28
- ours to throw away — kept on the page to copy rather than pushed
29
- through a URL, where it would land in logs and browser history. %>
30
- <% if params[:message].present? %>
31
- <div class="support-desk-form">
32
- <p class="support-desk-form__promise"><%= t("support_desk.limits.your_message") %></p>
33
- <%= text_area_tag :message, params[:message], rows: 6, readonly: true,
34
- class: "support-desk-form__input", id: "support_desk_kept_message" %>
35
- </div>
36
- <% end %>
37
-
38
- <%= link_to t("support_desk.wizard.back_to_list"), support_desk_routes.root_path,
39
- class: "support-desk-button" %>
9
+ <%= render "support_desk/tickets/limit_wall" %>
40
10
  </div>
@@ -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 = support_tickets.where.not(conversation_id: nil).distinct.pluck(:conversation_id)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
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` listener, which is what
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.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-18 00:00:00.000000000 Z
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.5.1
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.5.1
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