spree_core 5.4.0.beta8 → 5.4.0.beta10

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/finders/spree/products/find.rb +1 -1
  3. data/app/jobs/spree/search_provider/index_job.rb +22 -0
  4. data/app/jobs/spree/search_provider/remove_job.rb +19 -0
  5. data/app/models/concerns/spree/product_scopes.rb +95 -135
  6. data/app/models/concerns/spree/search_indexable.rb +83 -0
  7. data/app/models/concerns/spree/{multi_searchable.rb → searchable.rb} +10 -3
  8. data/app/models/concerns/spree/user_methods.rb +11 -8
  9. data/app/models/spree/address.rb +3 -15
  10. data/app/models/spree/credit_card.rb +1 -0
  11. data/app/models/spree/inventory_unit.rb +1 -1
  12. data/app/models/spree/line_item.rb +4 -0
  13. data/app/models/spree/metafield_definition.rb +7 -4
  14. data/app/models/spree/option_type.rb +3 -0
  15. data/app/models/spree/option_value.rb +3 -0
  16. data/app/models/spree/order.rb +27 -9
  17. data/app/models/spree/order_promotion.rb +1 -1
  18. data/app/models/spree/product.rb +9 -31
  19. data/app/models/spree/refresh_token.rb +60 -0
  20. data/app/models/spree/search_provider/base.rb +81 -0
  21. data/app/models/spree/search_provider/database.rb +95 -0
  22. data/app/models/spree/search_provider/meilisearch.rb +302 -0
  23. data/app/models/spree/search_provider/search_result.rb +5 -0
  24. data/app/models/spree/shipment.rb +1 -1
  25. data/app/models/spree/shipping_method.rb +1 -1
  26. data/app/models/spree/shipping_rate.rb +1 -1
  27. data/app/models/spree/taxon.rb +2 -7
  28. data/app/models/spree/variant.rb +6 -25
  29. data/app/models/spree/wishlist.rb +1 -0
  30. data/app/presenters/spree/search_provider/product_presenter.rb +131 -0
  31. data/app/services/spree/carts/update.rb +5 -4
  32. data/db/migrate/20260317000000_create_spree_refresh_tokens.rb +16 -0
  33. data/db/sample_data/payment_methods.rb +2 -0
  34. data/lib/spree/core/version.rb +1 -1
  35. data/lib/spree/core.rb +16 -3
  36. data/lib/tasks/search.rake +15 -0
  37. metadata +30 -6
  38. data/app/models/spree/cart_promotion.rb +0 -7
