spree_cm_commissioner 2.14.4 → 2.14.5

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +6 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +33 -0
  5. data/app/controllers/concerns/spree_cm_commissioner/waiting_room_authorization.rb +41 -8
  6. data/app/controllers/spree/admin/homepage_section_controller.rb +63 -5
  7. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +6 -2
  8. data/app/controllers/spree/api/v2/storefront/homepage_sections_controller.rb +1 -0
  9. data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -0
  10. data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +6 -2
  11. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +6 -2
  12. data/app/controllers/spree/api/v2/tenant/homepage_sections_controller.rb +1 -0
  13. data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -0
  14. data/app/models/active_storage/variation_decorator.rb +18 -0
  15. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +4 -0
  16. data/app/models/concerns/spree_cm_commissioner/order_holdable.rb +3 -2
  17. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +17 -0
  18. data/app/models/spree_cm_commissioner/homepage_section.rb +33 -0
  19. data/app/models/spree_cm_commissioner/homepage_section_app_background.rb +13 -0
  20. data/app/models/spree_cm_commissioner/homepage_section_web_background.rb +13 -0
  21. data/app/models/spree_cm_commissioner/product_decorator.rb +7 -0
  22. data/app/overrides/spree/admin/taxons/_form/enforce_waiting_room_session.html.erb.deface +10 -0
  23. data/app/serializers/spree/v2/storefront/homepage_section_serializer.rb +4 -1
  24. data/app/serializers/spree/v2/tenant/homepage_section_serializer.rb +3 -1
  25. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +21 -4
  26. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +1 -0
  27. data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +56 -5
  28. data/app/views/spree/admin/homepage_section/_form.html.erb +44 -0
  29. data/config/initializers/spree_permitted_attributes.rb +1 -0
  30. data/config/locales/dry_validations.en.yml +4 -0
  31. data/config/locales/en.yml +11 -0
  32. data/config/locales/km.yml +11 -0
  33. data/config/routes.rb +5 -0
  34. data/db/migrate/20261021000001_add_section_type_to_cm_homepage_sections.rb +6 -0
  35. data/lib/spree_cm_commissioner/version.rb +1 -1
  36. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5317bd75c6057165a07eae82d0e30f986371767925346783242d8d12a0a8d24
4
- data.tar.gz: b015f2bebfc1105dee65f7b4b22f63ee909b113541741f9dbc5ed48482ab4b04
3
+ metadata.gz: f7720d10f3459b8933df3d2521bdce4983e03adf800dc852cb4e6f28d689f146
4
+ data.tar.gz: 746c1c6e7cc864f5555020ab15d21eeac2c601d6f7d6b0bcfa1bff0b9633bb77
5
5
  SHA512:
6
- metadata.gz: 7c59a388a335fcffdbb53ef8ebd470bf9b310a169ac8057ba6b464c6cef8e4ee6425cccfa5bfda366ea9c7fa89aaca854465c914521df040d460ba39994bd0b3
7
- data.tar.gz: 0d8572c942886d56c1689ceb532e2d643f208d538dfb8fa40e21209198562dbe52fb7bb8a28f7573d0900407b8651421210eb4b99c6ea80aefa14b73677880c4
6
+ metadata.gz: 504fa1a8ea3a50979c1c23e366713e6e6b10bfe38efb4e3a4019b3f5f8cefa35ec460fd8a8b013cd1324c4ead1359fdbc0b00a7c0b56e71bd606c294cf180c6c
7
+ data.tar.gz: 5d1d182cbd5f0538d5d281c1c401b7724fe1bc27e0f3c5009345493a8335aa6bae62ed9dea22af2fc52b07ac8bfc1e617953851cbcaf7c5823773536ef4651ae
data/.env.example CHANGED
@@ -54,6 +54,12 @@ CACHE_SERIALIZER_DISABLED="" # Set to yes to turn off Spree's serializer record
54
54
  CACHE_STATIC_MAX_AGE=86400 # Static content (CMS pages) - 1 day
55
55
  CACHE_REALTIME_MAX_AGE=300 # Everything else listed (homepage, menus, products, events, vendors, trips, seat layouts, countries, provinces, guest card classes) - 5 minutes
56
56
 
57
+ # Shared secret(s) CloudFront sends to the origin as the X-Origin-Verify custom header. When set, listed CDN-cacheable
58
+ # APIs return 403 to requests without a matching header. Comma-separate two values while rotating. Leave blank to turn off.
59
+ # Set only after every client reaches these APIs through CloudFront, or they get 403.
60
+ CDN_ORIGIN_SECRETS=""
61
+ CDN_ORIGIN_REPORT_ONLY="" # Set to yes to log unverified requests instead of refusing them (use before enforcing)
62
+
57
63
  # Batch size for processing maintenance tasks in app/jobs/spree_cm_commissioner/maintenance_tasks/orchestrate_job.rb
58
64
  MAINTENANCE_TASKS_BATCH_SIZE=100
59
65
 
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.14.4)
37
+ spree_cm_commissioner (2.14.5)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -5,9 +5,14 @@ module SpreeCmCommissioner
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
+ # Prepended so a request that skipped the CDN is refused before any other callback hits the DB.
9
+ prepend_before_action :verify_cdn_origin!, if: :cdn_cacheable?
8
10
  after_action :set_cache_control_for_cdn
9
11
  end
10
12
 
13
+ # CloudFront sends this as an origin custom header; clients never see or send it.
14
+ CDN_ORIGIN_HEADER = 'X-Origin-Verify'.freeze
15
+
11
16
  # Every CDN-cached API, by full class name, with its GET actions and TTL tier. Anything not
12
17
  # listed keeps Rails' default `max-age=0, private, must-revalidate` and never reaches the CDN.
13
18
  #
@@ -92,6 +97,34 @@ module SpreeCmCommissioner
92
97
  response.headers['Cache-Control'] = "public, max-age=0, s-maxage=#{shared_max_age}"
93
98
  end
94
99
 
