spree_cm_commissioner 2.9.1.pre.pre3 → 2.9.2

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +9 -0
  3. data/Gemfile.lock +2 -2
  4. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +7 -0
  5. data/app/controllers/concerns/spree_cm_commissioner/load_test_account_management.rb +80 -0
  6. data/app/controllers/concerns/spree_cm_commissioner/load_test_account_scope.rb +46 -0
  7. data/app/controllers/concerns/spree_cm_commissioner/load_test_login_token_generation.rb +53 -0
  8. data/app/controllers/spree/admin/guests_controller.rb +4 -2
  9. data/app/controllers/spree/admin/system/load_test_accounts_controller.rb +24 -0
  10. data/app/controllers/spree/admin/system/load_test_flows_controller.rb +11 -0
  11. data/app/controllers/spree/admin/system/load_test_login_tokens_controller.rb +16 -0
  12. data/app/controllers/spree/admin/system/load_testing_controller.rb +9 -0
  13. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +48 -0
  14. data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -7
  15. data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +28 -0
  16. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +49 -0
  17. data/app/controllers/spree/api/v2/tenant/preview_products_controller.rb +1 -1
  18. data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -7
  19. data/app/controllers/spree_cm_commissioner/admin/variants_controller_decorator.rb +11 -0
  20. data/app/controllers/spree_cm_commissioner/api/v2/storefront/checkout_controller_decorator.rb +0 -2
  21. data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +105 -0
  22. data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +71 -0
  23. data/app/interactors/spree_cm_commissioner/user_id_token_authenticator.rb +2 -5
  24. data/app/interactors/spree_cm_commissioner/user_password_authenticator.rb +23 -1
  25. data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +78 -0
  26. data/app/mailers/spree/order_mailer_decorator.rb +1 -0
  27. data/app/models/concerns/spree_cm_commissioner/kyc_bitwise.rb +17 -0
  28. data/app/models/concerns/spree_cm_commissioner/line_items_filter_scope.rb +5 -3
  29. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +11 -0
  30. data/app/models/concerns/spree_cm_commissioner/product_delegation.rb +2 -0
  31. data/app/models/spree_cm_commissioner/homepage_section_relatable.rb +25 -0
  32. data/app/models/spree_cm_commissioner/order_decorator.rb +0 -6
  33. data/app/models/spree_cm_commissioner/payment_method_decorator.rb +2 -0
  34. data/app/models/spree_cm_commissioner/pricing_model_route.rb +8 -0
  35. data/app/models/spree_cm_commissioner/product_decorator.rb +1 -0
  36. data/app/models/spree_cm_commissioner/trip.rb +13 -1
  37. data/app/models/spree_cm_commissioner/user_decorator.rb +6 -0
  38. data/app/overrides/spree/admin/payment_methods/index/pagination.html.erb.deface +3 -0
  39. data/app/overrides/spree/admin/payment_methods/index/preserve_tab_in_search.html.erb.deface +4 -0
  40. data/app/overrides/spree/admin/products/_form/enable_social_sharing_email.html.erb.deface +13 -0
  41. data/app/overrides/spree/admin/variants/index/fixture_action.html.erb.deface +9 -0
  42. data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +2 -0
  43. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_result_serializer.rb +2 -2
  44. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_serializer.rb +3 -1
  45. data/app/services/spree_cm_commissioner/draft_order/create.rb +86 -0
  46. data/app/services/spree_cm_commissioner/load_test/create_accounts.rb +68 -0
  47. data/app/services/spree_cm_commissioner/load_test/destroy_accounts.rb +50 -0
  48. data/app/services/spree_cm_commissioner/load_test/flow_registry.rb +26 -0
  49. data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +140 -0
  50. data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +178 -0
  51. data/app/services/spree_cm_commissioner/load_test/login_token.rb +46 -0
  52. data/app/services/spree_cm_commissioner/order/guests/update.rb +56 -0
  53. data/app/services/spree_cm_commissioner/pricing_models/create_with_rule_groups.rb +9 -3
  54. data/app/services/spree_cm_commissioner/pricing_models/preview.rb +49 -6
  55. data/app/services/spree_cm_commissioner/pricing_models/update_with_rule_groups.rb +10 -7
  56. data/app/services/spree_cm_commissioner/trips/update_single_leg.rb +6 -1
  57. data/app/services/spree_cm_commissioner/user_authenticator.rb +22 -1
  58. data/app/services/spree_cm_commissioner/voting_credits/allocate.rb +13 -2
  59. data/app/views/spree/admin/featured_trips/index.html.erb +1 -1
  60. data/app/views/spree/admin/guests/_form.html.erb +37 -0
  61. data/app/views/spree/admin/shared/_system_tabs.html.erb +7 -0
  62. data/app/views/spree/admin/system/load_test_accounts/_login_tokens_modal.html.erb +52 -0
  63. data/app/views/spree/admin/system/load_test_accounts/index.html.erb +175 -0
  64. data/app/views/spree/admin/system/load_test_flows/index.html.erb +66 -0
  65. data/app/views/spree/admin/system/load_testing/index.html.erb +25 -0
  66. data/app/views/spree/admin/variants/fixture.html.erb +49 -0
  67. data/config/initializers/doorkeeper.rb +10 -6
  68. data/config/initializers/spree_permitted_attributes.rb +30 -0
  69. data/config/load_test_flows/platform.yml +25 -0
  70. data/config/load_test_flows/tenant.yml +46 -0
  71. data/config/locales/en.yml +94 -0
  72. data/config/locales/km.yml +2 -0
  73. data/config/routes.rb +19 -0
  74. data/db/migrate/20260630000001_add_trip_to_cm_pricing_model_routes.rb +7 -0
  75. data/db/migrate/20260703065239_add_position_to_cm_voting_contestants.rb +1 -1
  76. data/db/migrate/20260708150000_add_name_and_short_name_to_cm_trips.rb +6 -0
  77. data/lib/spree_cm_commissioner/test_helper/factories/pricing_model_trip_factory.rb +11 -0
  78. data/lib/spree_cm_commissioner/trip_result.rb +3 -1
  79. data/lib/spree_cm_commissioner/version.rb +1 -1
  80. metadata +39 -4