@@ -0,0 +1,302 @@
1
+ module Spree
2
+ module SearchProvider
3
+ class Meilisearch < Base
4
+ PREFIXED_ID_PATTERN = /\A[a-z]+_[A-Za-z0-9]+\z/
5
+ ALLOWED_STATUSES = %w[active draft archived paused].freeze
6
+
7
+ def self.indexing_required?
8
+ true
9
+ end
10
+
11
+ def initialize(store)
12
+ super
13
+ require 'meilisearch'
14
+ rescue LoadError
15
+ raise LoadError, "Add `gem 'meilisearch'` to your Gemfile to use the Meilisearch search provider"
16
+ end
17
+
18
+ def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
19
+ page = [page.to_i, 1].max
20
+ limit = limit.to_i.clamp(1, 100)
21
+
22
+ search_params = {
23
+ filter: build_filters(filters),
24
+ facets: facet_attributes,
25
+ sort: build_sort(sort),
26
+ offset: (page - 1) * limit,
27
+ limit: limit
28
+ }
29
+
30
+ Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
31
+
32
+ ms_result = client.index(index_name).search(query.to_s, search_params)
33
+
34
+ Rails.logger.debug { "[Meilisearch] #{ms_result['estimatedTotalHits']} hits in #{ms_result['processingTimeMs']}ms" }
35
+
36
+ # Hits have composite prefixed_id (prod_abc_en_USD), extract product_id (prod_abc)
37
+ product_prefixed_ids = ms_result['hits'].map { |h| h['product_id'] }.uniq
38
+ raw_ids = product_prefixed_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) }
39
+
40
+ # Intersect with AR scope for security/visibility.
41
+ # Since we filter by store/status/currency/discontinue_on in Meilisearch,
42
+ # the AR scope is a safety net — it should not filter anything out.
43
+ products = if raw_ids.any?
44
+ scope.where(id: raw_ids).reorder(nil)
45
+ else
46
+ scope.none
47
+ end
48
+
49
+ # Build Pagy object from Meilisearch response (passive mode)
50
+ pagy = build_pagy(ms_result, page, limit)
51
+
52
+ SearchResult.new(
53
+ products: products,
54
+ filters: build_facet_response(ms_result['facetDistribution'] || {}),
55
+ sort_options: available_sort_options.map { |id| { id: id } },
56
+ default_sort: 'manual',
57
+ total_count: ms_result['estimatedTotalHits'] || 0,
58
+ pagy: pagy
59
+ )
60
+ end
61
+
62
+ def index(product)
63
+ documents = ProductPresenter.new(product, store).call
64
+ client.index(index_name).add_documents(documents, 'prefixed_id')
65
+ end
66
+
67
+ def remove(product)
68
+ remove_by_id(product.prefixed_id)
69
+ end
70
+
71
+ def index_batch(documents)
72
+ client.index(index_name).add_documents(documents, 'prefixed_id')
73
+ end
74
+
75
+ # Remove all documents for a product by its prefixed_id (e.g. 'prod_abc')
76
+ def remove_by_id(prefixed_id)
77
+ filter = "product_id = '#{sanitize_prefixed_id(prefixed_id)}'"
78
+ client.index(index_name).delete_documents(filter: filter)
79
+ rescue ::Meilisearch::ApiError => e
80
+ raise unless e.http_code == 404
81
+ end
82
+
83
+ def reindex(scope = nil)
84
+ scope ||= store.products
85
+ ensure_index_settings!
86
+
87
+ scope.reorder(id: :asc)
88
+ .preload(*ProductPresenter::REQUIRED_PRELOADS)
89
+ .find_in_batches(batch_size: 500) do |batch|
90
+ documents = batch.flat_map { |product| ProductPresenter.new(product, store).call }
91
+ index_batch(documents)
92
+ end
93
+ end
94
+
95
+ # Configure index settings for filtering, sorting, and faceting.
96
+ # Called automatically by reindex, but can be called separately.
97
+ def ensure_index_settings!
98
+ index = client.index(index_name)
99
+ index.update_filterable_attributes(filterable_attributes)
100
+ index.update_sortable_attributes(sortable_attributes)
101
+ index.update_searchable_attributes(searchable_attributes)
102
+ end
103
+
104
+ private
105
+
106
+ def client
107
+ @client ||= ::Meilisearch::Client.new(
108
+ ENV.fetch('MEILISEARCH_URL', 'http://localhost:7700'),
109
+ ENV['MEILISEARCH_API_KEY']
110
+ )
111
+ end
112
+
113
+ def index_name
114
+ "#{store.code}_products"
115
+ end
116
+
117
+ def searchable_attributes
118
+ %w[name description sku option_values category_names tags]
119
+ end
120
+
121
+ def filterable_attributes
122
+ %w[product_id status in_stock store_ids locale currency discontinue_on price category_ids tags option_value_ids]
123
+ end
124
+
125
+ def sortable_attributes
126
+ %w[name price created_at available_on units_sold_count]
127
+ end
128
+
129
+ def facet_attributes
130
+ filterable_attributes
131
+ end
132
+
133
+ def available_sort_options
134
+ %w[price -price name -name -available_on available_on best_selling]
135
+ end
136
+
137
+ # Convert Ransack-style filters to Meilisearch filter syntax.
138
+ # Always applies store scoping, active status, and currency availability
139
+ # so that Meilisearch results match what the AR scope would return.
140
+ # All values are sanitized to prevent filter injection.
141
+ def build_filters(filters)
142
+ conditions = []
143
+
144
+ # Always scope to current store, locale, currency, active, not discontinued.
145
+ # This mirrors the AR scope: store.products.active(currency) with locale
146
+ conditions << "store_ids = '#{store.id}'"
147
+ conditions << "status = 'active'"
148
+ conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
149
+ conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
150
+ conditions << "(discontinue_on = 0 OR discontinue_on > #{Time.current.to_i})"
151
+
152
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
153
+ return conditions if filters.blank?
154
+
155
+ filters.each do |key, value|
156
+ next if value.blank?
157
+
158
+ key = key.to_s
159
+ case key
160
+ when 'price_gte'
161
+ conditions << "price >= #{value.to_f}"
162
+ when 'price_lte'
163
+ conditions << "price <= #{value.to_f}"
164
+ when 'in_stock'
165
+ conditions << 'in_stock = true' if value.to_s != '0'
166
+ when 'out_of_stock'
167
+ conditions << 'in_stock = false' if value.to_s != '0'
168
+ when 'categories_id_eq'
169
+ conditions << "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
170
+ when 'with_option_value_ids'
171
+ Array(value).each do |ov|
172
+ conditions << "option_value_ids = '#{sanitize_prefixed_id(ov)}'" if valid_prefixed_id?(ov)
173
+ end
174
+ end
175
+ end
176
+
177
+ conditions
178
+ end
179
+
180
+ def build_sort(sort)
181
+ return nil if sort.blank?
182
+
183
+ case sort
184
+ when 'price'
185
+ ['price:asc']
186
+ when '-price'
187
+ ['price:desc']
188
+ when 'name'
189
+ ['name:asc']
190
+ when '-name'
191
+ ['name:desc']
192
+ when '-available_on'
193
+ ['available_on:desc']
194
+ when 'available_on'
195
+ ['available_on:asc']
196
+ when 'best_selling'
197
+ ['units_sold_count:desc']
198
+ end
199
+ end
200
+
201
+ # Transform Meilisearch facetDistribution into our standard filter response format
202
+ def build_facet_response(facet_distribution)
203
+ filters = []
204
+
205
+ # Price range
206
+ if facet_distribution['price'].present?
207
+ amounts = facet_distribution['price'].keys.map(&:to_f)
208
+ filters << {
209
+ id: 'price',
210
+ type: 'price_range',
211
+ min: amounts.min,
212
+ max: amounts.max,
213
+ currency: currency
214
+ }
215
+ end
216
+
217
+ # Availability
218
+ if facet_distribution['in_stock'].present?
219
+ in_stock = facet_distribution['in_stock']['true'] || 0
220
+ out_of_stock = facet_distribution['in_stock']['false'] || 0
221
+ filters << {
222
+ id: 'availability',
223
+ type: 'availability',
224
+ options: [
225
+ { id: 'in_stock', count: in_stock },
226
+ { id: 'out_of_stock', count: out_of_stock }
227
+ ]
228
+ }
229
+ end
230
+
231
+ # Option values — group by option type for faceted display
232
+ if facet_distribution['option_value_ids'].present?
233
+ prefixed_ids = facet_distribution['option_value_ids'].keys
234
+ raw_ids = prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
235
+ option_values = Spree::OptionValue.where(id: raw_ids).includes(:option_type).preload_associations_lazily.index_by(&:prefixed_id)
236
+
237
+ # Group by option type
238
+ by_option_type = {}
239
+ facet_distribution['option_value_ids'].each do |ov_prefixed_id, count|
240
+ ov = option_values[ov_prefixed_id]
241
+ next unless ov
242
+
243
+ ot = ov.option_type
244
+ by_option_type[ot] ||= []
245
+ by_option_type[ot] << { id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position, count: count }
246
+ end
247
+
248
+ by_option_type.each do |option_type, values|
249
+ filters << {
250
+ id: option_type.prefixed_id,
251
+ type: 'option',
252
+ name: option_type.name,
253
+ label: option_type.label,
254
+ options: values.sort_by { |o| o[:position] }
255
+ }
256
+ end
257
+ end
258
+
259
+ # Categories
260
+ if facet_distribution['category_ids'].present?
261
+ prefixed_ids = facet_distribution['category_ids'].keys
262
+ raw_ids = prefixed_ids.filter_map { |pid| Spree::Taxon.decode_prefixed_id(pid) }
263
+ categories = Spree::Taxon.where(id: raw_ids).index_by(&:prefixed_id)
264
+
265
+ filters << {
266
+ id: 'categories',
267
+ type: 'category',
268
+ options: facet_distribution['category_ids'].filter_map do |prefixed_id, count|
269
+ cat = categories[prefixed_id]
270
+ next unless cat
271
+
272
+ { id: cat.prefixed_id, name: cat.name, permalink: cat.permalink, count: count }
273
+ end
274
+ }
275
+ end
276
+
277
+ filters
278
+ end
279
+
280
+ def build_pagy(ms_result, page, limit)
281
+ require 'pagy'
282
+ require 'pagy/toolbox/paginators/meilisearch'
283
+
284
+ fake_result = Struct.new(:raw_answer).new({
285
+ 'totalHits' => ms_result['estimatedTotalHits'] || 0,
286
+ 'hitsPerPage' => limit,
287
+ 'page' => page
288
+ })
289
+
290
+ Pagy::MeilisearchPaginator.paginate(fake_result, {})
291
+ end
292
+
293
+ def valid_prefixed_id?(value)
294
+ value.to_s.match?(PREFIXED_ID_PATTERN)
295
+ end
296
+
297
+ def sanitize_prefixed_id(value)
298
+ value.to_s.gsub(/[^a-zA-Z0-9_]/, '')
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,5 @@
1
+ module Spree
2
+ module SearchProvider
3
+ SearchResult = Struct.new(:products, :filters, :sort_options, :default_sort, :total_count, :pagy, keyword_init: true)
4
+ end
5
+ end
@@ -2,7 +2,7 @@ require 'ostruct'
2
2
 