100
+ # Refuses a listed action when it did not come through CloudFront, so the CDN can't be skipped by
101
+ # calling the origin directly. Off while CDN_ORIGIN_SECRETS is unset. It takes a comma-separated
102
+ # list so a new secret can be added to CloudFront before the old one is dropped. With
103
+ # CDN_ORIGIN_REPORT_ONLY=yes it only logs, to find clients still calling the origin before enforcing.
104
+ def verify_cdn_origin!
105
+ secrets = ENV.fetch('CDN_ORIGIN_SECRETS', '').split(',').map(&:strip).compact_blank
106
+ return if secrets.empty?
107
+
108
+ provided = request.headers[CDN_ORIGIN_HEADER].to_s
109
+ return if secrets.any? { |secret| ActiveSupport::SecurityUtils.secure_compare(provided, secret) }
110
+
111
+ if ENV['CDN_ORIGIN_REPORT_ONLY'] == 'yes'
112
+ CmAppLogger.log(
113
+ label: 'cdn_origin_unverified',
114
+ data: {
115
+ controller: self.class.name,
116
+ action: action_name,
117
+ path: request.path,
118
+ header_present: provided.present?,
119
+ via_cloudfront: request.headers['X-Amz-Cf-Id'].present?
120
+ }
121
+ )
122
+ return
123
+ end
124
+
125
+ head :forbidden
126
+ end
127
+
95
128
  private
96
129
 
97
130
  def cdn_cache_config
@@ -7,11 +7,30 @@ module SpreeCmCommissioner
7
7
  end
8
8
 
9
9
  def ensure_waiting_room_session_token
10
- return if ENV['WAITING_ROOM_DISABLED'] == 'yes'
11
- return if request_from_client?
10
+ return if waiting_room_disabled?
12
11
  return unless required_waiting_room_session?
12
+ return if active_waiting_room_session?
13
13
 
14
- return if SpreeCmCommissioner::WaitingRoomSession.active.exists?(token: params[:waiting_room_session_token])
14
+ handle_waiting_room_session_token_error
15
+ end
16
+
17
+ # Product-based counterpart to the path-based check above, for draft-order endpoints: gating by
18
+ # path would hold every booking (hotel, transit, ...) behind the waiting room, while this only
19
+ # holds orders for products that opt in via Product#enforce_waiting_room_session?. Call it as a
20
+ # before_action on #create, next to ensure_anonymous_booking_allowed!.
21
+ #
22
+ # Products covered by the request's private sale link (`st`) are exempt: that buyer draws from a
23
+ # pool locked for them, not the public stock the queue is rationing. The exemption is per
24
+ # request, not per product — the same product is usually on public sale too.
25
+ #
26
+ # products: [Array<Spree::Product>] every distinct product referenced by this request.
27
+ def ensure_waiting_room_session!(products)
28
+ return if waiting_room_disabled?
29
+
30
+ private_sale_product_ids = waiting_room_private_sale&.product_ids || []
31
+ enforced = products.select(&:enforce_waiting_room_session?).reject { |product| private_sale_product_ids.include?(product.id) }
32
+ return if enforced.empty?
33
+ return if active_waiting_room_session?
15
34
 
16
35
  handle_waiting_room_session_token_error
17
36
  end
@@ -23,13 +42,27 @@ module SpreeCmCommissioner
23
42
  apis.any? { |prefix| request.path.start_with?(prefix) }
24
43
  end
25
44
 
26
- # temporary disable session check from any client server requests.
27
- def request_from_client?
28
- request.headers['X-Cm-Api-Client-Version'].present?
29
- end
30
-
31
45
  def handle_waiting_room_session_token_error
32
46
  render_error_payload(Spree.t(:invalid_session_token), 400)
33
47
  end
48
+
49
+ private
50
+
51
+ def waiting_room_disabled?
52
+ ENV['WAITING_ROOM_DISABLED'] == 'yes'
53
+ end
54
+
55
+ def active_waiting_room_session?
56
+ SpreeCmCommissioner::WaitingRoomSession.active.exists?(token: params[:waiting_room_session_token])
57
+ end
58
+
59
+ # Verified, not just present: a made-up `st` must not skip the queue, so a token that fails
60
+ # Authorize simply earns no exemption.
61
+ def waiting_room_private_sale
62
+ return if params[:st].blank?
63
+
64
+ result = SpreeCmCommissioner::PrivateSales::Authorize.call(token: params[:st], user: spree_current_user)
65
+ result.value if result.success?
66
+ end
34
67
  end
35
68
  end
@@ -4,6 +4,8 @@ module Spree
4
4
  helper SpreeCmCommissioner::Admin::HomepageSegmentHelper
5
5
 
6
6
  before_action :load_tenants, only: [:index]
7
+ before_action :reject_invalid_section_type, only: %i[create update]
8
+ before_action :build_assets, only: %i[create update]
7
9
 
8
10
  helper_method :selected_tenant
9
11
 
@@ -39,8 +41,62 @@ module Spree
39
41
  end
40
42
  end
41
43
 
44
+ def remove_app_background
45
+ remove_asset(@object.app_background)
46
+ end
47
+
48
+ def remove_web_background
49
+ remove_asset(@object.web_background)
50
+ end
51
+
42
52
  private
43
53
 
54
+ # Always redirects back to `edit` rather than re-rendering it on failure (unlike the
55
+ # sibling GuestCardClassesController#remove_asset) — this controller's `edit` action sets
56
+ # extra ivars (`@homepage_section_relatables`) that this action never loads, so rendering
57
+ # the template directly here would raise.
58
+ def remove_asset(asset)
59
+ if asset&.destroy
60
+ flash[:success] = Spree.t('notice_messages.icon_removed')
61
+ else
62
+ flash[:error] = Spree.t('errors.messages.cannot_remove_icon')
63
+ end
64
+
65
+ redirect_to edit_admin_homepage_feed_homepage_section_url(@object)
66
+ end
67
+
68
+ # Only `trending` sections use these assets; building them for another type would let a
69
+ # direct API call attach a background the form never shows and the model then has to
70
+ # discard on save (see HomepageSection#clear_non_trending_fields). Either way the raw
71
+ # upload must be pulled out of permitted_resource_params — left in place, it gets assigned
72
+ # straight to the has_one association by update_resource/create_resource, which raises
73
+ # ActiveRecord::AssociationTypeMismatch instead of being silently ignored.
74
+ def build_assets
75
+ unless trending_section?
76
+ permitted_resource_params.delete(:app_background)
77
+ permitted_resource_params.delete(:web_background)
78
+ return
79
+ end
80
+
81
+ @object.build_app_background(attachment: permitted_resource_params.delete(:app_background)) if permitted_resource_params[:app_background]
82
+ @object.build_web_background(attachment: permitted_resource_params.delete(:web_background)) if permitted_resource_params[:web_background]
83
+ end
84
+
85
+ def trending_section?
86
+ (permitted_resource_params[:section_type].presence || @object.section_type).to_s == 'trending'
87
+ end
88
+
89
+ # `enum section_type:` raises ArgumentError on assignment of an unrecognized value, which
90
+ # would otherwise 500 instead of a normal validation failure — strip it here and flash
91
+ # instead, before it ever reaches create_resource/update_resource's mass-assignment.
92
+ def reject_invalid_section_type
93
+ submitted = permitted_resource_params[:section_type]
94
+ return if submitted.blank? || SpreeCmCommissioner::HomepageSection.section_types.key?(submitted.to_s)
95
+
96
+ permitted_resource_params.delete(:section_type)
97
+ flash[:error] = Spree.t('errors.messages.invalid_section_type')
98
+ end
99
+
44
100
  def collection
