spree_cm_commissioner 2.12.9 → 2.13.1

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/spree/admin/orders/customer_details_controller_decorator.rb +6 -4
  4. data/app/controllers/spree/admin/system/waiting_room_sessions_controller.rb +111 -0
  5. data/app/controllers/spree/api/v2/platform/resource_controller_decorator.rb +48 -0
  6. data/app/helpers/spree/admin/system/waiting_room_sessions_helper.rb +50 -0
  7. data/app/models/spree_cm_commissioner/line_item_decorator.rb +1 -1
  8. data/app/models/spree_cm_commissioner/pricing_actions/create_route_adjustments.rb +2 -2
  9. data/app/models/spree_cm_commissioner/voting_contestant.rb +1 -1
  10. data/app/overrides/spree/admin/orders/_order_actions/actions.html.erb.deface +4 -4
  11. data/app/overrides/spree/admin/orders/_search/taxon_search_field.html.erb.deface +8 -0
  12. data/app/overrides/spree/admin/orders/customer_details/edit/reassign_customer_search.html.erb.deface +4 -5
  13. data/app/overrides/spree/admin/taxons/_form/preview_checkbox.html.erb.deface +4 -0
  14. data/app/overrides/spree/admin/taxons/_form/vendor.html.erb.deface +10 -2
  15. data/app/overrides/spree/admin/users/_tabs/tabs.html.erb.deface +1 -1
  16. data/app/services/spree_cm_commissioner/waiting_room/admission/call_guests.rb +6 -3
  17. data/app/services/spree_cm_commissioner/waiting_room/guests/find.rb +37 -0
  18. data/app/services/spree_cm_commissioner/waiting_room/guests/list.rb +59 -0
  19. data/app/services/spree_cm_commissioner/waiting_room_lobby_metadata_fetcher.rb +13 -11
  20. data/app/views/spree/admin/shared/_taxon_tabs.html.erb +1 -1
  21. data/app/views/spree/admin/system/waiting_room_sessions/_guest_raw.html.erb +70 -0
  22. data/app/views/spree/admin/system/waiting_room_sessions/_guests_pagination.html.erb +36 -0
  23. data/app/views/spree/admin/system/waiting_room_sessions/_guests_table.html.erb +86 -0
  24. data/app/views/spree/admin/system/waiting_room_sessions/_tabs.html.erb +10 -0
  25. data/app/views/spree/admin/system/waiting_room_sessions/guests.html.erb +141 -0
  26. data/app/views/spree/admin/system/waiting_room_sessions/index.html.erb +1 -2
  27. data/app/views/spree/admin/user_events/_new_user.html.erb +2 -1
  28. data/app/views/spree/admin/user_events/_users.html.erb +1 -1
  29. data/config/locales/en.yml +5 -2
  30. data/config/locales/km.yml +5 -2
  31. data/config/routes.rb +3 -0
  32. data/lib/spree_cm_commissioner/version.rb +1 -1
  33. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 574f5215f2246c2cc433d99ab73113be56a4c82b509758548c1d0951c894caef
4
- data.tar.gz: 0fec7f50315d67e24a41779dafd38467c5c7648180c4b54b3ec1d05f1cc7f1a6
3
+ metadata.gz: 709925b9130644b3e39ef1bb01415692019bc72643176593877c42f1a710ff0f
4
+ data.tar.gz: a336bae1ac6555051a4d6c718d8cce7bb1fcbed67b8bea0a2accb5b96cb8ae1b
5
5
  SHA512:
6
- metadata.gz: 700f502becf735bfb55744ef153508ecfdb14f0f89f6a9a0b2063739c9739a0cbcfaab2d6b7f992858b0c601e212330a90d927a5c8c9b07621b96ef41071b35a
7
- data.tar.gz: 31e2cc185129609a659dc3257a343d62ff9dc5b491ab8120a8e4b3daf2b9f77f8d3aca445f7a128b6e2f30bfad4504a5d2dfb0d2a6980ddcb9a33f8eb41551cf
6
+ metadata.gz: '0769de948ff5436227d88a348a051a7992956deca52f0a165c4cfa885750b9d672c9192482070d922b52921ded1b080a08c004eb6d33206ae67638ee4eec3a61'
7
+ data.tar.gz: '09affaee452bf8a04579f43999a2beb71282b0d369109085fa59b63a8851d0c6fb80ad67b9ffc3930542ab6fe9e6b9c500c17b243380b5d33440225e30d8cc02'
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.12.9)
37
+ spree_cm_commissioner (2.13.1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -15,11 +15,13 @@ module Spree
15
15
  def assign_customer
16
16
  user = tenant_user_scope.find_by(id: params[:user_id])
17
17
 
18
- if user
19
- @order.associate_user!(user)
20
- flash[:success] = Spree.t('admin.orders.customer_details.customer_assigned', email: user.email)
21
- else
18
+ if user.nil?
22
19
  flash[:error] = Spree.t(:user_not_found)
20
+ elsif user.id == @order.user_id
21
+ flash[:notice] = Spree.t('admin.orders.customer_details.user_already_assigned', email: user.email)
22
+ else
23
+ @order.associate_user!(user)
24
+ flash[:success] = Spree.t('admin.orders.customer_details.user_assigned', email: user.email)
23
25
  end
24
26
 
25
27
  redirect_to spree.edit_admin_order_customer_path(@order)
@@ -2,6 +2,9 @@ module Spree
2
2
  module Admin
3
3
  module System
4
4
  class WaitingRoomSessionsController < Spree::Admin::BaseController
5
+ GUESTS_PER_PAGE_OPTIONS = [25, 50, 100, 200, 500, 1000].freeze
6
+ GUESTS_DEFAULT_PER_PAGE = 50
7
+
5
8
  def index
6
9
  @filter = params[:filter] if params[:filter] == 'load_test'
7
10
 
@@ -21,6 +24,114 @@ module Spree
21
24
 
22
25
  redirect_back fallback_location: admin_system_waiting_room_sessions_path
23
26
  end
27
+
28
+ # Only resolves date/doc_id for the form + the frame's src URL. The actual Firestore
29
+ # read happens in #guests_status, once the browser loads that src -- never here, or every
30
+ # full-page load would pay for a read whose result is thrown away unrendered.
31
+ def guests
32
+ @date = params[:date].presence || Time.zone.today.strftime('%Y-%m-%d')
33
+ @doc_id = params[:doc_id].presence
34
+ @per_page = guests_per_page
35
+ @in_queue_only = guests_in_queue_only?
36
+ end
37
+
38
+ # GET .../waiting_room_sessions/guests_status -- the poll target, mirroring
39
+ # SeatLockable#seat_lock_status: a standalone endpoint the async-operation-status
40
+ # Stimulus controller reloads every 10s, kept separate from `guests` so the initial
41
+ # full-page load never has to guess whether it's being polled.
42
+ def guests_status
43
+ date = params[:date].presence || Time.zone.today.strftime('%Y-%m-%d')
44
+ doc_id = params[:doc_id].presence
45
+ in_queue_only = guests_in_queue_only?
46
+ error = nil
47
+
48
+ guests =
49
+ if doc_id
50
+ # A direct doc-id lookup always finds the doc regardless of status -- an admin
51
+ # searching a specific id almost certainly wants to know its state (including
52
+ # Admitted/Left), not have it hidden by the same filter that shapes the plain list.
53
+ result = SpreeCmCommissioner::WaitingRoom::Guests::Find.call(date: date, doc_id: doc_id)
54
+ error = result.error.to_s unless result.success?
55
+ [result.value&.[](:guest)].compact
56
+ else
57
+ # Cached for the same span as the client's poll interval so N concurrently open admin
58
+ # tabs (each polling every 10s) collapse into one Firestore read per date per 10s,
59
+ # instead of one per open tab. Keyed by filter too -- "in queue" and "all" are
60
+ # different Firestore queries, not a client-side slice of the same result.
61
+ #
62
+ # A failed fetch (e.g. a missing Firestore composite index for in_queue_only's
63
+ # where+order combination) is never cached: caching `[]` would make the next 10s
64
+ # poll -- and the one after that -- believe the queue is empty instead of retrying.
65
+ cache_key = "admin/waiting_room_guests/#{date}/#{in_queue_only}"
66
+ docs = Rails.cache.read(cache_key)
67
+
68
+ if docs.nil?
69
+ result = SpreeCmCommissioner::WaitingRoom::Guests::List.call(date: date, in_queue_only: in_queue_only)
70
+ if result.success?
71
+ docs = result.value[:guests]
72
+ Rails.cache.write(cache_key, docs, expires_in: 10.seconds)
73
+ else
74
+ error = result.error.to_s
75
+ docs = []
76
+ end
77
+ end
78
+
79
+ Kaminari.paginate_array(docs).page(params[:page]).per(guests_per_page)
80
+ end
81
+
82
+ render partial: 'guests_table',
83
+ locals: { guests: guests, doc_id_search: doc_id.present?, date: date, frontier_position: frontier_position, error: error },
84
+ layout: false
85
+ end
86
+
87
+ # GET .../waiting_room_sessions/guest -- the click target for a row's "Raw" button. Not
88
+ # part of the cached list/poll cycle: a lone extra Firestore read only when an admin
89
+ # actually wants the full document, instead of shipping every field of every row (up to
90
+ # MAX_DOCS of them) in the guests_status response whether anyone looks at it or not.
91
+ def guest
92
+ # This is the one place in the tab meant to show the truth of this instant, not a
93
+ # 10s-old cached snapshot -- an admin opens it specifically to check a guest's current
94
+ # state. expires_now stops the browser from ever serving a previous click's response
95
+ # back for the same doc_id (e.g. reopening the same row after its state changed).
96
+ expires_now
97
+
98
+ date = params[:date].presence || Time.zone.today.strftime('%Y-%m-%d')
99
+ doc_id = params[:doc_id]
100
+ result = SpreeCmCommissioner::WaitingRoom::Guests::Find.call(date: date, doc_id: doc_id)
101
+ error = result.error.to_s unless result.success?
102
+
103
+ render partial: 'guest_raw', locals: { guest: result.value&.[](:guest), error: error }, layout: false
104
+ end
105
+
106
+ private
107
+
108
+ # Same shape as Spree's own admin index actions (e.g. Admin::OrdersController#index:
109
+ # `.per(params[:per_page] || Spree::Backend::Config[:admin_orders_per_page])`) -- an
110
+ # unlisted value still works (typed straight into the URL, same as any stock Spree index),
111
+ # GUESTS_PER_PAGE_OPTIONS only bounds what the dropdown itself offers.
112
+ def guests_per_page
113
+ requested = params[:per_page].to_i
114
+ requested.positive? ? requested : GUESTS_DEFAULT_PER_PAGE
115
+ end
116
+
117
+ # Defaults on: hiding Admitted/Left is the useful everyday view, and it's also what keeps
118
+ # the MAX_DOCS cap from being spent on already-resolved history on a day with heavy
119
+ # traffic (see WaitingRoom::Guests::List). ?status=all is the explicit escape hatch for
120
+ # historical/debugging lookups that need to see resolved guests too.
121
+ def guests_in_queue_only?
122
+ params[:status] != 'all'
123
+ end
124
+
125
+ # Same 10s window as the guest list itself: this is one extra doc read per render, and the
126
+ # "people ahead" figure it feeds only changes as often as Positions::Stamp ticks (10s),
127
+ # so caching it any shorter would just be re-fetching a value that hasn't moved.
128
+ def frontier_position
129
+ Rails.cache.fetch('admin/waiting_room_frontier_position', expires_in: 10.seconds) do
130
+ fetcher = SpreeCmCommissioner::WaitingRoomLobbyMetadataFetcher.new
131
+ fetcher.load_document_data
132
+ fetcher.frontier_position
133
+ end
134
+ end
24
135
  end
25
136
  end
26
137
  end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Platform
5
+ # Single reusable entry point for tenant-scoping any Platform API resource: pass
6
+ # `?tenant_id=X` on the request and the collection is narrowed to that tenant, without each
7
+ # controller having to wire up its own filter/param handling.
8
+ #
9
+ # Tri-state on the param itself:
10
+ # - absent: no scoping applied, collection flows through unrestricted (existing callers,
11
+ # eg. the products/users pickers in checkouts and private sales, are unaffected).
12
+ # - "0": scope to records with no tenant (tenant_id IS NULL) - most vendors today aren't
13
+ # attached to a tenant, so this is the common case for tenant-scoped search.
14
+ # - any other value: scope to that exact tenant_id.
15
+ #
16
+ # Default assumes the model owns a `tenant_id` column directly (true for Spree::User,
17
+ # Spree::Vendor, SpreeCmCommissioner::HomepageSection - see their `multi_tenant :tenant`
18
+ # declarations). A model related to tenant only indirectly should override
19
+ # `scope_by_tenant` in its own controller decorator.
20
+ module ResourceControllerDecorator
21
+ def scope
22
+ base_scope = super
23
+
24
+ # Keep the existing behavior when no tenant_id param is passed,
25
+ # so that existing controllers are unaffected.
26
+ return base_scope if params[:tenant_id].nil?
27
+
28
+ scope_by_tenant(base_scope, params[:tenant_id])
29
+ end
30
+
31
+ private
32
+
33
+ def scope_by_tenant(scope, tenant_id)
34
+ return scope unless scope.klass.column_names.include?('tenant_id')
35
+
36
+ if tenant_id.to_s == '0'
37
+ scope.where(tenant_id: nil)
38
+ else
39
+ scope.where(tenant_id: tenant_id)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ Spree::Api::V2::Platform::ResourceController.prepend(Spree::Api::V2::Platform::ResourceControllerDecorator)
@@ -0,0 +1,50 @@
1
+ module Spree
2
+ module Admin
3
+ module System
4
+ # Shared badge/derived-value logic for the Guests tab (_guests_table.html.erb's Queue and
5
+ # Heartbeat columns, _guest_raw.html.erb's modal) -- kept in one place so both never drift
6
+ # into disagreeing about what "Admitted" or "Stale ping" means for the same document.
7
+ module WaitingRoomSessionsHelper
8
+ # Mutually exclusive lifecycle stage, in the order a guest actually moves through them.
9
+ # Left is checked first because Heartbeat::Sweep can mark left_at on a doc that was still
10
+ # mid-call (call_dispatched_at set but never granted) -- left always wins once set.
11
+ def guest_queue_status(guest)
12
+ if guest[:left_at]
13
+ ['Left queue', 'badge-secondary']
14
+ elsif guest[:allow_to_enter_room_at]
15
+ %w[Admitted badge-success]
16
+ elsif guest[:call_dispatched_at]
17
+ ['Being called', 'badge-info']
18
+ else
19
+ %w[Waiting badge-primary]
20
+ end
21
+ end
22
+
23
+ # Only meaningful before a guest is resolved: once left_at or allow_to_enter_room_at is
24
+ # set, Heartbeat::Sweep's query (allow_to_enter_room_at == nil, left_at == nil) no longer
25
+ # looks at this doc's last_ping_at at all, so showing a ping state past that point would
26
+ # describe a check the server itself has stopped making.
27
+ def guest_heartbeat_status(guest)
28
+ return nil if guest[:left_at] || guest[:allow_to_enter_room_at]
29
+
30
+ last_ping_at = guest[:last_ping_at]
31
+ return ['Never pinged', 'badge-secondary'] if last_ping_at.nil?
32
+
33
+ stale = last_ping_at < SpreeCmCommissioner::WaitingRoom::Heartbeat::Sweep::HEARTBEAT_TTL_SECONDS.seconds.ago
34
+ stale ? ['Stale ping', 'badge-warning'] : ['Active ping', 'badge-success']
35
+ end
36
+
37
+ # Same formula the app itself shows the guest (see waiting_room_provider.dart's
38
+ # _peopleAheadOfYou): position minus the lobby's live frontier_position, floored at 1 so a
39
+ # guest at the very front never reads "0 ahead" (session presence, not this count, is what
40
+ # actually admits them). Nil until Positions::Stamp assigns this guest a position at all.
41
+ def guest_people_ahead(guest, frontier_position)
42
+ position = guest[:position]
43
+ return nil if position.nil?
44
+
45
+ [position - frontier_position.to_i, 1].max
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -37,7 +37,7 @@ module SpreeCmCommissioner
37
37
  base.validate :ensure_all_guests_have_blocks, if: :required_seat_selection?
38
38
 
39
39
  base.whitelisted_ransackable_associations |= %w[guests order]
40
- base.whitelisted_ransackable_attributes |= %w[number to_date from_date vendor_id]
40
+ base.whitelisted_ransackable_attributes |= %w[number to_date from_date vendor_id event_id]
41
41
 
42
42
  base.delegate :delivery_required?, :high_demand,
43
43
  to: :variant
@@ -34,8 +34,8 @@ module SpreeCmCommissioner
34
34
  seen = {}
35
35
 
36
36
  line_item_contexts.each_with_object([]) do |line_item_context, result|
37
- line_item_context.guest_contexts.select { |gc| guest_context_eligible?(gc, line_item_context) }.each_with_index do |guest_context, index|
38
- key = guest_context.saved_guest_id.present? ? [guest_context.saved_guest_id, index] : guest_context.id
37
+ line_item_context.guest_contexts.select { |gc| guest_context_eligible?(gc, line_item_context) }.each do |guest_context|
38
+ key = guest_context.saved_guest_id.presence || guest_context.id
39
39
  next if seen[key]
40
40
 
41
41
  seen[key] = true
@@ -34,7 +34,7 @@ module SpreeCmCommissioner
34
34
 
35
35
  # Republish the parent session's Firestore document whenever anything the show screen renders
36
36
  # changes, so a reveal reaches every device without a pull-to-refresh.
37
- FIRESTORE_PUBLISHED_COLUMNS = %w[visible vote_number position eliminated].freeze
37
+ FIRESTORE_PUBLISHED_COLUMNS = %w[visible vote_number position eliminated confirmed_rank].freeze
38
38
 
39
39
  after_commit :publish_session_to_firestore, on: %i[create update destroy]
40
40
 
@@ -13,7 +13,7 @@
13
13
  <% end %>
14
14
 
15
15
  <% if can?(:fire, order) && order.can_cancel? %>
16
- <% cancel_label = Spree.t(:cancel, scope: 'admin.order.events', default: Spree.t(:cancel)) %>
16
+ <% cancel_label = Spree.t(:cancel) %>
17
17
  <%= button_link_to cancel_label,
18
18
  '#',
19
19
  icon: 'cancel.svg',
@@ -24,7 +24,7 @@
24
24
  <div class="modal-dialog" role="document">
25
25
  <div class="modal-content">
26
26
  <div class="modal-header">
27
- <h5 class="modal-title mr-auto" id="cancelModalLabel-<%= order.number %>"><%= Spree.t(:order_sure_want_to, event: cancel_label) %></h5>
27
+ <h5 class="mr-auto modal-title" id="cancelModalLabel-<%= order.number %>"><%= Spree.t(:order_sure_want_to, event: cancel_label) %></h5>
28
28
  <button type="button" class="close" data-dismiss="modal" aria-label="<%= Spree.t(:close) %>">
29
29
  <span aria-hidden="true">&times;</span>
30
30
  </button>
@@ -32,12 +32,12 @@
32
32
 
33
33
  <%= form_with model: order, url: spree.cancel_admin_order_url(order), method: :put do |f| %>
34
34
  <div class="modal-body">
35
- <div class="form-group text-left text-start">
35
+ <div class="text-left form-group text-start">
36
36
  <%= f.label :internal_note, raw(Spree.t(:reason) + required_span_tag), class: 'd-block text-left text-start' %>
37
37
  <%= f.text_area :internal_note, rows: 3, class: 'form-control', placeholder: Spree.t(:enter_internal_note, default: 'Enter reason here...'), required: true %>
38
38
  </div>
39
39
  </div>
40
- <div class="modal-footer d-flex justify-end">
40
+ <div class="justify-end modal-footer d-flex">
41
41
  <button type="button" class="btn btn-primary" data-dismiss="modal"><%= Spree.t(:close) %></button>
42
42
  <%= f.submit Spree.t(:confirm), class: 'btn btn-danger' %>
43
43
  </div>
@@ -0,0 +1,8 @@
1
+ <!-- insert_before "[data-hook='admin_orders_index_search'] .row:nth-child(5) .col-12.col-lg-4:nth-child(3)" -->
2
+
3
+ <div class="col-12 col-lg-4">
4
+ <div class="form-group">
5
+ <%= label_tag :q_line_items_event_id_eq, "Event" %>
6
+ <%= f.select :line_items_event_id_eq, Spree::Taxon.events.pluck(:name, :id), { include_blank: true }, class: 'select2-clear js-filterable' %>
7
+ </div>
8
+ </div>
@@ -1,6 +1,5 @@
1
1
  <!-- replace "#select-customer" -->
2
2
 
3
- <% unless @order.completed? && @order.user.present? %>
4
3
  <%= turbo_frame_tag 'customer_reassign' do %>
5
4
  <div class="mb-3 card" data-hook>
6
5
  <div class="card-header">
@@ -28,14 +27,15 @@
28
27
 
29
28
  <% if @customer_search_query.present? %>
30
29
  <% if @customer_search_result %>
30
+ <% replacing_user = @order.user.present? %>
31
31
  <div class="flex-wrap gap-2 mt-2 mb-0 alert alert-success d-flex justify-content-between align-items-center">
32
32
  <span><%= Spree.t('admin.orders.customer_details.customer_found', email: @customer_search_result.email) %></span>
33
- <%= button_to Spree.t('admin.orders.customer_details.assign'),
33
+ <%= button_to replacing_user ? Spree.t('admin.orders.customer_details.replace_user') : Spree.t('admin.orders.customer_details.assign_user'),
34
34
  spree.assign_customer_admin_order_customer_path(@order),
35
35
  method: :patch,
36
36
  params: { user_id: @customer_search_result.id },
37
- class: 'btn btn-primary btn-sm',
38
- form: { data: { turbo_frame: '_top' } } %>
37
+ class: "btn btn-sm #{replacing_user ? 'btn-danger' : 'btn-primary'}",
38
+ form: { data: { turbo_frame: '_top', turbo_confirm: (Spree.t('admin.orders.customer_details.replace_user_confirm', email: @order.user.email) if replacing_user) } } %>
39
39
  </div>
40
40
  <% else %>
41
41
  <div class="mt-2 mb-0 alert alert-warning">
@@ -46,4 +46,3 @@
46
46
  </div>
47
47
  </div>
48
48
  <% end %>
49
- <% end %>
@@ -1,7 +1,11 @@
1
1
  <!-- insert_before "erb[loud]:contains('field_container :hide_from_nav')" -->
2
2
 
3
+ <% if @taxon.depth == 1 %>
4
+
3
5
  <%= f.field_container :preview, class: ['custom-control', 'custom-checkbox', 'my-4'] do %>
4
6
  <%= f.check_box :preview, class: 'custom-control-input' %>
5
7
  <%= f.label :preview, 'Preview (internal team only)', class: 'custom-control-label' %>
6
8
  <%= f.error_message_on :preview %>
7
9
  <% end %>
10
+
11
+ <% end %>
@@ -3,11 +3,19 @@
3
3
  <% if @taxon.depth == 1 %>
4
4
  <div>
5
5
  <%= f.field_container :vendor_id do %>
6
- <%= label_tag :vendor_id, Spree.t(:vendor) %>
6
+ <label class="d-flex">
7
+ <span class="mr-2"><%= Spree.t(:vendor) %></span>
8
+ <% if f.object.vendor_id.present? %>
9
+ <%= link_to_with_icon 'arrow-right-circle.svg', Spree.t(' '), spree.edit_admin_vendor_path(f.object.vendor_id), class: "d-flex align-items-center" do %>
10
+ <i class="bi bi-arrow-right-circle me-1"></i>
11
+ <% end %>
12
+ <% else %>
13
+ <span class="text-muted"><%= Spree.t(:no_vendor_assigned) %></span>
14
+ <% end %>
15
+ </label>
7
16
  <%= f.select :vendor_id, Spree::Vendor.order(:name).pluck(:name, :id),
8
17
  { include_blank: true }, { class: 'select2-clear js-filterable' } %>
9
18
  <%= f.error_message_on :vendor_id, class: 'error-message' %>
10
19
  <% end %>
11
20
  </div>
12
21
  <% end %>
13
-
@@ -45,7 +45,7 @@
45
45
  </li>
46
46
  <li class="nav-item">
47
47
  <%= link_to_with_icon 'calendar-event.svg',
48
- Spree.t(:user_events),
48
+ Spree.t(:crews),
49
49
  admin_user_events_path(user_id: @user.id),
50
50
  class: "nav-link #{'active' if current == :user_events }" %>
51
51
  </li>
@@ -128,9 +128,12 @@ module SpreeCmCommissioner
128
128
  previous_guests + eligible_guests_in(records_path, remaining_slots)
129
129
  end
130
130
 
131
- # This query requires a composite index (allow_to_enter_room_at ==, left_at ==,
132
- # call_dispatched_at ==, position !=, queued_at order); create it in Firebase beforehand, same
133
- # as the plain index #fetch_long_waiting_guests already needed.
131
+ # This query requires a composite index (allow_to_enter_room_at ==, call_dispatched_at ==,
132
+ # left_at ==, queued_at order, position !=); create it in Firebase beforehand, same as the
133
+ # plain index #fetch_long_waiting_guests already needed. The `!=` field goes LAST, after the
134
+ # orderBy field, not right after the equality fields the way a `<`/`>` range filter would --
135
+ # Firestore's own generated index link is the source of truth here, not the field order
136
+ # `!=` filters read like in the query itself.
134
137
  #
135
138
  # left_at == nil excludes a guest who already left the queue (leaveQueue()/markLeft(), same
136
139
  # write Positions::Stamp#lowest_unresolved_position_in and #sweep_abandoned both already
@@ -0,0 +1,37 @@
1
+ module SpreeCmCommissioner
2
+ module WaitingRoom
3
+ module Guests
4
+ # Single-doc lookup by id, for the admin "Guests" tab's search box. Deliberately a direct
5
+ # `get` rather than filtering List's result: a doc can exist outside List's queued_at-ordered
6
+ # MAX_DOCS window (or the request predates that cache), and a single-doc read costs the same
7
+ # whichever way it's found.
8
+ class Find
9
+ prepend ::Spree::ServiceModule::Base
10
+ include SpreeCmCommissioner::FirestoreConnection
11
+
12
+ def call(date:, doc_id:, firestore: nil)
13
+ @firestore = firestore if firestore.present?
14
+
15
+ guest = firestore_available? ? fetch(date, doc_id) : nil
16
+
17
+ success(guest: guest)
18
+ rescue StandardError => e
19
+ failure(nil, e.message)
20
+ end
21
+
22
+ private
23
+
24
+ def fetch(date, doc_id)
25
+ snapshot = firestore.col(records_path(date)).doc(doc_id).get
26
+ return nil unless snapshot.exists?
27
+
28
+ snapshot.data.merge(doc_id: snapshot.document_id)
29
+ end
30
+
31
+ def records_path(date)
32
+ "waiting_guests/#{date}/records"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,59 @@
1
+ module SpreeCmCommissioner
2
+ module WaitingRoom
3
+ module Guests
4
+ # Raw read of a waiting-guest partition for the admin "Guests" tab — the only place in the
5
+ # codebase that lists the actual `waiting_guests/{date}/records` documents rather than
6
+ # deriving lobby-level aggregates from them (contrast Heartbeat::Sweep/Positions::Stamp/
7
+ # Admission::CallGuests, which only ever query for docs matching specific eligibility
8
+ # criteria, never dump the partition itself).
9
+ class List
10
+ prepend ::Spree::ServiceModule::Base
11
+ include SpreeCmCommissioner::FirestoreConnection
12
+
13
+ # Same ceiling the admin UI paginates over; keeps one read bounded regardless of how many
14
+ # guests are actually queued.
15
+ MAX_DOCS = 5000
16
+
17
+ # in_queue_only: true filters at the Firestore query level (not after fetching) to the
18
+ # same "not yet resolved" criteria Admission::CallGuests uses, and defaults on — without
19
+ # it, a partition with more than MAX_DOCS total docs for the day fills the cap with the
20
+ # OLDEST docs by queued_at, which past a certain volume are entirely already-resolved
21
+ # (Admitted/Left) guests, silently hiding everyone still actually waiting behind them.
22
+ def call(date:, in_queue_only: true, firestore: nil)
23
+ @firestore = firestore if firestore.present?
24
+
25
+ guests = firestore_available? ? fetch(date, in_queue_only) : []
26
+
27
+ success(guests: guests)
28
+ rescue StandardError => e
29
+ failure(nil, e.message)
30
+ end
31
+
32
+ private
33
+
34
+ # Ordered by queued_at (arrival order), not `position` — a not-yet-stamped doc has
35
+ # `position: nil` and would sort inconsistently, hiding guests who just joined the queue.
36
+ #
37
+ # in_queue_only's composite index (allow_to_enter_room_at ==, left_at ==, queued_at order)
38
+ # is tracked in firebase/firestore.indexes.json — a different, narrower shape than the one
39
+ # CallGuests needs (which also filters call_dispatched_at), so it isn't covered by that one
40
+ # already existing. Still needs `firebase deploy --only firestore:indexes` run against each
41
+ # project (see firebase/README.md) before this filter works live: adding it to the tracked
42
+ # file doesn't deploy it by itself.
43
+ def fetch(date, in_queue_only)
44
+ query = firestore.col(records_path(date))
45
+ query = query.where('allow_to_enter_room_at', '==', nil).where('left_at', '==', nil) if in_queue_only
46
+
47
+ query.order('queued_at', 'asc')
48
+ .limit(MAX_DOCS)
49
+ .get
50
+ .map { |snapshot| snapshot.data.merge(doc_id: snapshot.document_id) }
51
+ end
52
+
53
+ def records_path(date)
54
+ "waiting_guests/#{date}/records"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,18 +1,22 @@
1
- require 'google/cloud/firestore'
2
-
3
1
  module SpreeCmCommissioner
4
2
  # Reads the lobby document (waiting_rooms/lobby) — the read-side counterpart to
5
3
  # WaitingRoom::PublishLobbyPath. Kept separate from WaitingRoomSystemMetadataFetcher,
6
4
  # which owns the unrelated metadata/system document.
7
5
  class WaitingRoomLobbyMetadataFetcher
6
+ include SpreeCmCommissioner::FirestoreConnection
7
+
8
8
  attr_reader :document_data
9
9
 
10
10
  def initialize(firestore: nil)
11
11
  @firestore = firestore if firestore.present?
12
12
  end
13
13
 
14
+ # Environments without the credential (dev, test, CI) get an empty document instead of a
15
+ # NoMethodError on a nil service_account -- every accessor below already treats a missing key
16
+ # in document_data as "unknown" (nil/false), so an empty hash here is a real, valid state, not
17
+ # a special case each of them has to know about.
14
18
  def load_document_data
15
- @document_data = document.get.data || {}
19
+ @document_data = firestore_available? ? (document.get.data || {}) : {}
16
20
  end
17
21
 
18
22
  # firebase metadata
@@ -32,16 +36,14 @@ module SpreeCmCommissioner
32
36
  document_data[:available_slots]
33
37
  end
34
38
 
35
- def document
36
- @document ||= firestore.col('waiting_rooms').doc('lobby')
37
- end
38
-
39
- def firestore
40
- @firestore ||= Google::Cloud::Firestore.new(project_id: service_account[:project_id], credentials: service_account)
39
+ # written by Positions::Stamp -- every position up to and including this one is resolved
40
+ # (granted or left). "N people ahead of you" for a stamped guest = their position minus this.
41
+ def frontier_position
42
+ document_data[:frontier_position]
41
43
  end
42
44
 
43
- def service_account
44
- @service_account ||= Rails.application.credentials.cloud_firestore_service_account
45
+ def document
46
+ @document ||= firestore.col('waiting_rooms').doc('lobby')
45
47
  end
46
48
  end
47
49
  end
@@ -33,7 +33,7 @@
33
33
  <%# user event only show for event %>
34
34
  <%= content_tag :li, class: 'nav-item' do %>
35
35
  <%= link_to_with_icon 'user.svg',
36
- Spree.t(:user_events),
36
+ Spree.t(:crews),
37
37
  admin_user_events_path(params: {taxon_id: @taxon.id, taxonomy_id: @taxonomy}),
38
38
  class: "nav-link #{'active' if current == :user_events}" %>
39
39
  <% end if can?(:manage, SpreeCmCommissioner::UserTaxon) && @taxon.event? && @taxon.depth == 1 %>
@@ -0,0 +1,70 @@
1
+ <%# Rendered only when a row's "Raw" button is clicked (see _guests_table.html.erb) -- the full
2
+ document, every field, is never present in the guests_status response until an admin actually
3
+ asks for one row's worth of it. Targets the ONE shared modal frame (guests.html.erb's
4
+ #guest_raw_modal_frame), not a per-doc id: only one modal is ever open at a time, so each click
5
+ just replaces whatever the frame last held. %>
6
+ <%= turbo_frame_tag 'guest_raw_modal_frame' do %>
7
+ <% if guest %>
8
+ <%# Same helper _guests_table.html.erb's Queue/Heartbeat columns use -- one definition of what
9
+ "Admitted" or "Stale ping" means, so the table and this modal never disagree about it. %>
10
+ <% queue_label, queue_badge = guest_queue_status(guest) %>
11
+ <% heartbeat = guest_heartbeat_status(guest) %>
12
+
13
+ <div class="mb-3 d-flex align-items-center flex-wrap">
14
+ <span class="mr-2 text-muted small text-uppercase" style="letter-spacing: .04em;">Queue</span>
15
+ <span class="mr-3 badge <%= queue_badge %>"><%= queue_label %></span>
16
+
17
+ <% if heartbeat %>
18
+ <% heartbeat_label, heartbeat_badge = heartbeat %>
19
+ <span class="mr-2 text-muted small text-uppercase" style="letter-spacing: .04em;">Heartbeat</span>
20
+ <span class="badge <%= heartbeat_badge %>"><%= heartbeat_label %></span>
21
+ <% end %>
22
+ </div>
23
+
24
+ <% session = guest[:session] %>
25
+ <% if session %>
26
+ <% session_expired_at = session[:expired_at] %>
27
+ <% session_state, session_badge =
28
+ if session_expired_at.nil?
29
+ ['Unknown', 'badge-secondary']
30
+ elsif session_expired_at > Time.zone.now
31
+ ['Active', 'badge-success']
32
+ else
33
+ ['Expired', 'badge-danger']
34
+ end %>
35
+
36
+ <div class="mb-3 p-3 border rounded">
37
+ <div class="mb-2 d-flex align-items-center flex-wrap">
38
+ <span class="mr-2 text-muted small text-uppercase" style="letter-spacing: .04em;">Session</span>
39
+ <span class="badge <%= session_badge %>"><%= session_state %></span>
40
+ </div>
41
+
42
+ <table class="mb-0 table table-sm table-borderless">
43
+ <tbody>
44
+ <tr>
45
+ <td class="text-muted" style="width: 120px;">ID</td>
46
+ <td><code><%= session[:id] || '—' %></code></td>
47
+ </tr>
48
+ <tr>
49
+ <td class="text-muted">Created</td>
50
+ <td><%= session[:created_at]&.strftime('%Y-%m-%d %H:%M:%S') || '—' %></td>
51
+ </tr>
52
+ <tr>
53
+ <td class="text-muted">Expires</td>
54
+ <td><%= session_expired_at&.strftime('%Y-%m-%d %H:%M:%S') || '—' %></td>
55
+ </tr>
56
+ </tbody>
57
+ </table>
58
+ </div>
59
+ <% else %>
60
+ <div class="mb-3 text-muted small">No session issued yet.</div>
61
+ <% end %>
62
+
63
+ <div class="mb-2 text-muted small text-uppercase" style="letter-spacing: .04em;">Raw document</div>
64
+ <pre class="p-2 mb-0 text-left rounded bg-light" style="max-height: 400px; overflow: auto;"><%= JSON.pretty_generate(guest) %></pre>
65
+ <% elsif error.present? %>
66
+ <div class="alert alert-danger mb-0">Could not load this document: <%= error %></div>
67
+ <% else %>
68
+ <span class="text-muted small">Document not found (may have been resolved or swept since the list loaded).</span>
69
+ <% end %>
70
+ <% end %>
@@ -0,0 +1,36 @@
1
+ <%# Mirrors Spree's own spree/admin/shared/_index_table_options partial (pagination left,
2
+ per-page select right) so this page behaves the same as every other admin index.
3
+ Doesn't reuse that partial's global `.js-per-page-select` JS (ransack.es6 in
4
+ spree_backend), though: that handler is bound once on the page-wide `spree:load` event,
5
+ which has already fired by the time Turbo lazy-loads this frame's content -- the select
6
+ doesn't exist yet when the binding runs, so it silently never receives the listener and
7
+ picking a value does nothing. `onchange` on the element itself has no such timing
8
+ dependency. Submits to the full #guests page -- the same place the date chips and doc-ID
9
+ search above already navigate to -- rather than straight to #guests_status, so every
10
+ control on this page reloads the same way instead of some doing a full page load and one
11
+ doing a frame-scoped partial swap.
12
+
13
+ Placed above the table, not below: with up to MAX_DOCS rows on a page, scrolling to the
14
+ bottom just to change page or page size is the exact friction this is meant to avoid. %>
15
+ <div class="row index-pagination-row mb-2">
16
+ <div class="col-12 col-sm-9 text-center text-sm-left">
17
+ <%= paginate guests, theme: 'admin-twitter-bootstrap-4' %>
18
+ </div>
19
+ <div class="col-12 col-sm-3 text-center text-sm-left">
20
+ <%# data-turbo-frame="_top": this form sits inside the polling turbo-frame, and #guests'
21
+ response contains that same frame id -- without this, Turbo would treat the submit
22
+ as a frame-scoped swap (URL bar and the rest of the page left untouched) rather than
23
+ the real full-page reload wanted here. %>
24
+ <%= form_tag guests_admin_system_waiting_room_sessions_path(date: date, doc_id: params[:doc_id], status: params[:status]),
25
+ method: :get, data: { turbo_frame: '_top' },
26
+ class: 'form-inline justify-content-center justify-content-sm-end' do %>
27
+ <%= select_tag :per_page,
28
+ options_for_select(
29
+ Spree::Admin::System::WaitingRoomSessionsController::GUESTS_PER_PAGE_OPTIONS,
30
+ params[:per_page].presence&.to_i || Spree::Admin::System::WaitingRoomSessionsController::GUESTS_DEFAULT_PER_PAGE
31
+ ),
32
+ class: 'w-auto form-control custom-select custom-select-sm',
33
+ onchange: 'this.form.requestSubmit()' %>
34
+ <% end %>
35
+ </div>
36
+ </div>
@@ -0,0 +1,86 @@
1
+ <%# Rendered by both #guests (as the frame's lazy-loaded src target) and #guests_status (every poll).
2
+ Wraps the SAME frame id `guests.html.erb` gave a `src` to, but WITHOUT a src attribute here --
3
+ Turbo refuses a response whose matching frame still carries the URL it was just fetched from
4
+ ("has a source URL which references itself"), so the src stays only on the DOM element Turbo
5
+ already has, set once on initial load. See async_operation_status_controller.js and
6
+ shared_console/setup/inventory_items/_seat_lock_status.html.erb for the same split. %>
7
+ <%= turbo_frame_tag 'async_operation_status_frame' do %>
8
+ <%# The poller lives on this inner element, never on the frame itself: frame.reload() replaces
9
+ the frame's children and leaves its own attributes alone, so a flag on the frame would never
10
+ change and the poll would never stop. Each reload disconnects this controller (clearing its
11
+ timer) and connects a fresh one -- which is what keeps a doc-id search result from being
12
+ polled at all, since `in-progress` is always "true" here rather than driven by a real
13
+ terminal state (there's no "done" for a live guest list -- it always keeps refreshing while
14
+ this tab is open). %>
15
+ <div data-controller="async-operation-status"
16
+ data-async-operation-status-in-progress-value="true"
17
+ data-async-operation-status-interval-value="10000">
18
+ <% if error.present? %>
19
+ <div class="alert alert-danger mb-3">
20
+ Could not load guests: <%= error %>
21
+ </div>
22
+ <% end %>
23
+
24
+ <%= render 'guests_pagination', guests: guests, date: date unless doc_id_search %>
25
+
26
+ <%# A fixed, curated set of columns -- not one per field in the document. Queue/Heartbeat
27
+ already summarize left_at/call_dispatched_at/allow_to_enter_room_at/last_ping_at, and
28
+ anything else (including the session map) is one Raw click away, so there's no dynamic
29
+ per-doc column list to keep in sync here. %>
30
+ <div class="table-responsive">
31
+ <table class="table table-sm table-hover mb-0">
32
+ <thead class="text-muted">
33
+ <tr>
34
+ <th>doc_id</th>
35
+ <th>queued_at</th>
36
+ <th>people_ahead_of_you</th>
37
+ <th>position</th>
38
+ <th>Queue</th>
39
+ <th>Heartbeat</th>
40
+ <th>Raw</th>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ <% guests.each do |guest| %>
45
+ <% queue_label, queue_badge = guest_queue_status(guest) %>
46
+ <% heartbeat = guest_heartbeat_status(guest) %>
47
+ <tr>
48
+ <td class="align-top"><code><%= guest[:doc_id] %></code></td>
49
+ <td class="align-top"><%= guest[:queued_at]&.strftime('%Y-%m-%d %H:%M:%S') || '—' %></td>
50
+ <td class="align-top"><%= guest_people_ahead(guest, frontier_position) || '—' %></td>
51
+ <td class="align-top"><%= guest[:position] || '—' %></td>
52
+ <td class="align-top"><span class="badge <%= queue_badge %>"><%= queue_label %></span></td>
53
+ <td class="align-top">
54
+ <% if heartbeat %>
55
+ <% heartbeat_label, heartbeat_badge = heartbeat %>
56
+ <span class="badge <%= heartbeat_badge %>"><%= heartbeat_label %></span>
57
+ <% else %>
58
+ &mdash;
59
+ <% end %>
60
+ </td>
61
+
62
+ <%# Nothing is fetched or rendered for this row until clicked: showGuestRawModal
63
+ (defined once in guests.html.erb) points the shared modal's frame at this URL
64
+ and opens the modal. A <button>, not a link -- this row sits inside the polling
65
+ turbo-frame, and an <a> here would get captured by Turbo's own frame-scoped
66
+ navigation before this row's markup is even a link click away from mattering. %>
67
+ <td class="align-top">
68
+ <%= button_tag 'View', type: 'button',
69
+ onclick: "showGuestRawModal('#{escape_javascript(guest_admin_system_waiting_room_sessions_path(date: date, doc_id: guest[:doc_id]))}')",
70
+ class: 'btn btn-outline-secondary btn-sm' %>
71
+ </td>
72
+ </tr>
73
+ <% end %>
74
+
75
+ <% if guests.empty? %>
76
+ <tr>
77
+ <td colspan="7" class="text-center text-muted py-4">
78
+ <%= doc_id_search ? 'No guest document found with that ID for this date.' : 'No guest documents for this date.' %>
79
+ </td>
80
+ </tr>
81
+ <% end %>
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ </div>
86
+ <% end %>
@@ -0,0 +1,10 @@
1
+ <ul class="nav nav-tabs mb-3">
2
+ <li class="nav-item">
3
+ <%= link_to 'Sessions', admin_system_waiting_room_sessions_path,
4
+ class: "nav-link #{'active' if current == :sessions}" %>
5
+ </li>
6
+ <li class="nav-item">
7
+ <%= link_to 'Guests', guests_admin_system_waiting_room_sessions_path,
8
+ class: "nav-link #{'active' if current == :guests}" %>
9
+ </li>
10
+ </ul>
@@ -0,0 +1,141 @@
1
+ <%= render 'spree/admin/shared/system_tabs', current: :waiting_room %>
2
+ <%= render 'tabs', current: :guests %>
3
+
4
+ <div class="mb-3 card">
5
+ <div class="card-header">
6
+ <h5 class="mb-0">Firestore waiting-room guests</h5>
7
+ </div>
8
+
9
+ <div class="card-body">
10
+ <% today = Time.zone.today.strftime('%Y-%m-%d') %>
11
+ <% yesterday = Time.zone.yesterday.strftime('%Y-%m-%d') %>
12
+ <%# nil (omitted from the URL), not 'in_queue' -- keeps the common-case URL clean, and matches
13
+ the controller's own `params[:status] != 'all'` default-on check. %>
14
+ <% status_param = @in_queue_only ? nil : 'all' %>
15
+
16
+ <%# Only today/yesterday get chip buttons -- the common case by far, and a free-text date
17
+ input invites typos a date picker would need extra JS to avoid. `date` is still a plain
18
+ query param the controller happily accepts for any value, so any other date stays one
19
+ URL edit away without needing UI for it. %>
20
+ <div class="mr-3 form-group d-inline-block">
21
+ <label class="mb-0 mr-2">Date</label>
22
+ <div class="btn-group btn-group-sm" role="group">
23
+ <%= link_to 'Today', guests_admin_system_waiting_room_sessions_path(date: today, doc_id: @doc_id, per_page: @per_page, status: status_param),
24
+ class: "btn btn-outline-secondary #{'active' if @date == today}" %>
25
+ <%= link_to 'Yesterday', guests_admin_system_waiting_room_sessions_path(date: yesterday, doc_id: @doc_id, per_page: @per_page, status: status_param),
26
+ class: "btn btn-outline-secondary #{'active' if @date == yesterday}" %>
27
+ </div>
28
+ <% unless [today, yesterday].include?(@date) %>
29
+ <span class="ml-2 text-muted small">Viewing <code><%= @date %></code> (set via URL)</span>
30
+ <% end %>
31
+ </div>
32
+
33
+ <%# In queue only (default) hides Admitted/Left -- it's the everyday view, and it's also what
34
+ keeps WaitingRoom::Guests::List::MAX_DOCS from being spent on already-resolved history on
35
+ a busy day (see that class). All is the explicit opt-in for historical/debugging lookups. %>
36
+ <div class="mr-3 form-group d-inline-block">
37
+ <label class="mb-0 mr-2">Status</label>
38
+ <div class="btn-group btn-group-sm" role="group">
39
+ <%= link_to 'In queue', guests_admin_system_waiting_room_sessions_path(date: @date, doc_id: @doc_id, per_page: @per_page),
40
+ class: "btn btn-outline-secondary #{'active' if @in_queue_only}" %>
41
+ <%= link_to 'All', guests_admin_system_waiting_room_sessions_path(date: @date, doc_id: @doc_id, per_page: @per_page, status: 'all'),
42
+ class: "btn btn-outline-secondary #{'active' unless @in_queue_only}" %>
43
+ </div>
44
+ </div>
45
+
46
+ <%= form_tag guests_admin_system_waiting_room_sessions_path, method: :get, class: 'form-inline mb-3' do %>
47
+ <%= hidden_field_tag :date, @date, id: nil %>
48
+ <%= hidden_field_tag :status, status_param, id: nil %>
49
+
50
+ <div class="mr-3 form-group">
51
+ <%= label_tag :doc_id, 'Doc ID', class: 'mr-2 mb-0' %>
52
+ <%= text_field_tag :doc_id, @doc_id, class: 'form-control form-control-sm', placeholder: 'search by exact doc id' %>
53
+ </div>
54
+
55
+ <%= hidden_field_tag :per_page, @per_page, id: nil %>
56
+
57
+ <%= button_tag 'Search', type: :submit, class: 'btn btn-outline-secondary btn-sm mr-2' %>
58
+
59
+ <% if @doc_id.present? %>
60
+ <%= link_to 'Clear search', guests_admin_system_waiting_room_sessions_path(date: @date, per_page: @per_page, status: status_param), class: 'btn btn-link btn-sm' %>
61
+ <% end %>
62
+ <% end %>
63
+
64
+ <p class="mb-3 text-muted small">
65
+ Raw documents under <code>waiting_guests/<%= @date %>/records</code>, up to
66
+ <%= SpreeCmCommissioner::WaitingRoom::Guests::List::MAX_DOCS %> most-recently-queued<%= ' still in queue' if @in_queue_only %>.
67
+ This list refreshes automatically every 10 seconds.
68
+ </p>
69
+
70
+ <%# `src` is set HERE, once, on initial load -- the polled response (#guests_status, via
71
+ _guests_table.html.erb) re-renders this same frame id WITHOUT src, so this attribute
72
+ stays put across every reload() call the async-operation-status controller makes. %>
73
+ <%= turbo_frame_tag 'async_operation_status_frame',
74
+ src: guests_status_admin_system_waiting_room_sessions_path(date: @date, doc_id: @doc_id, page: params[:page], per_page: @per_page, status: status_param) do %>
75
+ <div class="py-4 d-flex justify-content-center">
76
+ <span class="spinner-border spinner-border-sm text-muted" role="status"></span>
77
+ </div>
78
+ <% end %>
79
+ </div>
80
+ </div>
81
+
82
+ <%# One shared modal for every row's "Raw" button (see _guests_table.html.erb), not one per row --
83
+ up to MAX_DOCS of those would mean pre-building MAX_DOCS empty modals whether anyone opens one
84
+ or not. Its frame has no `src`: nothing is fetched until a click points it at a URL below. Lives
85
+ outside the polling frame above so a 10s refresh never touches it while it's open.
86
+
87
+ `display: none` toggled directly in JS below, not via Bootstrap's .fade/.show classes and
88
+ `$(...).modal('show')` -- this app's bundle doesn't have Bootstrap's jQuery modal plugin
89
+ loaded (`$.fn.modal` is undefined here), so this styles and shows/hides itself with no
90
+ dependency on which JS bundle happens to be on the page. %>
91
+ <div id="guest_raw_modal" role="dialog" aria-labelledby="guest_raw_modal_label" aria-hidden="true"
92
+ style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1050; background: rgba(0, 0, 0, 0.5);"
93
+ onclick="if (event.target === this) closeGuestRawModal();">
94
+ <div class="modal-dialog modal-lg" role="document" style="margin-top: 5vh;">
95
+ <div class="modal-content">
96
+ <div class="modal-header">
97
+ <h5 class="modal-title" id="guest_raw_modal_label">Guest document</h5>
98
+ <button type="button" class="close" aria-label="Close" onclick="closeGuestRawModal()">
99
+ <span aria-hidden="true">&times;</span>
100
+ </button>
101
+ </div>
102
+ <div class="modal-body">
103
+ <%= turbo_frame_tag 'guest_raw_modal_frame' do %>
104
+ <div class="text-muted small">Loading&hellip;</div>
105
+ <% end %>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <script>
112
+ // Called via a plain onclick on each row's "View" button (see _guests_table.html.erb) -- not an
113
+ // <a>. Turbo intercepts every <a> click inside a turbo-frame by default and tries to load the
114
+ // response into the nearest enclosing frame, which for a row is the polling
115
+ // async_operation_status_frame above; that frame's id has no match in the raw-document response,
116
+ // so Turbo would quietly clobber the table instead of opening anything. A <button> has no
117
+ // navigation for Turbo to intercept, so this function is just left to run.
118
+ function showGuestRawModal(url) {
119
+ // A turbo-frame keeps its old content visible while a new `src` loads (that's what lets
120
+ // polling elsewhere on this page swap without a blank flash) -- here that means the PREVIOUS
121
+ // row's document would stay on screen, unchanged, until the new fetch resolves. Clearing it
122
+ // first makes each click show its own row, never a stale one.
123
+ var frame = document.getElementById('guest_raw_modal_frame');
124
+ frame.innerHTML = '<div class="text-muted small">Loading&hellip;</div>';
125
+
126
+ // Cache-busted so reopening the SAME row's modal always issues a fresh request instead of
127
+ // Turbo/the browser reusing an identical prior GET for this exact URL -- this endpoint exists
128
+ // to show the guest's current Firestore state, not whatever it was on the last click.
129
+ frame.src = url + (url.indexOf('?') === -1 ? '?' : '&') + '_=' + Date.now();
130
+
131
+ document.getElementById('guest_raw_modal').style.display = 'block';
132
+ }
133
+
134
+ function closeGuestRawModal() {
135
+ document.getElementById('guest_raw_modal').style.display = 'none';
136
+ }
137
+
138
+ document.addEventListener('keydown', function(event) {
139
+ if (event.key === 'Escape') closeGuestRawModal();
140
+ });
141
+ </script>
@@ -1,6 +1,5 @@
1
1
  <%= render 'spree/admin/shared/system_tabs', current: :waiting_room %>