3
3
  module Spree
4
4
  class Shipment < Spree.base_class
5
- has_prefix_id :ship # Spree-specific: shipment
5
+ has_prefix_id :ful
6
6
 
7
7
  include Spree::Core::NumberGenerator.new(prefix: 'H', length: 11)
8
8
  include Spree::NumberIdentifier
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  class ShippingMethod < Spree.base_class
3
- has_prefix_id :shpm # Spree-specific: shipping method
3
+ has_prefix_id :dm
4
4
 
5
5
  acts_as_paranoid
6
6
  include Spree::CalculatedAdjustments
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  class ShippingRate < Spree.base_class
3
- has_prefix_id :shpr # Spree-specific: shipping rate
3
+ has_prefix_id :dr
4
4
 
5
5
  belongs_to :shipment, class_name: 'Spree::Shipment'
6
6
  belongs_to :tax_rate, -> { with_deleted }, class_name: 'Spree::TaxRate'
@@ -103,13 +103,8 @@ module Spree
103
103
  #
104
104
  # Search
105
105
  #
106
- if defined?(PgSearch)
107
- include PgSearch::Model
108
- pg_search_scope :search_by_name, against: :name, using: { tsearch: { any_word: true, prefix: true } }
109
- else
110
- def self.search_by_name(query)
111
- i18n { name.lower.matches("%#{query.downcase}%") }
112
- end
106
+ def self.search_by_name(query)
107
+ i18n { name.lower.matches("%#{query.downcase}%") }
113
108
  end