45
101
  return @collection if defined?(@collection)
46
102
 
@@ -57,16 +113,18 @@ module Spree
57
113
  @collection = scoped.page(params[:page]).per(params[:per_page])
58
114
  end
59
115
 
60
- # overrided
116
+ # overrided — memoized so `.delete()` in build_assets mutates the same hash instance
61
117
  def permitted_resource_params
118
+ return @permitted_resource_params if defined?(@permitted_resource_params)
119
+
62
120
  object_params = params[:spree_cm_commissioner_homepage_section]
63
121
  segment_value = helpers.calculate_segment_value(object_params)
64
122
  mini_app_value = helpers.calculate_mini_app_value(object_params)
65
123
 
66
- params
67
- .require(:spree_cm_commissioner_homepage_section)
68
- .permit(:title, :description, :active, :preview)
69
- .merge(segment: segment_value, mini_app: mini_app_value)
124
+ @permitted_resource_params = params
125
+ .require(:spree_cm_commissioner_homepage_section)
126
+ .permit(:title, :description, :active, :preview, :section_type, :font, :app_background, :web_background)
127
+ .merge(segment: segment_value, mini_app: mini_app_value)
70
128
  end
71
129
 
72
130
  # The storefront being viewed; no tenant in the URL means BookMe+. Also gates the mini app
@@ -8,6 +8,7 @@ module Spree
8
8
  include SpreeCmCommissioner::AnonymousBookingGuard
9
9
 
10
10
  before_action -> { ensure_anonymous_booking_allowed!(draft_order_products) }, only: :create
11
+ before_action -> { ensure_waiting_room_session!(draft_order_products) }, only: :create
11
12
 
12
13
  # A top-up credits a wallet, which must belong to a real user -- unlike #create,
13
14
  # this action has no guest-checkout path.
@@ -124,9 +125,12 @@ module Spree
124
125
  ).fetch(:line_items, [])
125
126
  end
126
127
 
128
+ # Memoized: both the anonymous-booking and waiting-room guards read it on the same request.
127
129
  def draft_order_products
128
- variant_ids = draft_order_line_items.pluck(:variant_id).compact
129
- Spree::Variant.where(id: variant_ids).includes(:product).map(&:product)
130
+ @draft_order_products ||= begin
131
+ variant_ids = draft_order_line_items.pluck(:variant_id).compact
132
+ Spree::Variant.where(id: variant_ids).includes(product: :event).map(&:product)
133
+ end
130
134
  end
131
135
 
132
136
  # override
@@ -23,6 +23,7 @@ module Spree
23
23
  .where(preview: false)
24
24
  .for_feed(params[:homepage_id] || :general, params[:mini_app])
25
25
  .order(position: :asc)
26
+ .includes(:app_background, :web_background)
26
27
  end
27
28
  end
28
29
  end
@@ -13,6 +13,7 @@ module Spree
13
13
  .where(preview: true)
14
14
  .where(id: SpreeCmCommissioner::HomepageSectionRelatable.section_ids_previewable_by(spree_current_user))
15
15
  .order(position: :asc)
16
+ .includes(:app_background, :web_background)
16
17
  end
17
18
  end
18
19
  end
@@ -9,6 +9,7 @@ module Spree
9
9
  include SpreeCmCommissioner::AnonymousBookingGuard
10
10
 
11
11
  before_action -> { ensure_anonymous_booking_allowed!(draft_order_products) }, only: :create
12
+ before_action -> { ensure_waiting_room_session!(draft_order_products) }, only: :create
12
13
 
13
14
  # Accepts a draft order request without building it: writes a queued Firestore status
14
15
  # document and schedules the real build (SpreeCmCommissioner::DraftOrders::Create) on a
@@ -59,9 +60,12 @@ module Spree
59
60
  .fetch(:line_items, [])
60
61
  end
61
62
 
63
+ # Memoized: both the anonymous-booking and waiting-room guards read it on the same request.
62
64
  def draft_order_products
63
- variant_ids = draft_order_line_items.pluck(:variant_id).compact
64
- Spree::Variant.where(id: variant_ids).includes(:product).map(&:product)
65
+ @draft_order_products ||= begin
66
+ variant_ids = draft_order_line_items.pluck(:variant_id).compact
67
+ Spree::Variant.where(id: variant_ids).includes(product: :event).map(&:product)
68
+ end
65
69
  end
66
70
  end
67
71
  end
@@ -6,6 +6,7 @@ module Spree
6
6
  include SpreeCmCommissioner::AnonymousBookingGuard
7
7
 
8
8
  before_action -> { ensure_anonymous_booking_allowed!(draft_order_products) }, only: :create
9
+ before_action -> { ensure_waiting_room_session!(draft_order_products) }, only: :create
9
10
 
10
11
  # A top-up credits a wallet, which must belong to a real user -- unlike #create,
11
12
  # this action has no guest-checkout path.
@@ -90,9 +91,12 @@ module Spree
90
91
  .fetch(:line_items, [])
91
92
  end
92
93
 
94
+ # Memoized: both the anonymous-booking and waiting-room guards read it on the same request.
93
95
  def draft_order_products
94
- variant_ids = draft_order_line_items.pluck(:variant_id).compact
95
- Spree::Variant.where(id: variant_ids).includes(:product).map(&:product)
96
+ @draft_order_products ||= begin
97
+ variant_ids = draft_order_line_items.pluck(:variant_id).compact
98
+ Spree::Variant.where(id: variant_ids).includes(product: :event).map(&:product)
99
+ end
96
100
  end
97
101
 
98
102
  # override
@@ -8,6 +8,7 @@ module Spree
8
8
  .where(preview: false)
