spree_core 5.4.0.beta6 → 5.4.0.beta8
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/app/helpers/spree/base_helper.rb +5 -7
- data/app/jobs/spree/payments/handle_webhook_job.rb +22 -0
- data/app/mailers/spree/base_mailer.rb +1 -1
- data/app/models/concerns/spree/user_methods.rb +16 -0
- data/app/models/spree/allowed_origin.rb +44 -0
- data/app/models/spree/asset.rb +121 -3
- data/app/models/spree/cart_promotion.rb +7 -0
- data/app/models/spree/checkout/default_requirements.rb +51 -0
- data/app/models/spree/checkout/registry.rb +112 -0
- data/app/models/spree/checkout/requirement.rb +49 -0
- data/app/models/spree/checkout/requirements.rb +56 -0
- data/app/models/spree/checkout/step.rb +52 -0
- data/app/models/spree/image/configuration/active_storage.rb +2 -14
- data/app/models/spree/image.rb +2 -78
- data/app/models/spree/legacy_user.rb +1 -0
- data/app/models/spree/line_item.rb +3 -3
- data/app/models/spree/order/checkout.rb +18 -0
- data/app/models/spree/order.rb +3 -0
- data/app/models/spree/order_promotion.rb +2 -0
- data/app/models/spree/payment_method.rb +34 -0
- data/app/models/spree/payment_session.rb +18 -0
- data/app/models/spree/product.rb +45 -34
- data/app/models/spree/shipment.rb +1 -0
- data/app/models/spree/store.rb +32 -0
- data/app/models/spree/variant.rb +21 -12
- data/app/services/spree/cart/create.rb +3 -30
- data/app/services/spree/carts/complete.rb +46 -0
- data/app/services/spree/carts/create.rb +32 -0
- data/app/services/spree/carts/update.rb +115 -0
- data/app/services/spree/{cart → carts}/upsert_items.rb +19 -23
- data/app/services/spree/payments/handle_webhook.rb +58 -0
- data/app/services/spree/seeds/all.rb +1 -0
- data/app/services/spree/seeds/allowed_origins.rb +14 -0
- data/app/views/spree/shared/_mailer_logo.html.erb +1 -1
- data/config/locales/en.yml +23 -2
- data/db/migrate/20260315000000_create_spree_allowed_origins.rb +14 -0
- data/db/migrate/20260315100000_add_product_media_support.rb +21 -0
- data/lib/spree/core/configuration.rb +3 -0
- data/lib/spree/core/dependencies.rb +4 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +1 -0
- data/lib/spree/permitted_attributes.rb +5 -1
- data/lib/spree/testing_support/factories/allowed_origin_factory.rb +8 -0
- data/lib/spree/testing_support/factories/asset_factory.rb +6 -9
- data/lib/spree/testing_support/factories/image_factory.rb +3 -1
- data/lib/spree/testing_support/factories/order_factory.rb +3 -0
- data/lib/tasks/images.rake +11 -11
- data/lib/tasks/products.rake +4 -2
- metadata +21 -6
- data/app/services/spree/orders/update.rb +0 -121
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8297e501dfb71ba02dbd003c3f2a6fa035585a07cca4cf3f7e039eb92bb55c6
|
|
4
|
+
data.tar.gz: 87546d3659afc8573cf33cd9b3b2ceac6e351f7f8e30969626991c7c39da8773
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8983ae8234112c480dc54e302bd7d56557b11418af19a0ca6d65aa182ed06195c828ae69dc122209c23057149febb28b4c9e410a460e9dba2b09978240788997
|
|
7
|
+
data.tar.gz: e62cdc311fe8a2a256442b38e84df024e5c145ef47dc3c2cf5981e923bf5e3558c3b6937c3a64d101eb0783f5c493fdfbc3e75db004740d45599fa9d8ddfc80a
|
|
@@ -114,10 +114,8 @@ module Spree
|
|
|
114
114
|
|
|
115
115
|
base_url = if options[:relative]
|
|
116
116
|
''
|
|
117
|
-
elsif store.respond_to?(:formatted_custom_domain) && store.formatted_custom_domain.present?
|
|
118
|
-
store.formatted_custom_domain
|
|
119
117
|
else
|
|
120
|
-
store.
|
|
118
|
+
store.storefront_url
|
|
121
119
|
end
|
|
122
120
|
|
|
123
121
|
localize = if options[:locale].present?
|
|
@@ -151,15 +149,15 @@ module Spree
|
|
|
151
149
|
# we should always try to render image of the default variant
|
|
152
150
|
# same as it's done on PDP
|
|
153
151
|
def default_image_for_product(product)
|
|
154
|
-
Spree::Deprecation.warn('BaseHelper#default_image_for_product is deprecated and will be removed in Spree
|
|
152
|
+
Spree::Deprecation.warn('BaseHelper#default_image_for_product is deprecated and will be removed in Spree 6.0. Please use product.primary_media instead')
|
|
155
153
|
|
|
156
|
-
product.
|
|
154
|
+
product.primary_media
|
|
157
155
|
end
|
|
158
156
|
|
|
159
157
|
def default_image_for_product_or_variant(product_or_variant)
|
|
160
|
-
Spree::Deprecation.warn('BaseHelper#default_image_for_product_or_variant is deprecated and will be removed in Spree
|
|
158
|
+
Spree::Deprecation.warn('BaseHelper#default_image_for_product_or_variant is deprecated and will be removed in Spree 6.0. Please use product_or_variant.primary_media instead')
|
|
161
159
|
|
|
162
|
-
product_or_variant.
|
|
160
|
+
product_or_variant.primary_media
|
|
163
161
|
end
|
|
164
162
|
|
|
165
163
|
def base_cache_key
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Payments
|
|
3
|
+
class HandleWebhookJob < Spree::BaseJob
|
|
4
|
+
queue_as Spree.queues.payment_webhooks
|
|
5
|
+
|
|
6
|
+
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
|
|
7
|
+
retry_on ActiveRecord::LockWaitTimeout, wait: 5.seconds, attempts: 3
|
|
8
|
+
discard_on ActiveRecord::RecordNotFound
|
|
9
|
+
|
|
10
|
+
def perform(payment_method_id:, action:, payment_session_id:)
|
|
11
|
+
payment_method = Spree::PaymentMethod.find(payment_method_id)
|
|
12
|
+
payment_session = Spree::PaymentSession.find(payment_session_id)
|
|
13
|
+
|
|
14
|
+
Spree::Dependencies.payments_handle_webhook_service.constantize.call(
|
|
15
|
+
payment_method: payment_method,
|
|
16
|
+
action: action.to_sym,
|
|
17
|
+
payment_session: payment_session
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -37,7 +37,7 @@ module Spree
|
|
|
37
37
|
# this is only a fail-safe solution if developer didn't set this in environment files
|
|
38
38
|
# http://guides.rubyonrails.org/action_mailer_basics.html#generating-urls-in-action-mailer-views
|
|
39
39
|
def ensure_default_action_mailer_url_host(store_url = nil)
|
|
40
|
-
host_url = store_url.presence || current_store.try(:
|
|
40
|
+
host_url = store_url.presence || current_store.try(:storefront_url)
|
|
41
41
|
|
|
42
42
|
return if host_url.blank?
|
|
43
43
|
|
|
@@ -18,6 +18,13 @@ module Spree
|
|
|
18
18
|
# Enable lifecycle events for user models
|
|
19
19
|
publishes_lifecycle_events
|
|
20
20
|
|
|
21
|
+
# Password reset token (Rails 7.1+ signed token, no DB column needed)
|
|
22
|
+
# Token auto-invalidates when password changes (salt changes)
|
|
23
|
+
# Expiration is configurable via Spree::Config.customer_password_reset_expires_in (in minutes)
|
|
24
|
+
generates_token_for :password_reset, expires_in: Spree::Config.customer_password_reset_expires_in.minutes do
|
|
25
|
+
password_salt&.last(10) || encrypted_password&.last(10)
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
# we need to have this callback before any dependent: :destroy associations
|
|
22
29
|
# https://github.com/rails/rails/issues/3458
|
|
23
30
|
before_validation :clone_billing_address, if: :use_billing?
|
|
@@ -35,6 +42,7 @@ module Spree
|
|
|
35
42
|
has_many :promotion_rule_users, class_name: 'Spree::PromotionRuleUser', foreign_key: :user_id, dependent: :destroy
|
|
36
43
|
has_many :promotion_rules, through: :promotion_rule_users, class_name: 'Spree::PromotionRule'
|
|
37
44
|
has_many :orders, foreign_key: :user_id, class_name: 'Spree::Order'
|
|
45
|
+
has_many :carts, -> { incomplete }, foreign_key: :user_id, class_name: 'Spree::Order'
|
|
38
46
|
has_many :completed_orders, -> { complete }, foreign_key: :user_id, class_name: 'Spree::Order'
|
|
39
47
|
has_many :store_credits, class_name: 'Spree::StoreCredit', foreign_key: :user_id, dependent: :destroy
|
|
40
48
|
has_many :wishlists, class_name: 'Spree::Wishlist', foreign_key: :user_id, dependent: :destroy
|
|
@@ -58,6 +66,14 @@ module Spree
|
|
|
58
66
|
#
|
|
59
67
|
attr_accessor :confirm_email, :terms_of_service
|
|
60
68
|
|
|
69
|
+
def self.find_by_password_reset_token(token)
|
|
70
|
+
find_by_token_for(:password_reset, token)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.find_by_password_reset_token!(token)
|
|
74
|
+
find_by_token_for!(:password_reset, token)
|
|
75
|
+
end
|
|
76
|
+
|
|
61
77
|
def self.multi_search(query)
|
|
62
78
|
sanitized_query = sanitize_query_for_multi_search(query)
|
|
63
79
|
return none if query.blank?
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
class AllowedOrigin < Spree.base_class
|
|
5
|
+
has_prefix_id :ao
|
|
6
|
+
|
|
7
|
+
include Spree::SingleStoreResource
|
|
8
|
+
|
|
9
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
10
|
+
|
|
11
|
+
validates :store, :origin, presence: true
|
|
12
|
+
validates :origin, uniqueness: { scope: [:store_id, *spree_base_uniqueness_scope] }
|
|
13
|
+
validate :origin_must_be_valid_http_url
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def origin_must_be_valid_http_url
|
|
18
|
+
return if origin.blank?
|
|
19
|
+
|
|
20
|
+
uri = URI.parse(origin)
|
|
21
|
+
|
|
22
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
23
|
+
errors.add(:origin, :invalid)
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if uri.host.blank?
|
|
28
|
+
errors.add(:origin, :invalid)
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Origins must not have a path, query, or fragment
|
|
33
|
+
if uri.path.present? && uri.path != '/'
|
|
34
|
+
errors.add(:origin, :must_be_origin_only)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if uri.query.present? || uri.fragment.present?
|
|
38
|
+
errors.add(:origin, :must_be_origin_only)
|
|
39
|
+
end
|
|
40
|
+
rescue URI::InvalidURIError
|
|
41
|
+
errors.add(:origin, :invalid)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/app/models/spree/asset.rb
CHANGED
|
@@ -1,20 +1,42 @@
|
|
|
1
1
|
module Spree
|
|
2
2
|
class Asset < Spree.base_class
|
|
3
|
-
has_prefix_id :
|
|
3
|
+
has_prefix_id :media
|
|
4
4
|
|
|
5
5
|
include Support::ActiveStorage
|
|
6
|
+
include Rails.application.routes.url_helpers
|
|
7
|
+
include Spree::ImageMethods # legacy, will be removed in Spree 6
|
|
6
8
|
include Spree::Metafields
|
|
7
9
|
include Spree::Metadata
|
|
8
10
|
|
|
11
|
+
# Legacy styles support (was in Spree::Image::Configuration::ActiveStorage)
|
|
12
|
+
# @deprecated Will be removed in Spree 6
|
|
13
|
+
def self.styles
|
|
14
|
+
@styles ||= Spree::Config.product_image_variant_sizes.transform_values do |dimensions|
|
|
15
|
+
"#{dimensions[0]}x#{dimensions[1]}>"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def default_style
|
|
20
|
+
:small
|
|
21
|
+
end
|
|
22
|
+
|
|
9
23
|
publishes_lifecycle_events
|
|
10
24
|
|
|
11
25
|
EXTERNAL_URL_METAFIELD_KEY = 'external.url'
|
|
26
|
+
MEDIA_TYPES = %w[image video external_video].freeze
|
|
27
|
+
|
|
28
|
+
after_initialize { self.media_type ||= 'image' }
|
|
12
29
|
|
|
13
30
|
belongs_to :viewable, polymorphic: true, touch: true
|
|
14
31
|
acts_as_list scope: [:viewable_id, :viewable_type]
|
|
15
32
|
|
|
16
33
|
delegate :key, :attached?, :variant, :variable?, :blob, :filename, :variation, to: :attachment
|
|
17
34
|
|
|
35
|
+
validates :media_type, inclusion: { in: MEDIA_TYPES }
|
|
36
|
+
validates :attachment, attached: true, content_type: Rails.application.config.active_storage.web_image_content_types,
|
|
37
|
+
if: -> { media_type == 'image' }
|
|
38
|
+
validates :external_video_url, presence: true, if: -> { media_type.in?(%w[video external_video]) }
|
|
39
|
+
|
|
18
40
|
WEBP_SAVER_OPTIONS = {
|
|
19
41
|
strip: true,
|
|
20
42
|
quality: 75,
|
|
@@ -43,14 +65,46 @@ module Spree
|
|
|
43
65
|
|
|
44
66
|
default_scope { includes(attachment_attachment: :blob) }
|
|
45
67
|
|
|
68
|
+
# STI was disabled in Spree::Image, keep it disabled here
|
|
69
|
+
self.inheritance_column = nil
|
|
70
|
+
|
|
46
71
|
store_accessor :private_metadata, :session_uploaded_assets_uuid
|
|
47
72
|
scope :with_session_uploaded_assets_uuid, lambda { |uuid|
|
|
48
73
|
where(session_id: uuid)
|
|
49
74
|
}
|
|
50
75
|
scope :with_external_url, ->(url) { url.present? ? with_metafield_key_value(EXTERNAL_URL_METAFIELD_KEY, url.strip) : none }
|
|
51
76
|
|
|
77
|
+
# Callbacks merged from Spree::Image
|
|
78
|
+
after_commit :touch_product_variants, if: :should_touch_product_variants?, on: :update
|
|
79
|
+
after_commit :update_viewable_thumbnail_on_create, on: :create
|
|
80
|
+
after_commit :update_viewable_thumbnail_on_destroy, on: :destroy
|
|
81
|
+
after_commit :update_viewable_thumbnail_on_reorder, on: :update, if: :saved_change_to_position?
|
|
82
|
+
after_commit :update_viewable_thumbnail_on_viewable_change, on: :update, if: :saved_change_to_viewable_id?
|
|
83
|
+
|
|
84
|
+
after_create :increment_viewable_media_count
|
|
85
|
+
after_destroy :decrement_viewable_media_count
|
|
86
|
+
|
|
52
87
|
def product
|
|
53
|
-
@product ||= viewable_type
|
|
88
|
+
@product ||= case viewable_type
|
|
89
|
+
when 'Spree::Variant' then viewable&.product
|
|
90
|
+
when 'Spree::Product' then viewable
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def focal_point
|
|
95
|
+
return nil if focal_point_x.nil? || focal_point_y.nil?
|
|
96
|
+
|
|
97
|
+
{ x: focal_point_x, y: focal_point_y }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def focal_point=(point)
|
|
101
|
+
if point.nil?
|
|
102
|
+
self.focal_point_x = nil
|
|
103
|
+
self.focal_point_y = nil
|
|
104
|
+
else
|
|
105
|
+
self.focal_point_x = point[:x]
|
|
106
|
+
self.focal_point_y = point[:y]
|
|
107
|
+
end
|
|
54
108
|
end
|
|
55
109
|
|
|
56
110
|
def external_url
|
|
@@ -66,7 +120,71 @@ module Spree
|
|
|
66
120
|
end
|
|
67
121
|
|
|
68
122
|
def event_prefix
|
|
69
|
-
'
|
|
123
|
+
'media'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @deprecated
|
|
127
|
+
def styles
|
|
128
|
+
Spree::Deprecation.warn("Asset#styles is deprecated and will be removed in Spree 6.0. Please use active storage variants with cdn_image_url")
|
|
129
|
+
|
|
130
|
+
self.class.styles.map do |_, size|
|
|
131
|
+
width, height = size.chop.split('x').map(&:to_i)
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
url: generate_url(size: size),
|
|
135
|
+
size: size,
|
|
136
|
+
width: width,
|
|
137
|
+
height: height
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def touch_product_variants
|
|
145
|
+
viewable.product.variants.touch_all
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def should_touch_product_variants?
|
|
149
|
+
viewable.is_a?(Spree::Variant) &&
|
|
150
|
+
viewable.is_master? &&
|
|
151
|
+
viewable.product.has_variants? &&
|
|
152
|
+
saved_change_to_position?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def increment_viewable_media_count
|
|
156
|
+
case viewable_type
|
|
157
|
+
when 'Spree::Variant'
|
|
158
|
+
Spree::Variant.increment_counter(:media_count, viewable_id)
|
|
159
|
+
Spree::Product.increment_counter(:media_count, viewable.product_id)
|
|
160
|
+
when 'Spree::Product'
|
|
161
|
+
Spree::Product.increment_counter(:media_count, viewable_id)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def decrement_viewable_media_count
|
|
166
|
+
case viewable_type
|
|
167
|
+
when 'Spree::Variant'
|
|
168
|
+
Spree::Variant.decrement_counter(:media_count, viewable_id)
|
|
169
|
+
Spree::Product.decrement_counter(:media_count, viewable.product_id)
|
|
170
|
+
when 'Spree::Product'
|
|
171
|
+
Spree::Product.decrement_counter(:media_count, viewable_id)
|
|
172
|
+
end
|
|
70
173
|
end
|
|
174
|
+
|
|
175
|
+
def update_viewable_thumbnail
|
|
176
|
+
case viewable_type
|
|
177
|
+
when 'Spree::Variant'
|
|
178
|
+
viewable.update_thumbnail!
|
|
179
|
+
viewable.product.update_thumbnail!
|
|
180
|
+
when 'Spree::Product'
|
|
181
|
+
viewable.update_thumbnail!
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
alias update_viewable_thumbnail_on_create update_viewable_thumbnail
|
|
186
|
+
alias update_viewable_thumbnail_on_destroy update_viewable_thumbnail
|
|
187
|
+
alias update_viewable_thumbnail_on_reorder update_viewable_thumbnail
|
|
188
|
+
alias update_viewable_thumbnail_on_viewable_change update_viewable_thumbnail
|
|
71
189
|
end
|
|
72
190
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Built-in checkout requirements that map to the standard Spree checkout flow.
|
|
4
|
+
#
|
|
5
|
+
# Checks line items, email, shipping address, shipping method, and payment.
|
|
6
|
+
# In Spree 6 these same checks will read from the +Cart+ model instead of
|
|
7
|
+
# the state machine — the API contract stays identical.
|
|
8
|
+
#
|
|
9
|
+
# @see Requirements
|
|
10
|
+
class DefaultRequirements
|
|
11
|
+
# @param order [Spree::Order]
|
|
12
|
+
def initialize(order)
|
|
13
|
+
@order = order
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<Hash{Symbol => String}>] unmet default requirements as
|
|
17
|
+
# +{ step:, field:, message: }+ hashes
|
|
18
|
+
def call
|
|
19
|
+
[].tap do |r|
|
|
20
|
+
r << req('cart', 'line_items', Spree.t('checkout_requirements.line_items_required')) unless @order.line_items.any?
|
|
21
|
+
r << req('address', 'email', Spree.t('checkout_requirements.email_required')) unless @order.email.present?
|
|
22
|
+
r << req('address', 'ship_address', Spree.t('checkout_requirements.ship_address_required')) if @order.requires_ship_address? && @order.ship_address.blank?
|
|
23
|
+
r << req('delivery', 'shipping_method', Spree.t('checkout_requirements.shipping_method_required')) if delivery_required? && !shipping_method_selected?
|
|
24
|
+
r << req('payment', 'payment', Spree.t('checkout_requirements.payment_required')) if payment_required? && !payment_satisfied?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def delivery_required?
|
|
31
|
+
@order.has_checkout_step?('delivery') && @order.delivery_required?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def shipping_method_selected?
|
|
35
|
+
@order.shipments.any? && @order.shipments.all? { |s| s.shipping_method.present? }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def payment_required?
|
|
39
|
+
@order.has_checkout_step?('payment') && @order.payment_required?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def payment_satisfied?
|
|
43
|
+
@order.payments.valid.any?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def req(step, field, message)
|
|
47
|
+
{ step: step, field: field, message: message }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Global registry for custom checkout steps and requirements.
|
|
4
|
+
#
|
|
5
|
+
# Provides a composable extension point so developers can add, remove, or
|
|
6
|
+
# reorder checkout steps and attach extra requirements to existing steps —
|
|
7
|
+
# all without subclassing or monkey-patching.
|
|
8
|
+
#
|
|
9
|
+
# Registered steps and requirements are evaluated by {Requirements} at
|
|
10
|
+
# serialization time to produce the +requirements+ array on the Cart API.
|
|
11
|
+
#
|
|
12
|
+
# @example Add a custom step
|
|
13
|
+
# Spree::Checkout::Registry.register_step(
|
|
14
|
+
# name: :loyalty,
|
|
15
|
+
# before: :payment,
|
|
16
|
+
# satisfied: ->(order) { order.loyalty_verified? },
|
|
17
|
+
# requirements: ->(order) { [{ step: 'loyalty', field: 'loyalty_number', message: 'Required' }] }
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# @example Add a requirement to an existing step
|
|
21
|
+
# Spree::Checkout::Registry.add_requirement(
|
|
22
|
+
# step: :payment,
|
|
23
|
+
# field: :po_number,
|
|
24
|
+
# message: 'PO number is required',
|
|
25
|
+
# satisfied: ->(order) { order.po_number.present? }
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# @example Remove a step
|
|
29
|
+
# Spree::Checkout::Registry.remove_step(:loyalty)
|
|
30
|
+
class Registry
|
|
31
|
+
class << self
|
|
32
|
+
# Register a new custom checkout step.
|
|
33
|
+
#
|
|
34
|
+
# @param name [String, Symbol] unique step identifier
|
|
35
|
+
# @param satisfied [Proc] lambda accepting an order, returns true when step is complete
|
|
36
|
+
# @param requirements [Proc] lambda accepting an order, returns Array of requirement hashes
|
|
37
|
+
# @param options [Hash] additional options forwarded to {Step} (+:before+, +:after+, +:applicable+)
|
|
38
|
+
# @return [Array<Step>] the updated steps list
|
|
39
|
+
def register_step(name:, satisfied:, requirements:, **options)
|
|
40
|
+
steps << Step.new(name: name, satisfied: satisfied, requirements: requirements, **options)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Add an extra requirement to an existing checkout step.
|
|
44
|
+
#
|
|
45
|
+
# @param step [String, Symbol] checkout step this requirement belongs to
|
|
46
|
+
# @param field [String, Symbol] field identifier
|
|
47
|
+
# @param message [String] human-readable validation message
|
|
48
|
+
# @param satisfied [Proc] lambda accepting an order, returns true when met
|
|
49
|
+
# @param options [Hash] additional options forwarded to {Requirement} (+:applicable+)
|
|
50
|
+
# @return [Array<Requirement>] the updated requirements list
|
|
51
|
+
def add_requirement(step:, field:, message:, satisfied:, **options)
|
|
52
|
+
requirements << Requirement.new(step: step, field: field, message: message, satisfied: satisfied, **options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Remove a previously registered step by name.
|
|
56
|
+
#
|
|
57
|
+
# @param name [String, Symbol] step name to remove
|
|
58
|
+
# @return [Array<Step>] the updated steps list
|
|
59
|
+
def remove_step(name)
|
|
60
|
+
steps.reject! { |s| s.name == name.to_s }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Remove a previously registered requirement by step and field.
|
|
64
|
+
#
|
|
65
|
+
# @param step [String, Symbol] checkout step the requirement belongs to
|
|
66
|
+
# @param field [String, Symbol] field identifier
|
|
67
|
+
# @return [Array<Requirement>] the updated requirements list
|
|
68
|
+
def remove_requirement(step:, field:)
|
|
69
|
+
requirements.reject! { |r| r.step == step.to_s && r.field == field.to_s }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns steps sorted by +before+/+after+ constraints relative to the checkout flow.
|
|
73
|
+
#
|
|
74
|
+
# The sort order is derived from {Spree::Order.checkout_step_names} so it
|
|
75
|
+
# stays in sync with any customizations to the checkout state machine.
|
|
76
|
+
# Steps with +before:+/+after:+ anchors are ordered by the anchor's position;
|
|
77
|
+
# steps without constraints are appended at the end.
|
|
78
|
+
#
|
|
79
|
+
# @return [Array<Step>] steps in display order
|
|
80
|
+
def ordered_steps
|
|
81
|
+
return steps if steps.empty?
|
|
82
|
+
|
|
83
|
+
step_order = Spree::Order.checkout_step_names.map(&:to_s)
|
|
84
|
+
positioned, unpositioned = steps.partition { |s| s.before || s.after }
|
|
85
|
+
|
|
86
|
+
sorted = positioned.sort_by do |s|
|
|
87
|
+
anchor = s.before || s.after
|
|
88
|
+
idx = step_order.index(anchor)
|
|
89
|
+
# before: inserts just before the anchor, after: just after
|
|
90
|
+
idx ? (s.before ? idx - 0.5 : idx + 0.5) : Float::INFINITY
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sorted + unpositioned
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Array<Step>] all registered steps
|
|
97
|
+
def steps = (@steps ||= [])
|
|
98
|
+
|
|
99
|
+
# @return [Array<Requirement>] all registered requirements
|
|
100
|
+
def requirements = (@requirements ||= [])
|
|
101
|
+
|
|
102
|
+
# Clear all registered steps and requirements. Intended for testing.
|
|
103
|
+
#
|
|
104
|
+
# @return [void]
|
|
105
|
+
def reset!
|
|
106
|
+
@steps = []
|
|
107
|
+
@requirements = []
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Value object representing a single additional requirement registered via {Registry}.
|
|
4
|
+
#
|
|
5
|
+
# Unlike {Step}, a Requirement attaches extra validation to an *existing* checkout step
|
|
6
|
+
# rather than introducing a new step.
|
|
7
|
+
#
|
|
8
|
+
# @example Require a PO number for B2B orders at the payment step
|
|
9
|
+
# Spree::Checkout::Registry.add_requirement(
|
|
10
|
+
# step: :payment,
|
|
11
|
+
# field: :po_number,
|
|
12
|
+
# message: 'PO number is required for business accounts',
|
|
13
|
+
# satisfied: ->(order) { order.po_number.present? },
|
|
14
|
+
# applicable: ->(order) { order.account&.business? }
|
|
15
|
+
# )
|
|
16
|
+
class Requirement
|
|
17
|
+
# @return [String] checkout step this requirement belongs to
|
|
18
|
+
attr_reader :step
|
|
19
|
+
|
|
20
|
+
# @return [String] field identifier (e.g. +"po_number"+, +"tax_id"+)
|
|
21
|
+
attr_reader :field
|
|
22
|
+
|
|
23
|
+
# @return [String] human-readable message shown when the requirement is not met
|
|
24
|
+
attr_reader :message
|
|
25
|
+
|
|
26
|
+
# @param step [String, Symbol] checkout step this requirement belongs to
|
|
27
|
+
# @param field [String, Symbol] field identifier
|
|
28
|
+
# @param message [String] human-readable validation message
|
|
29
|
+
# @param satisfied [Proc] lambda accepting an order, returns true when met
|
|
30
|
+
# @param applicable [Proc] lambda accepting an order, returns true when this requirement applies
|
|
31
|
+
# (defaults to always applicable)
|
|
32
|
+
def initialize(step:, field:, message:, satisfied:, applicable: ->(_) { true })
|
|
33
|
+
@step = step.to_s
|
|
34
|
+
@field = field.to_s
|
|
35
|
+
@message = message
|
|
36
|
+
@satisfied_proc = satisfied
|
|
37
|
+
@applicable_proc = applicable
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param order [Spree::Order]
|
|
41
|
+
# @return [Boolean] whether the requirement has been met
|
|
42
|
+
def satisfied?(order) = @satisfied_proc.call(order)
|
|
43
|
+
|
|
44
|
+
# @param order [Spree::Order]
|
|
45
|
+
# @return [Boolean] whether this requirement applies to the given order
|
|
46
|
+
def applicable?(order) = @applicable_proc.call(order)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Aggregates all checkout requirements for an order.
|
|
4
|
+
#
|
|
5
|
+
# Combines built-in checks from {DefaultRequirements} with custom steps and
|
|
6
|
+
# requirements registered in {Registry}. The resulting array of hashes is
|
|
7
|
+
# exposed on the Cart API as the +requirements+ attribute.
|
|
8
|
+
#
|
|
9
|
+
# Each requirement hash has the shape:
|
|
10
|
+
# { step: String, field: String, message: String }
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# reqs = Spree::Checkout::Requirements.new(order)
|
|
14
|
+
# reqs.call # => [{ step: "address", field: "email", message: "Email address is required" }]
|
|
15
|
+
# reqs.met? # => false
|
|
16
|
+
class Requirements
|
|
17
|
+
# @param order [Spree::Order]
|
|
18
|
+
def initialize(order)
|
|
19
|
+
@order = order
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<Hash{Symbol => String}>] all unmet requirements
|
|
23
|
+
def call
|
|
24
|
+
default + from_registered_steps + from_additional_requirements
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] true when all requirements are satisfied
|
|
28
|
+
def met?
|
|
29
|
+
call.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# @return [Array<Hash>] built-in checkout requirements
|
|
35
|
+
def default
|
|
36
|
+
DefaultRequirements.new(@order).call
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Array<Hash>] requirements from unsatisfied registered steps
|
|
40
|
+
def from_registered_steps
|
|
41
|
+
Registry.ordered_steps
|
|
42
|
+
.select { |s| s.applicable?(@order) }
|
|
43
|
+
.reject { |s| s.satisfied?(@order) }
|
|
44
|
+
.flat_map { |s| s.requirements(@order) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Hash>] requirements from unsatisfied registered requirements
|
|
48
|
+
def from_additional_requirements
|
|
49
|
+
Registry.requirements
|
|
50
|
+
.select { |r| r.applicable?(@order) }
|
|
51
|
+
.reject { |r| r.satisfied?(@order) }
|
|
52
|
+
.map { |r| { step: r.step, field: r.field, message: r.message } }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Value object representing a custom checkout step registered via {Registry}.
|
|
4
|
+
#
|
|
5
|
+
# @example Register a loyalty step before payment
|
|
6
|
+
# Spree::Checkout::Registry.register_step(
|
|
7
|
+
# name: :loyalty,
|
|
8
|
+
# before: :payment,
|
|
9
|
+
# satisfied: ->(order) { order.loyalty_verified? },
|
|
10
|
+
# requirements: ->(order) { [{ step: 'loyalty', field: 'loyalty_number', message: 'Enter loyalty number' }] }
|
|
11
|
+
# )
|
|
12
|
+
class Step
|
|
13
|
+
# @return [String] step name
|
|
14
|
+
attr_reader :name
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] name of the checkout step this should be placed after
|
|
17
|
+
attr_reader :after
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] name of the checkout step this should be placed before
|
|
20
|
+
attr_reader :before
|
|
21
|
+
|
|
22
|
+
# @param name [String, Symbol] unique step identifier
|
|
23
|
+
# @param satisfied [Proc] lambda accepting an order, returns true when the step is complete
|
|
24
|
+
# @param requirements [Proc] lambda accepting an order, returns Array of requirement hashes
|
|
25
|
+
# (+{ step:, field:, message: }+) describing what is still needed
|
|
26
|
+
# @param applicable [Proc] lambda accepting an order, returns true when this step applies
|
|
27
|
+
# (defaults to always applicable)
|
|
28
|
+
# @param after [String, Symbol, nil] place this step after the named checkout step
|
|
29
|
+
# @param before [String, Symbol, nil] place this step before the named checkout step
|
|
30
|
+
def initialize(name:, satisfied:, requirements:, applicable: ->(_) { true }, after: nil, before: nil)
|
|
31
|
+
@name = name.to_s
|
|
32
|
+
@after = after&.to_s
|
|
33
|
+
@before = before&.to_s
|
|
34
|
+
@satisfied_proc = satisfied
|
|
35
|
+
@requirements_proc = requirements
|
|
36
|
+
@applicable_proc = applicable
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param order [Spree::Order]
|
|
40
|
+
# @return [Boolean] whether the step's conditions have been met
|
|
41
|
+
def satisfied?(order) = @satisfied_proc.call(order)
|
|
42
|
+
|
|
43
|
+
# @param order [Spree::Order]
|
|
44
|
+
# @return [Array<Hash{Symbol => String}>] outstanding requirement hashes (+{ step:, field:, message: }+)
|
|
45
|
+
def requirements(order) = @requirements_proc.call(order)
|
|
46
|
+
|
|
47
|
+
# @param order [Spree::Order]
|
|
48
|
+
# @return [Boolean] whether this step applies to the given order
|
|
49
|
+
def applicable?(order) = @applicable_proc.call(order)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -1,22 +1,10 @@
|
|
|
1
|
+
# @deprecated This module is now a no-op. All logic has been moved to Spree::Asset.
|
|
2
|
+
# Will be removed in Spree 6.0.
|
|
1
3
|
module Spree
|
|
2
4
|
class Image < Asset
|
|
3
5
|
module Configuration
|
|
4
6
|
module ActiveStorage
|
|
5
7
|
extend ActiveSupport::Concern
|
|
6
|
-
|
|
7
|
-
included do
|
|
8
|
-
# Returns image styles derived from Spree::Config.product_image_variant_sizes
|
|
9
|
-
# Format: { variant_name: 'WxH>' } for API compatibility
|
|
10
|
-
def self.styles
|
|
11
|
-
@styles ||= Spree::Config.product_image_variant_sizes.transform_values do |dimensions|
|
|
12
|
-
"#{dimensions[0]}x#{dimensions[1]}>"
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def default_style
|
|
17
|
-
:small
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
8
|
end
|
|
21
9
|
end
|
|
22
10
|
end
|