@@ -39,29 +39,72 @@ module SpreeCmCommissioner
39
39
  end
40
40
 
41
41
  def find_pricing_models(group_contexts)
42
- route_pricing_models(group_contexts) + variant_pricing_models(group_contexts)
42
+ transit_pricing_models(group_contexts) + variant_pricing_models(group_contexts)
43
43
  end
44
44
 
45
- def route_pricing_models(group_contexts)
45
+ # Trip-level pricing overrides route-level pricing for the same journey; route pricing
46
+ # is the fallback when the trip (or connected journey's parent trip) has none assigned.
47
+ def transit_pricing_models(group_contexts)
46
48
  transit_contexts = group_contexts.select { |ctx| ctx.trip_id.present? }
47
49
  return [] if transit_contexts.blank?
48
50
 
49
51
  origin_place_id, destination_place_id = determine_origin_destination(transit_contexts)
50
52
  return [] if origin_place_id.blank? || destination_place_id.blank?
51
53
 
52
- vendor_ids = transit_contexts.map { |ctx| variant_vendor_id(ctx.variant_id) }.compact.uniq
54
+ vendor_ids = transit_vendor_ids(transit_contexts)
53
55
  return [] if vendor_ids.blank?
54
56
 
55
- route_ids = trip_route_ids(transit_contexts)
57
+ trip_level_pricing_models(
58
+ trip_ids: trip_candidate_ids(transit_contexts),
59
+ origin_place_id: origin_place_id,
60
+ destination_place_id: destination_place_id,
61
+ vendor_ids: vendor_ids
62
+ ).presence || route_level_pricing_models(
63
+ route_ids: trip_route_ids(transit_contexts),
64
+ origin_place_id: origin_place_id,
65
+ destination_place_id: destination_place_id,
66
+ vendor_ids: vendor_ids
67
+ )
68
+ end
69
+
70
+ def trip_level_pricing_models(trip_ids:, origin_place_id:, destination_place_id:, vendor_ids:)
71
+ return [] if trip_ids.empty?
56
72
 
57
73
  scope = SpreeCmCommissioner::PricingModelRoute
58
74
  .active
75
+ .trip_scoped
76
+ .where(trip_id: trip_ids)
59
77
  .for_origin_destination(origin_place_id, destination_place_id)
60
- .includes(:pricing_model)
61
78
 
79
+ pricing_models_for(scope, vendor_ids)
80
+ end
81
+
82
+ def route_level_pricing_models(route_ids:, origin_place_id:, destination_place_id:, vendor_ids:)
83
+ scope = SpreeCmCommissioner::PricingModelRoute
84
+ .active
85
+ .route_scoped
86
+ .for_origin_destination(origin_place_id, destination_place_id)
62
87
  scope = scope.where(route_id: route_ids) if route_ids.any?
63
88
 
64
- scope.map(&:pricing_model).select { |pm| vendor_ids.include?(pm.vendor_id) }
89
+ pricing_models_for(scope, vendor_ids)
90
+ end
91
+
92
+ # Resolves a PricingModelRoute scope into its pricing models, restricted to vendors
93
+ # actually present among the line items being priced.
94
+ def pricing_models_for(scope, vendor_ids)
95
+ scope.includes(:pricing_model).map(&:pricing_model).select { |pm| vendor_ids.include?(pm.vendor_id) }
96
+ end
97
+
98
+ # A booking's legs can each have their own trip-level price, and a connected journey
99
+ # (multiple legs sharing connected_trip_id) can also have its own price on the shared
100
+ # parent trip. This returns every trip id worth checking for either case.
101
+ def trip_candidate_ids(transit_contexts)
102
+ connected_trip_id = Integer(transit_contexts.first&.connected_trip_id.to_s, exception: false)
103
+ (transit_contexts.map(&:trip_id) + [connected_trip_id]).compact.uniq
104
+ end
105
+
106
+ def transit_vendor_ids(transit_contexts)
107
+ transit_contexts.map { |ctx| variant_vendor_id(ctx.variant_id) }.compact.uniq
65
108
  end