9
9
  .active
10
10
  .order(position: :asc)
11
+ .includes(:app_background, :web_background)
11
12
  end
12
13
 
13
14
  def scope
@@ -12,6 +12,7 @@ module Spree
12
12
  .where(preview: true)
13
13
  .where(id: SpreeCmCommissioner::HomepageSectionRelatable.section_ids_previewable_by(spree_current_user))
14
14
  .order(position: :asc)
15
+ .includes(:app_background, :web_background)
15
16
  end
16
17
  end
17
18
  end
@@ -0,0 +1,18 @@
1
+ module ActiveStorage
2
+ module VariationDecorator
3
+ # ActiveStorage reuses a JPEG upload's own extension as the variant format, but libvips
4
+ # picks its encoder from the output extension and rejects aliases like `.jfif`
5
+ # ("is not a known file format"). Mapping here rather than renaming blobs also fixes
6
+ # variant URLs already handed out, since their signed key embeds `format: "jfif"`.
7
+ JPEG_FORMAT_ALIASES = %w[jfif jpe].freeze
8
+
9
+ def format
10
+ format = super
11
+ JPEG_FORMAT_ALIASES.include?(format.to_s.downcase) ? 'jpg' : format
12
+ end
13
+ end
14
+ end
15
+
16
+ unless ActiveStorage::Variation.ancestors.include?(ActiveStorage::VariationDecorator)
17
+ ActiveStorage::Variation.prepend(ActiveStorage::VariationDecorator)
18
+ end
@@ -18,6 +18,10 @@ module SpreeCmCommissioner
18
18
  # For certain large events, admin can disable this manually.
19
19
  store_public_metadata :allow_manual_search_for_operator, :boolean, default: true
20
20
 
21
+ # Requires an active waiting-room session to create a draft order for this event's products
22
+ # (Product#enforce_waiting_room_session?). Server-only switch, so private.
23
+ store_private_metadata :enforce_waiting_room_session, :boolean, default: false
24
+
21
25
  # ⚠️ CRITICAL: Cached seat layout IDs kept in sync by SeatLayout callback.
22
26
  # If callback is removed/modified without updating this, availability checks will fail.
23
27
  #
@@ -4,8 +4,9 @@ module SpreeCmCommissioner
4
4
 
5
5
  included do
6
6
  # Store the earliest expiration time across all holds (seats + inventory)
7
- # in `public_metadata[:hold_expires_at]`. This is used only for UI display
8
- # (e.g., a countdown timer) and does not control the release logic.
7
+ # in `public_metadata[:hold_expires_at]`. This is used for UI display
8
+ # (e.g., a countdown timer) and by LineItems::ValidatePurchaseLimits to count an
9
+ # unpaid cart against the buyer's caps; it does not control the release logic.
9
10
  # Each hold is released independently when its own expiration time is reached.
10
11
  store_public_metadata :hold_expires_at, :datetime
11
12
 
@@ -7,6 +7,7 @@ module SpreeCmCommissioner
7
7
  included do # rubocop:disable Metrics/BlockLength
8
8
  after_commit :clear_has_incomplete_guest_info_cache, on: %i[create update]
9
9
 
10
+ state_machine.before_transition to: :address, do: :ensure_within_purchase_limits
10
11
  state_machine.before_transition to: :address, do: :hold_order_holds
11
12
 
12
13
  # Same transitions core calls create_tax_charge! from (gems/core/app/models/spree/order/checkout.rb,
@@ -107,6 +108,22 @@ module SpreeCmCommissioner
107
108
  end
108
109
  end
109
110
 
111
+ # Line items are only validated when they change, so a cart sent back to `cart` (hold
112
+ # released) and advanced again would skip the purchase-limit check and could be held next to
113
+ # another cart the buyer opened meanwhile. Runs before hold_order_holds so a rejection never
114
+ # leaves a hold behind.
115
+ def ensure_within_purchase_limits
116
+ line_items.each do |line_item|
117
+ result = SpreeCmCommissioner::LineItems::ValidatePurchaseLimits.call(line_item: line_item)
118
+ next if result.success?
119
+
120
+ errors.add(:base, result.error.to_s)
121
+ return false
122
+ end
123
+
124
+ true
125
+ end
126
+
110
127
  # Best-effort, like apply_prepaid_payment!'s other callers: a user with no (or no
111
128
  # currency-matching) store credit just doesn't get a payment here, and the app can still
112
129
  # remove/reapply it afterwards through the existing checkout store-credit endpoints.
@@ -10,10 +10,26 @@ module SpreeCmCommissioner
10
10
  attribute :segment, :integer, default: 0
11
11
  attribute :mini_app, :integer, default: 0
12
12
 
13
+ enum section_type: { default: 0, trending: 1, big_banner: 2 }
14
+
13
15
  self.whitelisted_ransackable_attributes = %w[title active]
14
16
 
15
17
  has_many :homepage_section_relatables, -> { active }, inverse_of: :homepage_section, dependent: :destroy
16
18
 
