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.
- checksums.yaml +4 -4
- data/.env.example +6 -0
- data/Gemfile.lock +1 -1
- data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +33 -0
- data/app/controllers/concerns/spree_cm_commissioner/waiting_room_authorization.rb +41 -8
- data/app/controllers/spree/admin/homepage_section_controller.rb +63 -5
- data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +6 -2
- data/app/controllers/spree/api/v2/storefront/homepage_sections_controller.rb +1 -0
- data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -0
- data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +6 -2
- data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +6 -2
- data/app/controllers/spree/api/v2/tenant/homepage_sections_controller.rb +1 -0
- data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -0
- data/app/models/active_storage/variation_decorator.rb +18 -0
- data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +4 -0
- data/app/models/concerns/spree_cm_commissioner/order_holdable.rb +3 -2
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +17 -0
- data/app/models/spree_cm_commissioner/homepage_section.rb +33 -0
- data/app/models/spree_cm_commissioner/homepage_section_app_background.rb +13 -0
- data/app/models/spree_cm_commissioner/homepage_section_web_background.rb +13 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +7 -0
- data/app/overrides/spree/admin/taxons/_form/enforce_waiting_room_session.html.erb.deface +10 -0
- data/app/serializers/spree/v2/storefront/homepage_section_serializer.rb +4 -1
- data/app/serializers/spree/v2/tenant/homepage_section_serializer.rb +3 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +21 -4
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +1 -0
- data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +56 -5
- data/app/views/spree/admin/homepage_section/_form.html.erb +44 -0
- data/config/initializers/spree_permitted_attributes.rb +1 -0
- data/config/locales/dry_validations.en.yml +4 -0
- data/config/locales/en.yml +11 -0
- data/config/locales/km.yml +11 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261021000001_add_section_type_to_cm_homepage_sections.rb +6 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7720d10f3459b8933df3d2521bdce4983e03adf800dc852cb4e6f28d689f146
|
|
4
|
+
data.tar.gz: 746c1c6e7cc864f5555020ab15d21eeac2c601d6f7d6b0bcfa1bff0b9633bb77
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
@@ -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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
@@ -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
|
|
8
|
-
# (e.g., a countdown timer) and
|
|
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,
|
|
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
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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:
|
|
123
|
-
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
|
|
|
@@ -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
|
|
3
|
-
# bypass a cap by splitting the purchase
|
|
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>
|
|
@@ -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'
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|
data/config/locales/km.yml
CHANGED
|
@@ -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
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
|
+
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-
|
|
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
|