2
-
3
- <%= link_to_with_icon 'arrow-left.svg', "Back to Waiting Room", admin_system_waiting_room_path, class: 'btn btn-outline-secondary btn-sm mb-3' %>
2
+ <%= render 'tabs', current: :sessions %>
4
3
 
5
4
  <div class="card mb-3">
6
5
  <div class="card-header d-flex justify-content-between align-items-center flex-wrap">
@@ -19,7 +19,8 @@
19
19
  { include_hidden: true },
20
20
  data: { autocomplete_url_value: 'users_api_v2',
21
21
  autocomplete_return_attr_value: 'email',
22
- autocomplete_search_query_value: 'email_i_cont',
22
+ autocomplete_search_query_value: 'email_or_normalized_login_or_phone_number_i_cont',
23
+ autocomplete_additional_url_params_value: "tenant_id=#{@taxon.vendor&.tenant_id || 0}",
23
24
  minimum_input_length: 3 } %>
24
25
  <%= f.error_message_on :user_id %>
25
26
  <% end %>
@@ -11,7 +11,7 @@
11
11
  <% end %>
12
12
 
13
13
  <% content_for :page_actions do %>
14
- <%= button_link_to Spree.t(:add_new_user_events), new_admin_user_event_path(params: {taxon_id: @taxon.id}), class: "btn-success", icon: 'add.svg', id: 'admin_new_user_events_link' %>
14
+ <%= button_link_to Spree.t(:add_new_crews), new_admin_user_event_path(params: {taxon_id: @taxon.id}), class: "btn-success", icon: 'add.svg', id: 'admin_new_user_events_link' %>
15
15
  <% end if can? :create, SpreeCmCommissioner::UserTaxon %>