19
+ has_one :app_background, as: :viewable, dependent: :destroy, class_name: 'SpreeCmCommissioner::HomepageSectionAppBackground'
20
+ has_one :web_background, as: :viewable, dependent: :destroy, class_name: 'SpreeCmCommissioner::HomepageSectionWebBackground'
21
+
22
+ # `font`/backgrounds only apply to `trending` sections. The admin form only hides those
23
+ # fields via JS when another type is picked — it doesn't clear them — and a direct API call
24
+ # could set them on any type, so normalize here rather than relying on the form. A hard
25
+ # validation would instead reject the common "switch an existing trending section back to
26
+ # default" edit, since the hidden `font` field still submits its stale value.
27
+ #
28
+ # Guarded beyond `unless: :trending?` so a routine save of an already-default/big_banner
29
+ # section (the steady-state case) skips the `app_background`/`web_background` association
30
+ # loads entirely instead of querying them on every save forever.
31
+ before_save :clear_non_trending_fields, if: :clear_non_trending_fields?
32
+
17
33
  scope :active, lambda {
18
34
  where(active: true)
19
35
  .joins(:homepage_section_relatables)
@@ -47,5 +63,22 @@ module SpreeCmCommissioner
47
63
 
48
64
  # Create maintaining task to purge homepage section related caches
49
65
  after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
66
+
67
+ private
68
+
69
+ # `.target` (not `.loaded?`) so this only fires for a background actually built/loaded in
70
+ # memory on *this* save. `.loaded?` stays true forever once an object has been saved once
71
+ # with the association touched, even after the target is nil'd out by a prior destroy —
72
+ # which made every later save of that same in-memory record re-run the clear unnecessarily.
73
+ def clear_non_trending_fields?
74
+ !trending? && (font.present? || section_type_changed? ||
75
+ association(:app_background).target.present? || association(:web_background).target.present?)
76
+ end
77
+
78
+ def clear_non_trending_fields
79
+ self.font = nil
80
+ app_background&.destroy
81
+ web_background&.destroy
82
+ end
50
83
  end
51
84
  end
@@ -0,0 +1,13 @@
1
+ module SpreeCmCommissioner
2
+ class HomepageSectionAppBackground < Asset
3
+ # 3:2 — taller than 16:9 so the image fills a `trending` card's full height instead of
4
+ # letterboxing. `medium` (960x640) is the upload target; `mini`/`small` are downscaled copies.
5
+ def asset_styles
6
+ {
7
+ mini: '160x107>',
8
+ small: '480x320>',
9
+ medium: '960x640>'
10
+ }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module SpreeCmCommissioner
2
+ class HomepageSectionWebBackground < Asset
3
+ # 5:1 — wide and short so the image spans a `trending` banner's full width without pushing
4
+ # page content down. `medium` (1440x288) is the upload target; `mini`/`small` are downscaled copies.
5
+ def asset_styles
6
+ {
7
+ mini: '160x32>',
8
+ small: '720x144>',
9
+ medium: '1440x288>'
10
+ }
11
+ end
12
+ end
13
+ end
@@ -222,6 +222,13 @@ module SpreeCmCommissioner
222
222
  end
223
223
  end
224
224
 
225
+ # Configured once on the event rather than per ticket product. Other product types (trips,
226
+ # rooms, ...) that need it later resolve their own source here; the draft-order guard
227
+ # (WaitingRoomAuthorization#ensure_waiting_room_session!) only ever asks the product.
228
+ def enforce_waiting_room_session?
229
+ event&.enforce_waiting_room_session? || false
230
+ end
231
+
225
232
  # Not yet on sale. Kept separate from available? so a scheduled product's real stock still
226
233
  # shows via in_stock? — Stock::AvailabilityValidatorDecorator checks this to block add-to-cart.
227
234
  def scheduled?
@@ -0,0 +1,10 @@
1
+ <!-- insert_before "erb[loud]:contains('field_container :hide_from_nav')" -->
2
+
3
+ <%= f.field_container :enforce_waiting_room_session, class: ['custom-control', 'custom-checkbox', 'my-4'] do %>
4
+ <%= f.check_box :enforce_waiting_room_session, class: 'custom-control-input' %>
5
+ <%= f.label :enforce_waiting_room_session, Spree.t(:enforce_waiting_room_session), class: 'custom-control-label' %>
6
+ <%= f.error_message_on :enforce_waiting_room_session %>
7
+ <small class="form-text text-muted">
8
+ <%= Spree.t(:enforce_waiting_room_session_hint) %>
9
+ </small>
10
+ <% end if @taxon.event? && @taxon.depth == 1 %> %>
@@ -2,8 +2,11 @@ module Spree
2
2
  module V2
3
3
  module Storefront
4
4
  class HomepageSectionSerializer < BaseSerializer
5
- attributes :id, :title, :segments, :mini_apps, :description, :active, :position, :preview, :created_at, :updated_at
5
+ attributes :id, :title, :segments, :mini_apps, :description, :active, :position, :preview,
6
+ :section_type, :font, :created_at, :updated_at
6
7
  has_many :homepage_section_relatables
8
+ has_one :app_background, serializer: ::SpreeCmCommissioner::V2::Storefront::AssetSerializer
9
+ has_one :web_background, serializer: ::SpreeCmCommissioner::V2::Storefront::AssetSerializer
7
10
  end
8
11
  end
9
12
  end
@@ -3,9 +3,11 @@ module Spree
3
3
  module Tenant
4
4
  class HomepageSectionSerializer < BaseSerializer
5
5
  attributes :id, :title, :segments, :description,
6
- :active, :position, :preview, :created_at, :updated_at
6
+ :active, :position, :preview, :section_type, :font, :created_at, :updated_at
7
7
 
8
8
  has_many :homepage_section_relatables, serializer: ::Spree::V2::Tenant::HomepageSectionRelatableSerializer
9
+ has_one :app_background, serializer: ::Spree::V2::Tenant::AssetSerializer
10
+ has_one :web_background, serializer: ::Spree::V2::Tenant::AssetSerializer
9
11
  end
10
12
  end
11
13
  end
@@ -38,6 +38,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
38
38
  stop_times = trip_mapping.external_payload&.dig('stop_times')
39
39
  return [] unless stop_times.is_a?(Array)
40
40
 
41
+ previous_time = nil
42
+
41
43
  synced_mappings = stop_times.each_with_index.map do |stop_time_data, index|
42
44
  # BookMeBus's stop_times[i]['arrival_time'] is the time the trip arrives at the NEXT
43
45
  # physical stop (i+1), not at stop i itself - it's 0/absent on the last entry, which has
@@ -50,7 +52,15 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
50
52
  # missing' on every booking attempt - never caught by specs because their fixtures set
51
53
  # arrival_time == departure_time on the same entry, sidestepping the real shape.
52
54
  incoming_arrival_time = index.positive? ? stop_times[index - 1]['arrival_time'] : nil
53
- sync_item!(trip_mapping, stop_time_data, incoming_arrival_time, index + 1, stop_times.size)
55
+
56
+ # BookMeBus sends time-of-day only, so an overnight trip (23:00 -> 05:00) needs its days rolled
57
+ # here: TransitOrder::Create trusts the stored datetimes and raises when arrival < departure.
58
+ arrival_time = roll_forward(parse_time(incoming_arrival_time), previous_time)
59
+ departure_time = roll_forward(parse_time(stop_time_data['departure_time']), arrival_time || previous_time)
60
+ previous_time = departure_time || arrival_time || previous_time
61
+
62
+ times = { arrival_time: arrival_time, departure_time: departure_time }
63
+ sync_item!(trip_mapping, stop_time_data, times, index + 1, stop_times.size)
54
64
  end.compact
55
65
 
56
66
  cleanup_stale_mappings!(trip_mapping, synced_mappings)
@@ -82,7 +92,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
82
92
  end
83
93
 
84
94
  # Sync a single trip stop
85
- def sync_item!(trip_mapping, stop_time_data, incoming_arrival_time, sequence, total_stops)
95
+ def sync_item!(trip_mapping, stop_time_data, times, sequence, total_stops)
86
96
  stop_data = stop_time_data['stop']
87
97
  location_data = stop_time_data['location']
88
98
  return nil unless stop_data.is_a?(Hash) && location_data.is_a?(Hash)
@@ -119,8 +129,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
119
129
  # BookMeBus returns one entry per search.
120
130
  allow_boarding: sequence == 1,
121
131
  allow_drop_off: sequence == total_stops,
122
- departure_time: parse_time(stop_time_data['departure_time']),
123
- arrival_time: parse_time(incoming_arrival_time)
132
+ departure_time: times[:departure_time],
133
+ arrival_time: times[:arrival_time]
124
134
  )