66
109
 
67
110
  def variant_pricing_models(group_contexts)
@@ -3,11 +3,11 @@ module SpreeCmCommissioner
3
3
  class UpdateWithRuleGroups
4
4
  prepend ::Spree::ServiceModule::Base
5
5
 
6
- def call(pricing_model:, params:, route: nil, origin_place_id: nil, destination_place_id: nil)
6
+ def call(pricing_model:, params:, route: nil, trip: nil, origin_place_id: nil, destination_place_id: nil) # rubocop:disable Metrics/ParameterLists
7
7
  ApplicationRecord.transaction do
8
8
  pricing_model.update!(model_params(params))
9
9
  sync_rule_groups(pricing_model, params[:rule_groups])
10
- update_route_assignment(pricing_model, route, origin_place_id, destination_place_id) if route.present?
10
+ update_route_assignment(pricing_model, route, trip, origin_place_id, destination_place_id) if route.present?
11
11
  end
12
12
 
13
13
  success(pricing_model)
@@ -47,14 +47,17 @@ module SpreeCmCommissioner
47
47
  end
48
48
  end
49
49
 
50
- def update_route_assignment(pricing_model, route, origin_place_id, destination_place_id)
50
+ # Each pricing model created via this flow has a single assignment row; update it in place so
51
+ # the assigned trip (and O-D) can be changed on edit. Trip-scoped rows take O-D from the trip.
52
+ def update_route_assignment(pricing_model, route, trip, origin_place_id, destination_place_id)
53
+ origin_place_id = trip.origin_place_id if trip.present?
54
+ destination_place_id = trip.destination_place_id if trip.present?
51
55
  return unless origin_place_id.present? && destination_place_id.present?
52
56
 
53
- route_assignment = PricingModelRoute.find_or_initialize_by(
54
- route: route,
55
- pricing_model: pricing_model
56
- )
57
+ route_assignment = PricingModelRoute.find_or_initialize_by(pricing_model: pricing_model)
57
58
  route_assignment.update!(
59
+ route: route,
60
+ trip: trip,
58
61
  origin_place_id: origin_place_id,
59
62
  destination_place_id: destination_place_id
60
63
  )
@@ -81,7 +81,12 @@ module SpreeCmCommissioner
81
81
  end
82
82
 
83
83
  def trip_attrs(form)
84
- { allow_booking: form.allow_booking, allow_seat_selection: form.allow_seat_selection }.compact
84
+ {
85
+ allow_booking: form.allow_booking,
86
+ allow_seat_selection: form.allow_seat_selection,
87
+ name: form.name,
88
+ short_name: form.short_name
89
+ }.compact
85
90
  end
86
91
 
87
92
  def product_attrs(form)
@@ -2,7 +2,9 @@
2
2
  module SpreeCmCommissioner
3
3
  class UserAuthenticator
4
4
  # :username, :password
5
- # :id_token
5
+ # :id_token (Firebase social login)
6
+ # :telegram_id_token -> Telegram OIDC login (Firebase/Google social login)
7
+ # :user_id_token (load-test JWT, self-identifying, minted by LoadTest::LoginToken)
6
8
  # :fb_access_token
7
9
  # :telegram_init_data, :tg_bot
8
10
  # :session_id
@@ -33,9 +35,17 @@ module SpreeCmCommissioner
33
35
  tenant_id = oauth_application&.tenant_id
34
36
 
35
37
  case flow_type(params)
38
+ when 'load_test_auth'
39
+ # k6 sends the load-test JWT as `user_id_token`; internally it's a login_token (see
40
+ # UserPasswordAuthenticator's self-identifying, token-only path).
41
+ options = { login_token: params[:user_id_token], tenant_id: tenant_id }
42
+ SpreeCmCommissioner::UserPasswordAuthenticator.call(options)
36
43
  when 'login_auth'
37
44
  options = { login: params[:username], password: params[:password], tenant_id: tenant_id }
38
45
  SpreeCmCommissioner::UserPasswordAuthenticator.call(options)
46
+ when 'telegram_oauth_auth'
47
+ options = { id_token: params[:telegram_id_token], tenant_id: tenant_id }
48
+ SpreeCmCommissioner::UserTelegramOauthAuthenticator.call(options)
39
49
  when 'social_auth'
40
50
  options = { id_token: params[:id_token], tenant_id: tenant_id }
41
51
  SpreeCmCommissioner::UserIdTokenAuthenticator.call(options)
@@ -52,7 +62,12 @@ module SpreeCmCommissioner
52
62
  end
53
63
 
54
64
  def self.flow_type(params)
65
+ # A separate params key from `id_token` (social login) means we only ever attempt to decode
66
+ # a token that's actually claiming to be a load-test token, instead of decoding every
67
+ # request's id_token to find out.
68
+ return 'load_test_auth' if load_test_id_token?(params[:user_id_token])
55
69
  return 'login_auth' if params.key?(:username) && params.key?(:password)
70
+ return 'telegram_oauth_auth' if params[:telegram_id_token].present?
56
71
  return 'social_auth' if params.key?(:id_token)
57
72
  return 'facebook_auth' if params.key?(:fb_access_token)