16
16
 
17
17
  <%= render partial: 'spree/admin/shared/taxon_tabs', locals: { current: :user_events } %>
@@ -660,8 +660,11 @@ en:
660
660
  customer_found: "Found %{email}."
661
661
  customer_not_found: 'No customer found for "%{query}" in this tenant.'
662
662
  clear_search: "Clear search"
663
- assign: "Assign"
664
- customer_assigned: "Order reassigned to %{email}."
663
+ assign_user: "Assign User"
664
+ replace_user: "Replace User"
665
+ replace_user_confirm: "This order is currently assigned to %{email}. Replace them with the user found above?"
666
+ user_assigned: "Order reassigned to %{email}."
667
+ user_already_assigned: "This order is already assigned to %{email}."
665
668
  event:
666
669
  check_in:
667
670
  success: "Guest check-in in successfully"
@@ -433,8 +433,11 @@ km:
433
433
  customer_found: "រកឃើញ %{email}។"
434
434
  customer_not_found: 'រកមិនឃើញអតិថិជនសម្រាប់ "%{query}" ក្នុង tenant នេះទេ។'
435
435
  clear_search: "សម្អាតការស្វែងរក"
436
- assign: "ផ្តល់ជូន"
437
- customer_assigned: "លំដាប់នេះត្រូវបានផ្លាស់ប្តូរទៅ %{email}។"
436
+ assign_user: "ផ្តល់ជូនអ្នកប្រើប្រាស់"
437
+ replace_user: "ជំនួសអ្នកប្រើប្រាស់"
438
+ replace_user_confirm: "លំដាប់នេះកំពុងភ្ជាប់ជាមួយ %{email} រួចហើយ។ តើជំនួសដោយអ្នកប្រើប្រាស់ដែលរកឃើញខាងលើមែនទេ?"
439
+ user_assigned: "លំដាប់នេះត្រូវបានផ្លាស់ប្តូរទៅ %{email}។"
440
+ user_already_assigned: "លំដាប់នេះបានភ្ជាប់ជាមួយ %{email} រួចហើយ។"
438
441
  event:
439
442
  check_in:
440
443
  success: "Guest check-in in successfully"
data/config/routes.rb CHANGED
@@ -24,6 +24,9 @@ Spree::Core::Engine.add_routes do
24
24
  resources :waiting_room_sessions, controller: :waiting_room_sessions, only: [:index] do
25
25
  collection do
26
26
  post :kill
27
+ get :guests
28
+ get :guests_status
29
+ get :guest
27
30
  end
28
31
  end
29
32
 
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.12.9'.freeze
2
+ VERSION = '2.13.1'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.12.9
4
+ version: 2.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
@@ -961,6 +961,7 @@ files:
961
961
  - app/controllers/spree/api/v2/platform/homepage_section_relatable_options_controller.rb
962
962
  - app/controllers/spree/api/v2/platform/option_types_controller_decorator.rb
963
963
  - app/controllers/spree/api/v2/platform/places_controller.rb
964
+ - app/controllers/spree/api/v2/platform/resource_controller_decorator.rb
964
965
  - app/controllers/spree/api/v2/platform/seat_number_layouts_controller.rb
965
966
  - app/controllers/spree/api/v2/platform/taxons_controller_decorator.rb
966
967
  - app/controllers/spree/api/v2/resource_controller_decorator.rb
@@ -1226,6 +1227,7 @@ files:
1226
1227
  - app/finders/spree_cm_commissioner/variants/find_by_option_value_ids.rb