125
135
  tracker.save_if_changed!(trip_stop)
126
136
  end
@@ -206,6 +216,13 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
206
216
  mapping&.internal
207
217
  end
208
218
 
219
+ def roll_forward(time, not_before)
220
+ return time if time.nil? || not_before.nil?
221
+
222
+ time += 1.day while time < not_before
223
+ time
224
+ end
225
+
209
226
  def parse_time(seconds_from_midnight)
210
227
  return nil unless seconds_from_midnight.present? && seconds_from_midnight.to_i.positive?
211
228
 
@@ -762,6 +762,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
762
762
  def map_route_type(route_type)
763
763
  case route_type&.to_s&.downcase
764
764
  when 'ferry' then :ferry
765
+ when 'private_taxi' then :intercity_taxi
765
766
  else :bus
766
767
  end
767
768
  end
@@ -1,6 +1,7 @@
1
1
  # Per-product purchase caps, one independent check per scope. The order cap looks at a single
2
- # order; account/ip/device aggregate over the buyer's completed orders, so a customer can't
3
- # bypass a cap by splitting the purchase across carts.
2
+ # order; account/ip/device aggregate over the buyer's completed orders and their other carts
3
+ # still under an unexpired hold, so a customer can't bypass a cap by splitting the purchase
4
+ # across carts - including carts opened while an earlier one is still unpaid.
4
5
  #
5
6
  # A cap of nil or 0 is off. A scope with no value on the order (guest order, no IP, no device
6
7
  # id) has nothing to count the buyer against, so its cap does not apply.
@@ -16,6 +17,32 @@ module SpreeCmCommissioner
16
17
 
17
18
  COUNTED_STATES = %w[complete resumed].freeze
18
19
 