58
73
  return 'telegram_web_app_auth' if params.key?(:telegram_init_data) && params.key?(:tg_bot)
@@ -61,6 +76,12 @@ module SpreeCmCommissioner
61
76
  raise exception(I18n.t('authenticator.invalid_or_missing_params'))
62
77
  end
63
78
 
79
+ # True only for a load-test id_token — it's signed with our own secret, so it decodes here
80
+ # whereas a real Firebase/Google id_token (RS256, Google's keys) returns nil.
81
+ def self.load_test_id_token?(token)
82
+ token.present? && SpreeCmCommissioner::LoadTest::LoginToken.decode(token).present?
83
+ end
84
+
64
85
  def self.exception(message)
65
86
  Doorkeeper::Errors::DoorkeeperError.new(message)
66
87
  end
@@ -29,8 +29,8 @@ module SpreeCmCommissioner
29
29
  voting_credit.save!
30
30
  end
31
31
 
32
- total_amount = total_paid_vote_credits + total_free_vote_credits
33
- record_transaction(voting_credit, total_paid_vote_credits) if total_amount.positive?
32
+ record_transaction(voting_credit, total_paid_vote_credits) if total_paid_vote_credits.positive?
33
+ record_transaction_free(voting_credit, total_free_vote_credits) if total_free_vote_credits.positive?
34
34
  end
35
35
 
36
36
  record_free_claims
@@ -99,6 +99,17 @@ module SpreeCmCommissioner
99
99
  )
100
100
  end
101
101
 
102
+ def record_transaction_free(vote_credit, total_amount)
103
+ vote_credit.voting_credit_transactions.create!(
104
+ tenant_id: @order.tenant_id,
105
+ action: :free_claim,
106
+ amount: total_amount,
107
+ originator: @order,
108
+ memo: "Free claim for order ##{@order.number}",
109
+ user_total_amount: vote_credit.available_votes
110
+ )
111
+ end
112
+
102
113
  def record_free_claims
103
114
  return if @order.user_id.blank?
104
115
 
@@ -85,7 +85,7 @@
85
85
  <% if @featured_trips.any? %>
86
86
  <% @featured_trips.each do |trip| %>
87
87
  <tr id="<%= spree_dom_id trip %>">
88
- <td><%= link_to trip.product&.name || trip.id, spree.edit_admin_product_path(trip.product) if trip.product %></td>
88
+ <td><%= link_to trip.display_name || trip.id, spree.edit_admin_product_path(trip.product) if trip.product %></td>
89
89
  <td><%= trip.vendor&.name %></td>
90
90
  <td><%= [trip.origin_place&.name, trip.destination_place&.name].compact.join(' to ') %></td>
91
91
  <td><%= l(trip.departure_time, format: :short) if trip.departure_time %></td>
@@ -106,6 +106,43 @@
106
106
  <%= f.label :phone_number, raw(Spree.t(:phone_number) + required_span_tag) %>
107
107
  <%= f.text_field :phone_number, class: 'form-control' %>
108
108
  <% end %>
109
+ <% when :guest_contact %>
110
+ <%= f.field_container :contact do %>
111
+ <%= f.label :contact, raw(Spree.t(:contact) + required_span_tag) %>
112
+ <%= f.text_field :contact, class: 'form-control' %>
113
+ <% end %>
114
+ <% when :guest_organization %>
115
+ <%= f.field_container :other_organization do %>
116
+ <%= f.label :other_organization, raw(Spree.t(:organization) + required_span_tag) %>
117
+ <%= f.text_field :other_organization, class: 'form-control' %>
118
+ <% end %>
119
+ <% when :guest_address %>
120
+ <%= f.field_container :address do %>
121
+ <%= f.label :address, raw(Spree.t(:address) + required_span_tag) %>
122
+ <%= f.text_field :address, class: 'form-control' %>
123
+ <% end %>
124
+ <% when :guest_social_contact %>
125
+ <div class="row">
126
+ <div class="col-12 col-lg-6">
127
+ <%= f.field_container :social_contact_platform do %>
128
+ <%= f.label :social_contact_platform, raw(Spree.t(:social_contact_platform) + required_span_tag) %>
129
+ <%= f.select :social_contact_platform,
130
+ SpreeCmCommissioner::Guest.social_contact_platforms.keys.map { |platform| [platform.to_s.capitalize, platform] },
131
+ { include_blank: true }, class: 'fullwidth select2' %>
132
+ <% end %>
133
+ </div>
134
+ <div class="col-12 col-lg-6">
135
+ <%= f.field_container :social_contact do %>
136
+ <%= f.label :social_contact, raw(Spree.t(:social_contact) + required_span_tag) %>
137
+ <%= f.text_field :social_contact, class: 'form-control' %>
138
+ <% end %>
139
+ </div>
140
+ </div>
141
+ <% when :guest_expectation %>
142
+ <%= f.field_container :expectation do %>
143
+ <%= f.label :expectation, raw(Spree.t(:expectation) + required_span_tag) %>
144
+ <%= f.text_area :expectation, class: 'form-control', rows: 3 %>
145
+ <% end %>
109
146
  <% end %>