114
109
 
115
110
  scope :with_matching_name, lambda { |name_to_match|
@@ -129,33 +129,14 @@ module Spree
129
129
 
130
130
  scope :with_digital_assets, -> { joins(:digitals) }
131
131
 
132
- if defined?(PgSearch)
133
- include PgSearch::Model
132
+ scope :search, ->(query) {
133
+ next none if query.blank? || query.length < 3
134
134
 
135
- pg_search_scope :search_by_sku, against: :sku, using: { tsearch: { prefix: true } }
136
-
137
- pg_search_scope :search_by_sku_or_options,
138
- against: :sku,
139
- using: { tsearch: { prefix: true } },
140
- associated_against: { option_values: %i[presentation] }
141
-
142
- pg_search_scope :search_by_name_sku_or_options, against: :sku, associated_against: {
143
- product: %i[name],
144
- option_values: %i[presentation]
145
- }, using: { tsearch: { prefix: true } }
146
-
147
- scope :multi_search, lambda { |query|
148
- return none if query.blank? || query.length < 3
149
-
150
- search_by_name_sku_or_options(query)
151
- }
152
- else
153
- scope :multi_search, lambda { |query|
154
- return none if query.blank? || query.length < 3
135
+ product_name_or_sku_cont(query)
136
+ }
155
137
 
156
- product_name_or_sku_cont(query)
157
- }
158
- end
138
+ # Backward compatibility alias — remove in Spree 6.0
139
+ scope :multi_search, ->(*args) { search(*args) }
159
140
 
160
141
  # FIXME: cost price should be represented with DisplayMoney class
161
142
  LOCALIZED_NUMBERS = %w(cost_price weight depth width height)
@@ -16,6 +16,7 @@ module Spree
16
16
  belongs_to :store, class_name: 'Spree::Store'
17
17
 
18
18
  has_many :wished_items, class_name: 'Spree::WishedItem', dependent: :destroy
19
+ alias wishlist_items wished_items
19
20
  has_many :variants, through: :wished_items, source: :variant, class_name: 'Spree::Variant'
20
21
  has_many :products, -> { distinct }, through: :variants, source: :product, class_name: 'Spree::Product'
21
22
 
@@ -0,0 +1,131 @@
1
+ module Spree
2
+ module SearchProvider
3
+ class ProductPresenter
4
+ # Associations needed by this presenter — used by reindex and rake task for preloading
5
+ REQUIRED_PRELOADS = [
6
+ :taxons, :option_types, :primary_media, :store_products,
7
+ { variants_including_master: [:prices, :option_values] }
8
+ ].freeze
9
+
10
+ attr_reader :product, :store
11
+
12
+ def initialize(product, store)
13
+ @product = product
14
+ @store = store
15
+ end
16
+
17
+ # Returns an array of documents — one per market × locale combination.
18
+ # Each document has flat name, description, price fields (no dynamic suffixes).
19
+ def call
20
+ documents = []
21
+
22
+ market_locale_pairs.each do |market, locale|
23
+ # Skip if product has no price in this currency
24
+ next unless lowest_price(market.currency)
25
+
26
+ Mobility.with_locale(locale) do
27
+ documents << build_document(locale, market.currency, default_locale)
28
+ end
29
+ end
30
+
31
+ # Fallback for stores without markets (legacy/test)
32
+ if documents.empty?
33
+ fallback_currency = store.default_market&.currency || store.supported_currencies_list.first&.iso_code
34
+ if fallback_currency && lowest_price(fallback_currency)
35
+ documents << build_document(default_locale, fallback_currency, default_locale)
36
+ end
37
+ end
38
+
39
+ documents
40
+ end
41
+
42
+ private
43
+
44
+ def build_document(locale, currency, fallback_locale)
45
+ {
46
+ # Composite ID: product + locale + currency
47
+ prefixed_id: "#{product.prefixed_id}_#{locale}_#{currency}",
48
+ product_id: product.prefixed_id,
49
+ locale: locale.to_s,
50
+ currency: currency,
51
+ # Translated fields — with fallback to default locale
52
+ name: translated(product, :name, fallback_locale),
53
+ description: translated(product, :description, fallback_locale),
54
+ slug: translated(product, :slug, fallback_locale),
55
+ # Price in this currency
56
+ price: lowest_price(currency)&.to_f,
57
+ compare_at_price: compare_at_price(currency)&.to_f,
58
+ # Non-locale/currency fields
59
+ status: product.status,
60
+ sku: product.sku,
61
+ in_stock: product.in_stock?,
62
+ store_ids: cached_store_ids,
63
+ discontinue_on: product.discontinue_on&.to_i || 0,
64
+ category_ids: product.taxons.map(&:prefixed_id),
65
+ category_names: product.taxons.map { |t| translated(t, :name, fallback_locale) },
66
+ option_type_ids: product.option_types.map(&:prefixed_id),
67
+ option_type_names: product.option_types.map { |ot| translated(ot, :presentation, fallback_locale) },
68
+ option_value_ids: variant_option_value_ids,
69
+ option_values: variant_option_values_data.map { |ov| translated(ov, :presentation, fallback_locale) }.uniq,
70
+ tags: product.tag_list || [],
71
+ thumbnail_url: product.primary_media&.url(:large),
72
+ units_sold_count: cached_units_sold_count,
73
+ available_on: product.available_on&.iso8601,
74
+ created_at: product.created_at&.iso8601,
75
+ updated_at: product.updated_at&.iso8601
76
+ }
77
+ end
78
+
79
+ # Returns all market × locale pairs for this store
80
+ def market_locale_pairs
81
+ @market_locale_pairs ||= store.markets.flat_map do |market|
82
+ market.supported_locales_list.map { |locale| [market, locale] }
83
+ end
84
+ end
85
+
86
+ def default_locale
87
+ @default_locale ||= store.default_market&.default_locale || I18n.default_locale.to_s
88
+ end
89
+
90
+ # Read a translated attribute with fallback to default locale.
91
+ def translated(record, attribute, fallback_locale)
92
+ value = record.send(attribute)
93
+ return value if value.present?
94
+
95
+ record.send(attribute, locale: fallback_locale.to_sym)
96
+ rescue ArgumentError
97
+ value
98
+ end
99
+
100
+ def lowest_price(currency)
101
+ @prices_cache ||= {}
102
+ @prices_cache[currency] = product.price_in(currency)&.amount unless @prices_cache.key?(currency)
103
+ @prices_cache[currency]
104
+ end
105
+
106
+ def compare_at_price(currency)
107
+ @compare_at_cache ||= {}
108
+ @compare_at_cache[currency] = product.compare_at_amount_in(currency) unless @compare_at_cache.key?(currency)
109
+ @compare_at_cache[currency]
110
+ end
111
+
112
+ # Memoized — avoids N+1 when called per document
113
+ def cached_store_ids
114
+ @cached_store_ids ||= product.store_ids.map(&:to_s)
115
+ end
116
+
117
+ def cached_units_sold_count
118
+ @cached_units_sold_count ||= product.store_products.detect { |sp| sp.store_id == store.id }&.units_sold_count || 0
119
+ end
120
+
121
+ def variant_option_value_ids
122
+ variant_option_values_data.map(&:prefixed_id).uniq
123
+ end
124
+
125
+ # Use variants_including_master (matches reindex preload) instead of variants.includes
126
+ def variant_option_values_data
127
+ @variant_option_values_data ||= product.variants_including_master.flat_map(&:option_values).uniq
128
+ end
129
+ end
130
+ end
131
+ end
@@ -9,8 +9,8 @@ module Spree
9
9
 
10
10
  ApplicationRecord.transaction do
11
11
  assign_cart_attributes
12
- assign_address(:ship_address)
13
- assign_address(:bill_address)
12
+ assign_address(:shipping_address)
13
+ assign_address(:billing_address)
14
14
 
15
15
  cart.save!
16
16
 
@@ -34,10 +34,11 @@ module Spree
34
34
 
35
35
  def assign_cart_attributes
36
36
  cart.email = params[:email] if params[:email].present?
37
- cart.special_instructions = params[:special_instructions] if params.key?(:special_instructions)
37
+ cart.customer_note = params[:customer_note] if params.key?(:customer_note)
38
38
  cart.currency = params[:currency].upcase if params[:currency].present?
39
39
  cart.locale = params[:locale] if params[:locale].present?
40
40
  cart.metadata = cart.metadata.merge(params[:metadata].to_h) if params[:metadata].present?
41
+ cart.use_shipping = params[:use_shipping] if params.key?(:use_shipping)
41
42
  end
42
43
 
43
44
  def assign_address(address_type)
@@ -59,7 +60,7 @@ module Spree
59
60
  # Only revert to address state when shipping address changes.
60
61
  # Billing address updates (e.g. during payment) should not
61
62
  # destroy shipments and reset the checkout flow.
62
- revert_to_address_state if address_type == :ship_address && cart.has_checkout_step?('address')
63
+ revert_to_address_state if address_type == :shipping_address && cart.has_checkout_step?('address')
63
64
  cart.public_send(:"#{address_type}_attributes=", address_params)
64
65
  end
65
66
  end
@@ -0,0 +1,16 @@
1
+ class CreateSpreeRefreshTokens < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :spree_refresh_tokens do |t|
4
+ t.string :token, null: false
5
+ t.references :user, polymorphic: true, null: false
6
+ t.datetime :expires_at, null: false
7
+ t.string :ip_address
8
+ t.string :user_agent
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :spree_refresh_tokens, :token, unique: true
13
+ add_index :spree_refresh_tokens, :expires_at
14
+ add_index :spree_refresh_tokens, [:user_type, :user_id], name: 'idx_refresh_tokens_user'
15
+ end
16
+ end
@@ -4,6 +4,7 @@ cc_payment_method = Spree::Gateway::Bogus.where(
4
4
  active: true
5
5
  ).first_or_initialize
6
6
 
7
+ cc_payment_method.display_on = 'back_end'
7
8
  cc_payment_method.stores = Spree::Store.all
8
9
  cc_payment_method.save!
9
10
 
@@ -13,5 +14,6 @@ check_payment_method = Spree::PaymentMethod::Check.where(
13
14
  active: true
14
15
  ).first_or_initialize
15
16
 
17
+ check_payment_method.display_on = 'back_end'
16
18
  check_payment_method.stores = Spree::Store.all
17
19
  check_payment_method.save!
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.4.0.beta8'.freeze
2
+ VERSION = '5.4.0.beta10'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/core.rb CHANGED
@@ -110,13 +110,14 @@ module Spree
110
110
  gift_cards: :default,
111
111
  webhooks: :default,
112
112
  payment_webhooks: :default,
113
- api_keys: :default
113
+ api_keys: :default,
114
+ search: :default
114
115
  )