20
+ # An unpaid cart counts until its hold expires. Without a hold (a product without inventory
21
+ # holds) it counts while still in checkout - not sent back to `cart`, not archived - for one
22
+ # hold duration after its last update; entering checkout or paying bumps updated_at. A
23
+ # released hold clears hold_expires_at and sends the cart back to `cart`, so it stops
24
+ # counting at once. A `cart` order can't be paid without re-entering `address`, which runs
25
+ # this check again (OrderStateMachine#ensure_within_purchase_limits).
26
+ # Spree's metadata serializer stores public_metadata as a JSON string inside the jsonb
27
+ # column, so `->>` on it is always NULL; `#>> '{}'` unwraps it (or an object) back to jsonb.
28
+ COUNTED_ORDERS_SQL = <<~SQL.squish.freeze
29
+ (spree_orders.completed_at IS NOT NULL AND spree_orders.state IN (:states))
30
+ OR (
31
+ spree_orders.completed_at IS NULL
32
+ AND COALESCE(
33
+ ((spree_orders.public_metadata #>> '{}')::jsonb->>'hold_expires_at')::timestamptz,
34
+ CASE WHEN spree_orders.state <> 'cart' AND spree_orders.archived_at IS NULL
35
+ THEN spree_orders.updated_at + make_interval(secs => :hold_seconds) END
36
+ ) > :now
37
+ )
38
+ SQL
39
+
40
+ AGGREGATE_SCOPES = {
41
+ account: %i[max_quantity_per_account user_id],
42
+ device: %i[max_quantity_per_device device_id],
43
+ ip: %i[max_quantity_per_ip last_ip_address]
44
+ }.freeze
45
+
19
46
  def call(line_item:)
20
47
  @line_item = line_item
21
48
  @product = line_item.product
@@ -23,6 +50,8 @@ module SpreeCmCommissioner
23
50
 
24
51
  return success(nil) if @product.blank?
25
52
 
53
+ lock_buyer!
54
+
26
55
  check_order_limit!
27
56
  check_account_limit!
28
57
  check_ip_limit!
@@ -35,6 +64,22 @@ module SpreeCmCommissioner
35
64
 
36
65
  private
37
66
 
67
+ # Two carts for the same buyer saved at the same moment can't see each other's uncommitted
68
+ # rows, so both would pass. A transaction-level lock per capped identity makes the second
69
+ # wait until the first commits - in DraftOrders::Create that is after the hold sets
70
+ # hold_expires_at, so the second then counts it. Only capped identities are locked, so
71
+ # unrelated buyers never wait; outside a transaction (a bare valid?) the lock is released
72
+ # at once, which is harmless.
73
+ def lock_buyer!
74
+ AGGREGATE_SCOPES.each do |scope, (cap_attribute, identity_column)|
75
+ identity = @order&.public_send(identity_column)
76
+ next unless @product.public_send(cap_attribute).to_i.positive? && identity.present?
77
+
78
+ key = Zlib.crc32("spree_cm_commissioner:purchase_limit:#{scope}:#{identity}")
79
+ Spree::Order.connection.execute("SELECT pg_advisory_xact_lock(#{key})")
80
+ end
81
+ end
82
+
38
83
  def check_order_limit!
39
84
  limit = @product.max_quantity_per_order.to_i
40
85
  return unless limit.positive?
@@ -102,18 +147,24 @@ module SpreeCmCommissioner
102
147
  end
103
148
  end
104
149
 
105
- # What the buyer already holds for this product, outside the order being validated.
150
+ # What the buyer already holds for this product, outside the order being validated:
151
+ # completed orders, plus unpaid carts still inside their hold (OrderHolds::Hold, kept
152
+ # alive through payment by OrderHolds::LockForPayment), see COUNTED_ORDERS_SQL. An
153
+ # abandoned cart stops counting on its own, so no cleanup is needed.
106
154
  # product_id is not a column on spree_line_items - it is `delegate :product_id, to:
107
155
  # :variant` - so the product filter joins through spree_variants, indexed on product_id.
108
156
  def purchased_by(scope)
109
157
  Spree::LineItem.joins(:order, :variant)
110
- .where(spree_orders: { state: COUNTED_STATES })
111
158
  .where(spree_orders: scope)
112
159
  .where(spree_variants: { product_id: @product.id })
113
- .where.not(spree_orders: { completed_at: nil })
114
160
  .where.not(spree_orders: { id: @line_item.order_id })
161
+ .where(COUNTED_ORDERS_SQL, states: COUNTED_STATES, now: Time.current, hold_seconds: hold_seconds)
115
162
  .sum(:quantity)
116
163
  end
164
+
165
+ def hold_seconds
166
+ SpreeCmCommissioner::OrderHolds::Hold::HOLD_DURATION.to_i
167
+ end
117
168
  end
118
169
  end
119
170
  end
@@ -8,6 +8,32 @@
8
8
  <%= f.label :title %>
9
9
  <%= f.text_field :title, class: 'form-control' %>
10
10
  <% end %>
11
+ <%= f.field_container :section_type do %>
12
+ <%= f.label :section_type, Spree.t(:section_type) %>
13
+ <%= f.select :section_type,
14
+ SpreeCmCommissioner::HomepageSection.section_types.keys.map { |key| [Spree.t("section_types.#{key}"), key] },
15
+ {}, class: 'fullwidth select2' %>
16
+ <% end %>
17
+ <div id="homepage_section_trending_fields">
18
+ <%= f.field_container :font do %>
19
+ <%= f.label :font, Spree.t(:font) %>
20
+ <%= f.text_field :font, class: 'form-control' %>
21
+ <% end %>
22
+ <%= render 'shared/asset_field',
23
+ field: :app_background,
24
+ label: Spree.t(:app_background),
25
+ asset: f.object.app_background,
26
+ remove_url: (f.object.app_background.present? ? remove_app_background_admin_homepage_feed_homepage_section_url(f.object) : nil),
27
+ form: f,
28
+ classes: ['col-md-12'] %>
29
+ <%= render 'shared/asset_field',
30
+ field: :web_background,
31
+ label: Spree.t(:web_background),
32
+ asset: f.object.web_background,
33
+ remove_url: (f.object.web_background.present? ? remove_web_background_admin_homepage_feed_homepage_section_url(f.object) : nil),
34
+ form: f,
35
+ classes: ['col-md-12'] %>
36
+ </div>
11
37
  <%= f.field_container :segment do %>
12
38
  <%= f.label :segment, class: 'd-flex align-items-center' %>
13
39
  <div class="d-flex flex-wrap">
@@ -59,3 +85,21 @@
59
85
  </div>
60
86
  </div>
61
87
  </div>
88
+ <script>
89
+ $(document).ready(function () {
90
+ const sectionTypeSelector = "#spree_cm_commissioner_homepage_section_section_type"
91
+ const trendingFieldsSelector = "#homepage_section_trending_fields"
92
+
93
+ // initialize
94
+ toggleTrendingFields();
95
+
96
+ $(sectionTypeSelector).on("change", function () {
97
+ toggleTrendingFields();
98
+ });
99
+
100
+ function toggleTrendingFields() {
101
+ var selectedType = $(sectionTypeSelector).val();
102
+ $(trendingFieldsSelector).toggle(selectedType === "trending");
103
+ }
104
+ });
105
+ </script>
@@ -57,6 +57,7 @@ module Spree
57
57
  group_check_in_enabled
58
58
  individual_check_in_enabled
59
59
  allow_manual_search_for_operator
60
+ enforce_waiting_room_session
60
61
  show_badge_status
61
62
  purchasable_on
62
63
  available_on
@@ -2,6 +2,10 @@ en:
2
2
  dry_validation:
3
3
  errors:
4
4
  rules:
5
+ date:
6
+ must_be_in_future: Date must be today or in the future
7
+ origin_id:
8
+ must_be_different: Origin and destination must be different
5
9
  from_date:
6
10
  must_be_in_future: Start date must be today or in future
7
11
  must_be_later_than_start_date: 'End Date must be later than or equal Start Date'
@@ -195,6 +195,7 @@ en:
195
195
  errors:
196
196
  messages:
197
197
  must_be_positive_integer: "must be a positive whole number"
198
+ invalid_section_type: "Section type was not recognized, so it was left unchanged."
198
199
 
199
200
  purchase_limit:
200
201
  device_cap_requires_app_only: "Device limit requires the ticket to be app-only."
@@ -287,6 +288,14 @@ en:
287
288
  tenant: "Tenant"
288
289
  segment: "Segment"
289
290
  homepages: "Homepages"
291
+ section_type: "Section Type"
292
+ font: "Font"
293
+ app_background: "App Background"
294
+ web_background: "Web Background"
295
+ section_types:
296
+ default: "Default"
297
+ trending: "Trending"
298
+ big_banner: "Big Banner"
290
299
  purchase_limits:
291
300
  title: "Purchase Limits"
292
301
  hint: "Caps on how many units one buyer can purchase, checked independently per scope. Leave blank to disable. Caps never reset and lowering one does not claw back past purchases."
@@ -373,6 +382,8 @@ en:
373
382
  csv_unknown_status: "Something went wrong. Please try again."
374
383
  new_homepage_banner: "Add New Banner"
375
384
  invalid_session_token: "Invalid session token"
385
+ enforce_waiting_room_session: "Enforce waiting room on order"
386
+ enforce_waiting_room_session_hint: "Orders for this event are rejected unless the buyer holds an active waiting-room session. Buyers using a private sale link are exempt. The website has no waiting room yet, so web orders for this event are rejected too."
376
387
  intel_phone_number: "Intel Phone Number"
377
388
  phone_number: "Phone Number"
378
389
  contact: "Contact"
@@ -161,6 +161,7 @@ km:
161
161
  errors:
162
162
  messages:
163
163
  must_be_positive_integer: "ត្រូវតែជាចំនួនគត់វិជ្ជមាន"
164
+ invalid_section_type: "ប្រភេទផ្នែកមិនត្រឹមត្រូវទេ ដូច្នេះវានៅដូចដើម។"
164
165
 
165
166
  purchase_limit:
166
167
  device_cap_requires_app_only: "ដែនកំណត់តាមឧបករណ៍ត្រូវការឱ្យសំបុត្រមានតែក្នុងកម្មវិធីប៉ុណ្ណោះ។"
@@ -245,6 +246,14 @@ km:
245
246
  tenant: "អង្គភាព"
246
247
  segment: "ផ្នែក"
247
248
  homepages: "ទំព័រដើម"
249
+ section_type: "ប្រភេទផ្នែក"
250
+ font: "ពុម្ពអក្សរ"
251
+ app_background: "ផ្ទៃខាងក្រោយកម្មវិធី"
252
+ web_background: "ផ្ទៃខាងក្រោយវិបសាយ"
253
+ section_types:
254
+ default: "លំនាំដើម"
255
+ trending: "កំពុងពេញនិយម"
256
+ big_banner: "បដាធំ"
248
257
  purchase_limits:
249
258
  title: "ដែនកំណត់ការទិញ"
250
259
  hint: "ដែនកំណត់ចំនួនដែលអ្នកទិញម្នាក់អាចទិញបាន ដោយពិនិត្យដោយឡែកតាមប្រភេទនីមួយៗ។ ទុកទទេដើម្បីបិទ។ ដែនកំណត់មិនកំណត់ឡើងវិញទេ ហើយការបន្ថយវាមិនប៉ះពាល់ការទិញកន្លងមកឡើយ។"
@@ -326,6 +335,8 @@ km:
326
335
  csv_unknown_status: "Something went wrong. Please try again."
327
336
  new_homepage_banner: "បន្ថែមផ្ទាំងរូបភាពថ្មី"
328
337
  invalid_session_token: "Invalid session token"
338
+ enforce_waiting_room_session: "តម្រូវឱ្យឆ្លងកាត់បន្ទប់រង់ចាំមុនពេលបញ្ជាទិញ"
339
+ enforce_waiting_room_session_hint: "ការបញ្ជាទិញសម្រាប់ព្រឹត្តិការណ៍នេះនឹងត្រូវបដិសេធ លុះត្រាតែអ្នកទិញមានវគ្គបន្ទប់រង់ចាំដែលនៅសកម្ម។ អ្នកទិញតាមតំណលក់ឯកជនមិនស្ថិតក្រោមលក្ខខណ្ឌនេះទេ។ គេហទំព័រមិនទាន់មានបន្ទប់រង់ចាំនៅឡើយ ដូច្នេះការបញ្ជាទិញតាមគេហទំព័រសម្រាប់ព្រឹត្តិការណ៍នេះក៏នឹងត្រូវបដិសេធដែរ។"
329
340
  intel_phone_number: "លេខទូរសព្ទ"
330
341
  phone_number: "លេខទូរសព្ទ"
331
342
  new_customer: "អតិថិជនថ្មី"
data/config/routes.rb CHANGED
@@ -375,6 +375,11 @@ Spree::Core::Engine.add_routes do
375
375
  collection do
376
376
  post :update_positions
377
377
  end
378
+
379
+ member do
380
+ delete :remove_app_background
381
+ delete :remove_web_background
382
+ end
378
383
  end
379
384
 
380
385
  resources :homepage_section_relatable do
@@ -0,0 +1,6 @@
1
+ class AddSectionTypeToCmHomepageSections < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :cm_homepage_sections, :section_type, :integer, default: 0, null: false
4
+ add_column :cm_homepage_sections, :font, :string
5
+ end
6
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.14.4'.freeze
2
+ VERSION = '2.14.5'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.14.4
4
+ version: 2.14.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-26 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -1475,6 +1475,7 @@ files:
1475
1475
  - app/mailers/spree_cm_commissioner/pin_code_mailer.rb
1476
1476
  - app/mailers/spree_cm_commissioner/team_invite_mailer.rb
1477
1477
  - app/models/.gitkeep
1478
+ - app/models/active_storage/variation_decorator.rb
1478
1479
  - app/models/blazer/query_decorator.rb
1479
1480
  - app/models/concerns/spree_cm_commissioner/audit_wrapper.rb
1480
1481
  - app/models/concerns/spree_cm_commissioner/event_metadata.rb
@@ -1612,7 +1613,9 @@ files:
1612
1613
  - app/models/spree_cm_commissioner/homepage_banner_app_image.rb
1613
1614
  - app/models/spree_cm_commissioner/homepage_banner_web_image.rb
1614
1615
  - app/models/spree_cm_commissioner/homepage_section.rb
1616
+ - app/models/spree_cm_commissioner/homepage_section_app_background.rb
1615
1617
  - app/models/spree_cm_commissioner/homepage_section_relatable.rb
1618
+ - app/models/spree_cm_commissioner/homepage_section_web_background.rb
1616
1619
  - app/models/spree_cm_commissioner/hotel_google_wallet.rb
1617
1620
  - app/models/spree_cm_commissioner/icon_decorator.rb
1618
1621
  - app/models/spree_cm_commissioner/id_card.rb
@@ -1998,6 +2001,7 @@ files:
1998
2001
  - app/overrides/spree/admin/taxons/_form/background_color_and_foreground_color.html.erb.deface
1999
2002
  - app/overrides/spree/admin/taxons/_form/check_in_flows.html.erb.deface
2000
2003
  - app/overrides/spree/admin/taxons/_form/custom_redirect_url.html.erb.deface
2004
+ - app/overrides/spree/admin/taxons/_form/enforce_waiting_room_session.html.erb.deface
2001
2005
  - app/overrides/spree/admin/taxons/_form/hide_video_banner.html.erb.deface
2002
2006
  - app/overrides/spree/admin/taxons/_form/kind_html.erb.deface
2003
2007
  - app/overrides/spree/admin/taxons/_form/live_stock_window_form_date.html.erb.deface
@@ -3766,6 +3770,7 @@ files:
3766
3770
  - db/migrate/20261013000001_add_hidden_and_presentation_to_spree_product_option_types.rb
3767
3771
  - db/migrate/20261013000002_move_hidden_from_spree_option_types_to_product_option_types.rb
3768
3772
  - db/migrate/20261016000001_add_index_to_spree_line_items_number.rb
3773
+ - db/migrate/20261021000001_add_section_type_to_cm_homepage_sections.rb
3769
3774
  - docker-compose.yml
3770
3775
  - docs/api/scoped-access-token-endpoints.md
3771
3776
  - docs/option_types/attr_types.md