110
147
  <% end %>
111
148
  </div>
@@ -23,4 +23,11 @@
23
23
  admin_system_maintenance_tasks_path,
24
24
  class: "nav-link #{'active' if current == :maintenance_tasks}" %>
25
25
  <% end %>
26
+
27
+ <%= content_tag :li, class: 'nav-item' do %>
28
+ <%= link_to_with_icon 'speedometer.svg',
29
+ Spree.t('load_test.nav_label'),
30
+ admin_system_load_testing_index_path,
31
+ class: "nav-link #{'active' if current == :load_testing}" %>
32
+ <% end %>
26
33
  <% end %>
@@ -0,0 +1,52 @@
1
+ <%# Static, always on the page (hidden until the JS in manage_accounts.html.erb adds `.open`). The
2
+ Generate button opens this immediately, then fetches the tokens over AJAX and fills the
3
+ textarea — no page navigation. Fully self-contained: visuals come from the inline <style> here,
4
+ no dependency on Bootstrap's JS/CSS or any external bundle. %>
5
+ <div id="login_tokens_modal_backdrop" class="login-tokens-modal-backdrop"></div>
6
+ <div id="loginTokensModal" class="login-tokens-modal" role="dialog" aria-labelledby="loginTokensModalLabel">
7
+ <div class="login-tokens-modal-header">
8
+ <h5 class="mb-0" id="loginTokensModalLabel"><%= Spree.t('load_test.accounts.login_tokens_json') %></h5>
9
+ <button type="button" class="btn btn-sm btn-outline-secondary login-tokens-modal-close" data-dismiss="modal" aria-label="<%= Spree.t(:close) %>">
10
+ <span aria-hidden="true">&times;</span>
11
+ </button>
12
+ </div>
13
+ <div class="login-tokens-modal-body">
14
+ <div class="d-flex justify-content-between align-items-center mb-2 flex-shrink-0">
15
+ <label class="mb-0"><%= Spree.t('load_test.accounts.copy_into_k6') %></label>
16
+ <button type="button" class="btn btn-sm btn-outline-primary p-1" title="<%= Spree.t('load_test.accounts.copy_json') %>" aria-label="<%= Spree.t('load_test.accounts.copy_json') %>"
17
+ onclick="navigator.clipboard.writeText(document.getElementById('login_tokens_json').value); show_flash('success', <%= Spree.t('load_test.accounts.json_copied').to_json %>)">
18
+ <%= svg_icon name: 'clipboard', width: 16, height: 16, classes: 'm-0' %>
19
+ </button>
20
+ </div>
21
+ <div id="login_tokens_status" class="text-muted small mb-2 flex-shrink-0"></div>
22
+ <%= text_area_tag :login_tokens_json, nil,
23
+ readonly: true, class: 'form-control font-monospace login-tokens-modal-textarea' %>
24
+ </div>
25
+ </div>
26
+
27
+ <style>
28
+ .login-tokens-modal-backdrop {
29
+ position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4);
30
+ opacity: 0; visibility: hidden; transition: opacity .2s ease; z-index: 1040;
31
+ }
32
+ .login-tokens-modal-backdrop.open { opacity: 1; visibility: visible; }
33
+ .login-tokens-modal {
34
+ position: fixed; top: 0; right: 0; height: 100%; width: 560px; max-width: 92vw;
35
+ background: #fff; box-shadow: -2px 0 12px rgba(0, 0, 0, .2);
36
+ transform: translateX(100%); transition: transform .25s ease; z-index: 1050;
37
+ display: flex; flex-direction: column;
38
+ }
39
+ .login-tokens-modal.open { transform: translateX(0); }
40
+ .login-tokens-modal-header {
41
+ display: flex; align-items: center; justify-content: space-between;
42
+ padding: 1rem 1.25rem; border-bottom: 1px solid #e9ecef; flex-shrink: 0;
43
+ }
44
+ .login-tokens-modal-close {
45
+ line-height: 1; font-size: 1.25rem; padding: 0 .5rem;
46
+ }
47
+ .login-tokens-modal-body {
48
+ padding: 1.25rem; overflow: hidden; flex: 1; min-height: 0;
49
+ display: flex; flex-direction: column;
50
+ }
51
+ .login-tokens-modal-textarea { flex: 1; min-height: 0; resize: none; }
52
+ </style>
@@ -0,0 +1,175 @@
1
+ <%= render 'spree/admin/shared/system_tabs', current: :load_testing %>
2
+
3
+ <%= link_to Spree.t('load_test.back_to_load_testing'), admin_system_load_testing_index_path, class: 'btn btn-outline-secondary btn-sm mb-3' %>
4
+
5
+ <div class="row">
6
+ <div class="col-md-12">
7
+ <div class="card mb-3">
8
+ <div class="card-header"><h5 class="mb-0"><%= Spree.t('load_test.accounts.manage_accounts_platform') %></h5></div>
9
+ <div class="card-body">
10
+ <p class="text-muted">
11
+ <%= Spree.t('load_test.accounts.manage_accounts_body_html').html_safe %>
12
+ </p>
13
+ <%= form_tag admin_system_load_test_accounts_path, method: :post do %>
14
+ <div class="row">
15
+ <div class="col-md-3 form-group">
16
+ <label for="count"><%= Spree.t('load_test.accounts.count') %></label>
17
+ <%= number_field_tag :count, params[:count] || 20, class: 'form-control', min: 1 %>
18
+ </div>
19
+ <div class="col-md-4 form-group">
20
+ <label for="email_prefix"><%= Spree.t('load_test.accounts.email_prefix') %></label>
21
+ <%= text_field_tag :email_prefix, params[:email_prefix] || 'loadtest', class: 'form-control' %>
22
+ </div>
23
+ <div class="col-md-5 form-group">
24
+ <label for="email_domain"><%= Spree.t('load_test.accounts.email_domain') %></label>
25
+ <%= text_field_tag :email_domain, params[:email_domain], class: 'form-control', placeholder: Spree.t('load_test.accounts.email_domain_placeholder') %>
26
+ </div>
27
+ </div>
28
+ <%= button_tag Spree.t('load_test.accounts.create_accounts'), type: :submit, class: 'btn btn-primary' %>
29
+ <%= button_tag Spree.t('load_test.accounts.destroy_accounts'), type: :submit,
30
+ formaction: bulk_destroy_admin_system_load_test_accounts_path,
31
+ data: { confirm: Spree.t('load_test.accounts.destroy_accounts_confirm') },
32
+ class: 'btn btn-outline-danger' %>
33
+ <% end %>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="card mb-3">
38
+ <div class="card-header"><h5 class="mb-0"><%= Spree.t('load_test.accounts.generate_login_tokens_platform') %></h5></div>
39
+ <div class="card-body">
40
+ <p class="text-muted">
41
+ <%= Spree.t('load_test.accounts.generate_login_tokens_body_html', ttl: SpreeCmCommissioner::LoadTest::LoginToken::TTL.inspect).html_safe %>
42
+ </p>
43
+
44
+ <div class="d-flex justify-content-between align-items-start flex-wrap">
45
+ <%# Drives the table's own pagination below — a plain GET reload, separate from the AJAX
46
+ Generate Login Tokens form. Changing Per Page resets back to page 1. A typed number
47
+ input rather than Spree's usual 25/50/75 dropdown, since the load-test pool can run
48
+ well past those fixed sizes. %>
49
+ <%= form_tag admin_system_load_test_accounts_path, method: :get, class: 'form-inline mb-3' do %>
50
+ <label for="per_page" class="mr-2"><%= Spree.t('load_test.accounts.per_page') %></label>
51
+ <%= number_field_tag :per_page, @export_accounts.limit_value, class: 'form-control mr-2 mb-2', style: 'width: 100px', min: 1 %>
52
+ <%= button_tag Spree.t('load_test.accounts.apply'), type: :submit, name: nil, class: 'btn btn-outline-secondary mb-2' %>
53
+ <% end %>
54
+
55
+ <%# JS below intercepts submit: opens the drawer immediately, then fetches the tokens as
56
+ JSON into it — no navigation. data-turbo:false keeps Turbo out of the form entirely.
57
+ Hidden page/per_page mirror the table's own pagination below, so the generated tokens
58
+ always match what's on screen — page the table to change which accounts get tokens. %>
59
+ <%= form_tag admin_system_load_test_login_tokens_path,
60
+ method: :post, id: 'login_tokens_form', class: 'form-inline mb-3', data: { turbo: false } do %>
61
+ <%= hidden_field_tag :page, @export_accounts.current_page %>
62
+ <%= hidden_field_tag :per_page, @export_accounts.limit_value %>
63
+ <%= button_tag Spree.t('load_test.accounts.generate_login_tokens'), type: :submit, class: 'btn btn-primary mb-2' %>
64
+ <% end %>
65
+ </div>
66
+
67
+ <% if @export_accounts.any? %>
68
+ <div class="table-responsive">
69
+ <table class="table mb-0">
70
+ <thead class="text-muted">
71
+ <tr>
72
+ <th><%= Spree.t('load_test.accounts.table_no') %></th>
73
+ <th><%= Spree.t('load_test.accounts.table_email') %></th>
74
+ <th><%= Spree.t('load_test.accounts.table_user_id') %></th>
75
+ <th><%= Spree.t('load_test.accounts.table_created_at') %></th>
76
+ <th><%= Spree.t('load_test.accounts.table_last_sign_in_at') %></th>
77
+ <th><%= Spree.t('load_test.accounts.table_current_sign_in_at') %></th>
78
+ </tr>
79
+ </thead>
80
+ <tbody>
81
+ <% @export_accounts.each_with_index do |account, index| %>
82
+ <tr>
83
+ <td><%= @export_accounts.offset_value + index + 1 %></td>
84
+ <td><%= account.email %></td>
85
+ <td><code><%= account.id %></code></td>
86
+ <td><%= account.created_at&.strftime('%Y-%m-%d %H:%M') %></td>
87
+ <td><%= account.last_sign_in_at&.strftime('%Y-%m-%d %H:%M') || '—' %></td>
88
+ <td><%= account.current_sign_in_at&.strftime('%Y-%m-%d %H:%M') || '—' %></td>
89
+ </tr>
90
+ <% end %>
91
+ </tbody>
92
+ </table>
93
+ </div>
94
+
95
+ <%= paginate @export_accounts, theme: 'admin-twitter-bootstrap-4' %>
96
+ <% else %>
97
+ <div class="m-5 text-center no-objects-found">
98
+ <%= Spree.t('load_test.accounts.no_accounts_found') %>
99
+ </div>
100
+ <% end %>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+
107
+ <%# Always on the page (outside the .row/.col so its position:fixed is viewport-relative), hidden
108
+ until the JS below opens it. Populated over AJAX — see the fetch handler. %>
109
+ <%= render "login_tokens_modal" %>
110
+
111
+ <script>
112
+ (function() {
113
+ var i18n = {
114
+ generating: <%= Spree.t('load_test.accounts.generating').to_json.html_safe %>,
115
+ errorPrefix: <%= Spree.t('load_test.accounts.error_prefix').to_json.html_safe %>,
116
+ requestFailedTemplate: <%= Spree.t('load_test.accounts.request_failed', status: '%{status}').to_json.html_safe %>
117
+ };
118
+
119
+ var form = document.getElementById('login_tokens_form');
120
+ var drawer = document.getElementById('loginTokensModal');
121
+ var backdrop = document.getElementById('login_tokens_modal_backdrop');
122
+ var textarea = document.getElementById('login_tokens_json');
123
+ var status = document.getElementById('login_tokens_status');
124
+ if (!form || !drawer || !backdrop) return;
125
+
126
+ function openDrawer() {
127
+ drawer.classList.add('open');
128
+ backdrop.classList.add('open');
129
+ document.body.style.overflow = 'hidden';
130
+ }
131
+ function closeDrawer() {
132
+ drawer.classList.remove('open');
133
+ backdrop.classList.remove('open');
134
+ document.body.style.removeProperty('overflow');
135
+ }
136
+
137
+ backdrop.addEventListener('click', closeDrawer);
138
+ drawer.querySelectorAll('[data-dismiss="modal"]').forEach(function(btn) {
139
+ btn.addEventListener('click', closeDrawer);
140
+ });
141
+
142
+ form.addEventListener('submit', function(event) {
143
+ event.preventDefault();
144
+
145
+ // Open first, show a loading state — the drawer never waits on the request to appear.
146
+ openDrawer();
147
+ textarea.value = '';
148
+ status.textContent = i18n.generating;
149
+
150
+ var csrf = document.querySelector('meta[name="csrf-token"]');
151
+ fetch(form.action, {
152
+ method: 'POST',
153
+ credentials: 'same-origin',
154
+ headers: {
155
+ 'Accept': 'application/json',
156
+ 'X-CSRF-Token': csrf ? csrf.content : ''
157
+ },
158
+ body: new FormData(form)
159
+ })
160
+ .then(function(response) {
161
+ return response.json().then(function(data) {
162
+ if (!response.ok) throw new Error(data.error || i18n.requestFailedTemplate.replace('%{status}', response.status));
163
+ return data;
164
+ });
165
+ })
166
+ .then(function(data) {
167
+ textarea.value = JSON.stringify(data, null, 2);
168
+ status.textContent = '';
169
+ })
170
+ .catch(function(error) {
171
+ status.textContent = i18n.errorPrefix + error.message;
172
+ });
173
+ });
174
+ })();
175
+ </script>
@@ -0,0 +1,66 @@
1
+ <%= render 'spree/admin/shared/system_tabs', current: :load_testing %>
2
+
3
+ <%= link_to Spree.t('load_test.back_to_load_testing'), admin_system_load_testing_index_path, class: 'btn btn-outline-secondary btn-sm mb-3' %>
4
+
5
+ <p class="text-muted">
6
+ <%= Spree.t('load_test.flows.description_html').html_safe %>
7
+ </p>
8
+
9
+ <% @load_test_flows.each_with_index do |(flow_name, flow), index| %>
10
+ <details class="card mb-3" <%= 'open' if index.zero? %>>
11
+ <summary class="card-header d-flex justify-content-between align-items-center" style="cursor: pointer; list-style: none;">
12
+ <div>
13
+ <h6 class="mb-1"><%= flow_name.to_s.humanize %></h6>
14
+ <span class="text-muted small"><%= flow[:description] %></span>
15
+ </div>
16
+ <%= svg_icon name: 'chevron-down', width: 16, height: 16 %>
17
+ </summary>
18
+
19
+ <div class="card-body">
20
+ <div class="mb-3">
21
+ <div class="text-muted text-uppercase small mb-1" style="letter-spacing: .04em;"><%= Spree.t('load_test.flows.save_to') %></div>
22
+ <code><%= flow[:destination] %></code>
23
+ </div>
24
+
25
+ <% if flow[:data_button] %>
26
+ <div class="alert alert-primary d-flex mb-3" role="alert">
27
+ <%= svg_icon name: 'cursor-fill', width: 18, height: 18, classes: 'mr-2 flex-shrink-0' %>
28
+ <div>
29
+ <strong><%= Spree.t('load_test.flows.data_button_label', label: flow[:data_button][:label]) %></strong> — <%= flow[:data_button][:where] %>
30
+ <% if flow[:data_button][:note] %>
31
+ <div class="text-muted small mt-1"><%= flow[:data_button][:note] %></div>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+ <% end %>
36
+
37
+ <% if flow[:source] %>
38
+ <div class="mb-3">
39
+ <div class="text-muted text-uppercase small mb-2" style="letter-spacing: .04em;"><%= Spree.t('load_test.flows.look_up_manually') %></div>
40
+ <div class="table-responsive">
41
+ <table class="table table-sm mb-0">
42
+ <thead class="text-muted">
43
+ <tr>
44
+ <th><%= Spree.t('load_test.flows.field') %></th>
45
+ <th><%= Spree.t('load_test.flows.where_to_find_it') %></th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ <% flow[:source].each do |field, instructions| %>
50
+ <tr>
51
+ <td class="align-top text-nowrap"><code><%= field %></code></td>
52
+ <td class="text-muted"><%= instructions %></td>
53
+ </tr>
54
+ <% end %>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ </div>
59
+ <% end %>
60
+
61
+ <% if flow[:doc] %>
62
+ <p class="text-muted small mb-0"><%= Spree.t('load_test.flows.full_request_table_html', doc: flow[:doc]).html_safe %></p>
63
+ <% end %>
64
+ </div>
65
+ </details>
66
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <%= render 'spree/admin/shared/system_tabs', current: :load_testing %>
2
+
3
+ <div class="row">
4
+ <div class="col-md-12">
5
+ <div class="list-group">
6
+ <%= link_to admin_system_load_test_accounts_path,
7
+ class: 'list-group-item list-group-item-action d-flex justify-content-between align-items-center' do %>
8
+ <div>
9
+ <strong><%= Spree.t('load_test.index.manage_accounts') %></strong>
10
+ <div class="text-muted small"><%= Spree.t('load_test.index.manage_accounts_description') %></div>
11
+ </div>
12
+ <%= svg_icon name: 'chevron-right', width: 20, height: 20 %>
13
+ <% end %>
14
+
15
+ <%= link_to admin_system_load_test_flows_path,
16
+ class: 'list-group-item list-group-item-action d-flex justify-content-between align-items-center' do %>
17
+ <div>
18
+ <strong><%= Spree.t('load_test.index.test_data_sources') %></strong>
19
+ <div class="text-muted small"><%= Spree.t('load_test.index.test_data_sources_description') %></div>
20
+ </div>
21
+ <%= svg_icon name: 'chevron-right', width: 20, height: 20 %>
22
+ <% end %>
23
+ </div>
24
+ </div>
25
+ </div>
@@ -0,0 +1,49 @@
1
+ <%= render partial: 'spree/admin/shared/product_tabs', locals: { current: :variants } %>
2
+
3
+ <% content_for :page_title do %>
4
+ <%= Spree.t(:fixture, default: 'Fixture') %> &mdash; <%= @variant.options_text.presence || @variant.sku %>
5
+ <% end %>
6
+
7
+ <% filename = "#{@fixture_env}.json" %>
8
+
9
+ <div class="card mb-4">
10
+ <div class="card-body">
11
+ <% if @fixture_issues.blank? %>
12
+ <div class="alert alert-success" role="alert">
13
+ <strong>Ready for load testing.</strong> This variant is purchasable and sits under an event, so the
14
+ booking flow can run against it end-to-end.
15
+ </div>
16
+ <% else %>
17
+ <div class="alert alert-warning" role="alert">
18
+ <strong>Not ready for load testing.</strong> The booking flow will not complete against this variant until
19
+ the following is fixed:
20
+ <ul class="mb-0 mt-2">
21
+ <% @fixture_issues.each do |issue| %>
22
+ <li><%= issue %></li>
23
+ <% end %>
24
+ </ul>
25
+ </div>
26
+ <% end %>
27
+
28
+ <p class="text-muted">
29
+ Save this as <code><%= filename %></code> in
30
+ <code>load_testing/config/fixture/platform/booking_ticket_flow/</code>
31
+ (git-ignored).
32
+ </p>
33
+
34
+ <div class="position-relative" data-controller="clipboard">
35
+ <div class="position-absolute d-flex" style="top: .5rem; right: .5rem;">
36
+ <button type="button" class="btn btn-sm btn-primary"
37
+ data-action="clipboard#copy"
38
+ onclick="show_flash('success', '<%= j Spree.t(:copied_to_clipboard, default: 'Copied to clipboard') %>')">
39
+ <%= Spree.t(:copy, default: 'Copy') %>
40
+ </button>
41
+ </div>
42
+ <textarea readonly data-clipboard-target="source" rows="12"
43
+ class="form-control text-monospace bg-light"
44
+ style="resize: vertical;"><%= JSON.pretty_generate(@fixture) %></textarea>
45
+ </div>
46
+ </div>
47
+ </div>
48
+
49
+ <%= link_to Spree.t(:back), spree.admin_product_variants_path(@product), class: 'btn btn-outline-secondary' %>