1227
1228
  - app/helpers/spree/admin/base_helper_decorator.rb
1228
1229
  - app/helpers/spree/admin/google_wallets_helper.rb
1230
+ - app/helpers/spree/admin/system/waiting_room_sessions_helper.rb
1229
1231
  - app/helpers/spree/base_helper_decorator.rb
1230
1232
  - app/helpers/spree/billing/navigation_helper_decorator.rb
1231
1233
  - app/helpers/spree/billing/sortable_tree_helper.rb
@@ -1833,6 +1835,7 @@ files:
1833
1835
  - app/overrides/spree/admin/orders/_search/line_item_number_search_field.html.erb.deface
1834
1836
  - app/overrides/spree/admin/orders/_search/payment_method_search_field.html.erb.deface
1835
1837
  - app/overrides/spree/admin/orders/_search/payment_number_search_field.html.erb.deface
1838
+ - app/overrides/spree/admin/orders/_search/taxon_search_field.html.erb.deface
1836
1839
  - app/overrides/spree/admin/orders/_search/vendor_search_field.html.erb.deface
1837
1840
  - app/overrides/spree/admin/orders/customer_details/_form/phone_number_field.html.erb.deface
1838
1841
  - app/overrides/spree/admin/orders/customer_details/_form/view_profile_link.html.erb.deface