115
116
  end
116
117
 
117
- # @deprecated Spree.searcher_class is deprecated and will be removed in Spree 5.5.
118
+ # @deprecated Spree.searcher_class is deprecated and will be removed in Spree 5.5. Use Spree.search_provider instead.
118
119
  def self.searcher_class=(value)
119
- Spree::Deprecation.warn('Spree.searcher_class is deprecated and will be removed in Spree 5.5. Please remove it from your initializer.')
120
+ Spree::Deprecation.warn('Spree.searcher_class is deprecated and will be removed in Spree 5.5. Use Spree.search_provider instead.')
120
121
  @@searcher_class = value
121
122
  end
122
123
 
@@ -124,6 +125,18 @@ module Spree
124
125
  @@searcher_class
125
126
  end
126
127
 
128
+ # Search provider class name. Controls product search, filtering, and faceted navigation.
129
+ #
130
+ # Spree.search_provider = 'Spree::SearchProvider::Meilisearch'
131
+ #
132
+ def self.search_provider
133
+ @@search_provider ||= 'Spree::SearchProvider::Database'
134
+ end
135
+
136
+ def self.search_provider=(value)
137
+ @@search_provider = value.to_s
138
+ end
139
+
127
140
  # Returns the events adapter class used for publishing and subscribing to events.
128
141
  #
129
142
  # @example Using a custom adapter
@@ -0,0 +1,15 @@
1
+ namespace :spree do
2
+ namespace :search do
3
+ desc 'Reindex all products in the search provider'
4
+ task reindex: :environment do
5
+ Spree::Store.all.find_each do |store|
6
+ provider = Spree.search_provider.constantize.new(store)
7
+ total = store.products.count
8
+
9
+ puts "Reindexing #{store.name} (#{total} products)..."
10
+ provider.reindex(store.products)
11
+ puts "Done."
12
+ end
13
+ end
14
+ end
15
+ end