@@ -2481,6 +2484,8 @@ files:
2481
2484
  - app/services/spree_cm_commissioner/waiting_room/admission/call_guests_chunk.rb
2482
2485
  - app/services/spree_cm_commissioner/waiting_room/advance_queue.rb
2483
2486
  - app/services/spree_cm_commissioner/waiting_room/bypass_eligibility.rb
2487
+ - app/services/spree_cm_commissioner/waiting_room/guests/find.rb
2488
+ - app/services/spree_cm_commissioner/waiting_room/guests/list.rb
2484
2489
  - app/services/spree_cm_commissioner/waiting_room/heartbeat/sweep.rb
2485
2490
  - app/services/spree_cm_commissioner/waiting_room/positions/stamp.rb
2486
2491
  - app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb
@@ -2706,6 +2711,11 @@ files:
2706
2711
  - app/views/spree/admin/system/maintenance_tasks/_search.html.erb
2707
2712
  - app/views/spree/admin/system/maintenance_tasks/index.html.erb
2708
2713
  - app/views/spree/admin/system/waiting_room/show.html.erb
2714
+ - app/views/spree/admin/system/waiting_room_sessions/_guest_raw.html.erb
2715
+ - app/views/spree/admin/system/waiting_room_sessions/_guests_pagination.html.erb
2716
+ - app/views/spree/admin/system/waiting_room_sessions/_guests_table.html.erb
2717
+ - app/views/spree/admin/system/waiting_room_sessions/_tabs.html.erb
2718
+ - app/views/spree/admin/system/waiting_room_sessions/guests.html.erb
2709
2719
  - app/views/spree/admin/system/waiting_room_sessions/index.html.erb
2710
2720
  - app/views/spree/admin/taxon_childrens/index.html.erb
2711
2721
  - app/views/spree/admin/taxon_vendors/_form.html.erb